Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration from a status category to
85//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
86//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
87//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
88//! A missing mapped option refuses the write before either representation changes. Reads
89//! give a closed issue's reason precedence over its option, while an open issue's option
90//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
91//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
92//! list, so it preserves every existing option id and verifies the field and item
93//! assignments immediately afterwards. It counts a terminal category's mapped option as
94//! configured, because a terminal write refuses without it. No ordinary source read or
95//! write calls that mutation, whose
96//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
97//! and a mistake destroys every item's status. A status this board cannot represent is a
98//! refusal naming the status and the instance instead.
99//!
100//! `unknown` is disabled by default because this source cannot preserve an open-ended
101//! status word: it writes an existing board option and never
102//! creates an option. An operator may map `unknown` to one existing option, in which case
103//! every unknown word lands on that option and reads back as `unknown` under the option's
104//! name. This differs from `local-md`, which writes and reads the original word itself.
105//!
106//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
107//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
108//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
109//! finished tasks were only moved to a "Done" column would read 0% complete forever.
110// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
111//!
112//! # What this source declares, field by field
113//!
114//! One verdict per field of [`Capabilities`], and what `Native` means when this source
115//! says it. *Proven* means a shared journey drives it against the real
116//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
117//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
118//! [`capabilities`](TaskSource::capabilities) from parting.
119//!
120//! | Field | Verdict |
121//! | --- | --- |
122//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
123//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
124//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
125//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
126//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
127//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so neither `ProjectV2.items` nor any issue the search did not name is read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
128//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
129//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
130//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping`. |
131//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
132//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
133//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project 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. |
134//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
135//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
136//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
137//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
138//!
139//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
140//! source has documents and that its tasks have comments, both of which hold — and the three
141//! facts behind the uniform `Native` on the
142//! predicates beside it are recorded below rather than re-derived, because a reader who
143//! takes `Native` to mean *the remote service filters* will read that uniformity as a
144//! lie.
145//!
146//! First, the plugin contract defines `Support::Native` as *the source applies this
147//! predicate itself*, and says nothing about where it applies it. What the declaration
148//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
149//! — so that the engine may push it down and apply nothing of its own.
150//!
151//! Second, this source can keep that promise for every predicate at no additional API
152//! cost, because whichever of the reads below answers a query has already read every
153//! candidate that query will return before it filters anything. Filtering those items is
154//! in-process work over data already in hand.
155//!
156//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
157//! applied in process over what that question returned. A project filter has a relationship — a
158//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
159//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
160//! a search for metadata values, is the board-scoped issue search carrying the text and each
161//! value as quoted phrases; an origin is the board's own field filter over its origin field
162//! beside the same search for the id. **The text search narrows, and that is this source's
163//! declared semantics:** GitHub matches whole words where the substring rule this source and
164//! the local Markdown source confirm with would match inside one, so an item holding the text
165//! only inside a longer word is never a candidate. Every item returned does contain the text.
166//! A project query's text, and a document query's scoped to no project, is that same search
167//! and narrows on the same terms, its candidates confirmed by their kind as well.
168//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
169//! so those three are applied in process over the candidates, and a query carrying none of
170//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
171//! engine compensate for work this source has already done, and declaring `projects` native
172//! while ignoring the filter (which this source once did) silently returns another project's
173//! tasks, because the engine trusts the declaration and applies nothing locally.
174//!
175//! # The three ways this source reaches an item, and what each costs
176//!
177//! A board read is charged for what its *nested* connections could return rather than for
178//! what was asked, so one whole-board read costs the same whether the question was about
179//! one project or about all of them. That is why a question about one project is never
180//! answered by reading the board:
181//!
182//! | The question | What is sent | What it costs |
183//! | --- | --- | --- |
184//! | 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 |
185//! | 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 |
186//! | 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 |
187//! | 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 |
188//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
189//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
190//! | 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 |
191//! | 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 |
192//! | 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 |
193//! | 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 |
194//! | 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 |
195//! | 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 |
196//!
197//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
198//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
199//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
200//! equal to declared points equal to the row. They include the origin lookup and the
201//! field/repository discovery a create needs. A bound re-copy changes status, priority,
202//! content and metadata; comment recount means a subsequent detail read. Each request here
203//! costs one declared point. A membership beyond the embedded page can additionally require
204//! the one-point membership recovery described above. A bound re-copy of a task filed under a
205//! project adds one read, the engine confirming that project's link by its own id once per
206//! command; and the same-source far ends a write newly names — those that do not already block
207//! the item, whose own read answered for them — are read together by their own ids,
208//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
209//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
210//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
211//!
212//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
213//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
214//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
215//! holds both halves.
216//!
217//! **An existing item is written body last.** A bound re-copy and a `task update` send its
218//! board fields first — the `Status` option and the `Priority` together, in one request — then
219//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
220//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
221//! undoing an earlier field when a later one fails, so that order is what makes a write
222//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
223//! the one piece of metadata written before the body, an origin a copy re-points, is put back
224//! when a later write is refused — and when putting it back is refused too, the write's own
225//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
226//! of those writes in turn, whole and as one aliased field failing after the one before it.
227//!
228//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
229//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
230//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
231//!
232//! - **A board is accepted at creation but its item is not answered, so a create still files
233//!   the issue itself: a new copy is 5 requests, and 4 with `--create`.**
234//!   `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
235//!   Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
236//!   The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
237//!   against a real board on 2026-10-01 with a create sending the board there and reading the
238//!   item off the payload's `Issue.projectItems`: every one of its four creates answered with
239//!   no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
240//!   refused "Content already exists in this project" — GitHub had filed the issue after
241//!   answering, and refuses a second filing rather than answering with the item it holds. A
242//!   create therefore sends no `projectV2Ids` and files the issue with
243//!   `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
244//!   real is the read before it: the board's fields and the repository's id together, in
245//!   [`graphql::CREATION_CONTEXT`], at the point the repository is known.
246//! - **A comment still reads its target first, so a comment is 2 requests.**
247//!   `AddCommentInput.subjectId: ID!` is declared there with
248//!   `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
249//!   "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
250//!   project's issue, a document's issue, an issue on no board of this source and a pull
251//!   request all are: GitHub writes the comment, so there is no refusal to map into "that is
252//!   not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
253//!   refuses those by name.
254//!
255//! | Verb | Requests / points | Documents |
256//! | --- | --- | --- |
257//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
258//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
259//! | 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 |
260//! | 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 |
261//! | 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 |
262//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
263//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
264//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
265//! | recount | 1 | ISSUE_DETAIL |
266//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
267//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
268//! | content | 2 | ISSUE, UPDATE_ISSUE |
269//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
270//! | 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 |
271//! | record only | 1 | ISSUE |
272//!
273//! <!-- github-search-paging:start -->
274//! Board-scoped text, metadata, project-name and comment-activity searches send every
275//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
276//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
277//! needs rows. A page is never resized to the rows still needed: GitHub orders one
278//! search differently at different page sizes, so one fixed size makes a paged walk
279//! send exactly the requests one whole read sends, and the answer's order is the order
280//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
281//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
282//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
283//! returned and fetched pages: a limit is sliced from the pages it needs, and local
284//! confirmation can require more candidates than matching rows. Walking all pages
285//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
286//! cursor and how far into that page the last answer stopped, and resumes in the same
287//! process or a new one, without duplicates or gaps. It carries no rows: one process
288//! sends each page's search once, and a new process re-reads only the page it resumes
289//! in, then sends a further page once, never as a re-read, only when its limit still
290//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
291//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
292//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
293//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
294//! not required to include the original process's writes still omitted by the index.
295//! <!-- github-search-paging:end -->
296//!
297//! The board half of an issue — its board item's id, its `Status` option and this
298//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
299//! an item reached any of those ways resolves through the same
300//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
301//! same status, the same labels and the same qualified id. That connection comes back a
302//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
303//! on the page in hand and — only if that page reports more of the connection — in the
304//! last row's read of that one issue's memberships, resumed from the page's own cursor and
305//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
306//! report, which is what keeps an id naming another repository's issue from being answered
307//! as an item of this board; and because the page is where the search starts rather than
308//! where it ends, that answer is one about a connection read to exhaustion and never about
309//! an unread page. Nothing costs the extra read but an issue on more boards than a page
310//! holds: an issue this board really does not hold reports no next page, so its
311//! memberships are already exhausted where they arrived.
312//!
313//! **No document here selects the board's own `Labels` field, and nothing is lost by
314//! that.** An item's labels are read from its content alone, wherever that content is
315//! reached: the three documents above select `Issue.labels` on the fragment, and
316//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
317//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
318//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
319//! create one, and `ProjectV2FieldValue` — the whole of what
320//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
321//! it from the content, for every content type it exists on, and there is nothing it can
322//! hold that the content does not already say: for an `Issue` it *is* that issue's own
323//! labels, so selecting it beside them unions a set with itself.
324//!
325//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
326//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
327//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
328//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
329//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
330//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
331//! label set, and that set is the fixture issue's own, by
332//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
333//! item whose content is a draft reports an empty set, by
334//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
335//! selection is held over [`graphql::DOCUMENTS`] by
336//! `no_document_selects_the_boards_own_labels_field`.
337//!
338//! The whole-board row is still the board's own item connection, and deliberately: a
339//! **draft** board item is not an issue, so no search can list one, and the reads that have
340//! to answer for the whole board are the ones whose cost is the board's size anyway.
341//!
342//! **A question about one item this source already names by id never lists the board.**
343//! Whether that item is on this board, and what its board fields are, is answered by reading
344//! that item — its own `Issue.projectItems`, walked to exhaustion by
345//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
346//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
347//! holds. That covers a write's destination, the project a new item is filed under, a
348//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
349//! and the delete that takes back an item a copy made. What such a write needs of the board
350//! and the item does not carry — the board's id, the `Status` and origin field definitions —
351//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
352//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
353//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
354//! minutes. Scanning this host's 842-item board has refused a document copy and an update
355//! even though the items' own reads named that board. A scan there gives the wrong answer
356//! as well as paying for every page. So a `board.items` lookup does not belong on any of
357//! those paths.
358//!
359//! **What a read may return is capped too, and that cap is on the document rather than on
360//! the board.** GitHub limits the number of nodes **one query may return** to
361//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
362//! an error naming the connection the count crossed at, not a slow or a partial result.
363//! Every board this source reads is refused the same way, so no board is too big for these
364//! documents and none is small enough to save one that is over.
365//!
366//! The count is arithmetic over the document's own text: each connection contributes the
367//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
368//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
369//! restate them — `github-graphql-node-count` implements them, and
370//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
371//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
372//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
373//! same text on every run and fails naming any that reaches the limit — so a connection
374//! added to a shared fragment is caught there rather than by GitHub.
375//!
376//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
377//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
378//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
379//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
380//! effectively squared there, which is why it is the one the limit is most sensitive to.
381//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
382//! page of memberships misses is recovered by one further read rather than refused, so it
383//! buys a bound every read pays for at the price of a request only a multi-board issue
384//! pays.
385//!
386//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
387//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
388//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
389//! `cost` is rate-limit points, metered per hour across everything one credential does; it
390//! is what the two limiters [`Limiter`] tells apart meter, and a document under
391//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
392//! that second number, and `tests/point_cost.rs` pins every document in
393//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
394//! one under, the pin itself is the check. The credentialed lane reconciles both figures
395//! against GitHub's own, off a probe it already sends.
396//!
397//! **What is pinned that way is a per-document price and never a session's.** The record in
398//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
399//! **requests** and **worst-case nodes** — and neither is points. What one whole session
400//! consumes of the hourly point allowance is observable only from a credentialed run's own
401//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
402//! and what `tests/live.rs` prints at the end of every run.
403//!
404//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
405//!
406//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
407//! enumerations of a board can supply one alone.** Resolving a node id is strongly
408//! consistent, so a read by id and a project's own sub-issues are already current. The
409//! other two are not, and they are behind by different amounts and in different directions:
410//!
411//! - GitHub's **issue search** is an index and answers a write made moments ago with the
412//!   value from before it — usually for a second or two.
413//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
414//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
415//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
416//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
417//!
418//! That second one is a measurement rather than a caution. This repository's own
419//! credentialed journey writes a project and waits for the board to report it, then writes a
420//! task and waits for the same thing seconds later on the same board: the project wait is
421//! answered through the search and converged in two or three attempts in each of three runs,
422//! and the task wait is answered through `ProjectV2.items` and converged in none of them
423//! inside thirty. Separately, an item added to a second and larger board was read back by
424//! `Issue.projectItems` on that board's own id while every one of that connection's nine
425//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
426//! through the lagging one alone is what had a board read deny an issue that had certainly
427//! landed on it.
428//!
429//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
430//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
431//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
432//! search reports what the projection is behind on. What closes the last
433//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
434//! source answers is completed with what this process itself wrote, so an item created
435//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
436//! nothing is written down, and the record dies with the process. **A wait that has to
437//! observe GitHub's own data cannot be answered from that record** — which is why the
438//! credentialed journey asks through a source built afresh, and why the union above rather
439//! than a longer wait is what makes such a wait converge.
440//!
441//! **A narrowed read is the same bargain, stated for each of the three predicates it
442//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
443//! than walking the board, and every such answer is completed with what this process wrote —
444//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
445//! each filtered by the same predicates as the rest — so an item this command wrote a moment
446//! ago is returned by a query that matches it whether or not the index has caught up. An item
447//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
448//! consistent. What is left is stated rather than papered over:
449//!
450//! | Read | Finds | Behind by |
451//! | --- | --- | --- |
452//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
453//! | 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 |
454//! | 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 |
455//! | origin, third read | this process's own writes | nothing |
456//!
457//! So an origin carrier another process added within the last second or two, before either
458//! index has it, can be missing from an origin query, and one written by the release before
459//! this one — its origin in the field alone — can be missing for as long as the board's own
460//! item connection is behind on it. A copy that must not duplicate its own earlier write
461//! relies on the link it records, not on either index. **A board draft is not an issue**, so
462//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
463//! lists one, the origin lookup drops any the board's own field filter names, and one this
464//! process wrote is not added back either.
465//!
466//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
467//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
468//! body's metadata slot, so the issue search can find it in seconds. The field is
469//! authoritative: this source reads an item's origin from the field alone, so a slot that
470//! disagrees with it, or holds one where the field holds none, is never read as a second
471//! origin — and the release before this one reads the slot, drops that key's copy for the
472//! field's, and sees the same one origin.
473//!
474//! Filtering happens before paging, so a page of a filtered result is a page of the
475//! survivors rather than the survivors of a page. Label matching and the substring rule a
476//! text candidate is confirmed by answer the same question the same way the local Markdown
477//! source's do; which candidates a text search has to confirm is GitHub's word match, which
478//! is the one place the two sources can answer the same text differently.
479//!
480//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
481//! has one source, `capabilities`, and the note above is the reasoning behind it rather
482//! than a second copy of it: without the three facts recorded here a reader takes the
483//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
484//! crate's own capabilities test, which pins every field of it against a fully spelled-out
485//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
486//! fails to compile there rather than going unasserted. -->
487//! The fixture-server tests above run wherever this crate is selected; the credentialed
488//! lane runs in the same required check, beside them, and can fail it — it verifies the
489//! 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,
490//! one filed under neither, a label on one of the three and a closed status on another —
491//! because that shape is what tells an honoured predicate from an ignored one: a board
492//! holding a single project answers a project filter the same way whether or not this
493//! source applies it, which is exactly how the defect above went unseen.
494//!
495//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
496//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
497//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
498//! nominated is what keeps a credentialed write lane off a board and a repository nobody
499//! nominated; it never asks GitHub which project was updated most recently. Before it
500//! starts, the lane also clears any item titled — and any repository label named — the way
501//! it titles and names its own artifacts, which is self-healing after an interrupted run:
502//! a process killed between its writes and its cleanup leaves artifacts the next run
503//! removes.
504//!
505//! # What a session of requests costs, and where the report is
506//!
507//! This source records **every** request it sends into [`accounting::Accounting`], at
508//! `send_once` — the one place a request leaves this crate, which is why a read path added
509//! later is counted without anybody remembering to count it. That is the whole of what this
510//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
511//! session's spend is arrived at, and what it deliberately does not know are set out.
512//!
513//! What one whole session of the live journey costs, counted that way against this crate's
514//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
515//! reduction it came out of, and with what it does and does not say about rate-limit points.
516//!
517//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
518//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
519//! code path — no environment variable, no feature, no build configuration — because an
520//! instrument nobody switches on measures nothing, and
521//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
522//! source's counts the whole session rather than this source's share. The credentialed lane
523//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
524//! passed or failed.
525//!
526//! **A live session refuses to start unless the account can afford it.** Before it does any
527//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
528//! GitHub documents as not counting against the REST rate limit and which answers both of
529//! its budgets at once — and starts only if, for each of them, what remains minus this
530//! session's estimated cost is still at least
531//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
532//! allowance. A session that cannot **declines**: it did not run, so it is
533//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
534//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
535//! waiting for the budget to come back. The estimate is derived offline from
536//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
537//! which is also where the published rule that model rests on is cited; the accounting
538//! above records the gate's own read like any other request, and
539//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
540//!
541//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
542//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
543//! text, which is what lets it run on every platform and on a pull request from a fork with
544//! no credential — and that is what actually stops a regression merging. But an offline
545//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
546//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
547//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
548//! number of nodes this query may return"* and whose `cost` is what that document would
549//! spend, both for a document **without executing it**, and the lane asks it for every query
550//! document this source sends, under the largest bindings this source sends, and fails when
551//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
552//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
553//! one; the offline pins still cover it. It records what those calls reported about the
554//! account's own allowance, because whether asking is free is a thing to observe rather than
555//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
556//! and `cost` is metered against an hourly allowance the accounting above reads off a
557//! credentialed run's own response headers.
558//!
559//! **GitHub has two rate limiters and this source is refused by both, so nothing here
560//! treats them as one thing.** The primary budget is the hourly allowance `gh api
561//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
562//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
563//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
564//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
565//! one part of.
566#![deny(missing_docs)]
567
568use std::collections::BTreeMap;
569use std::sync::{Arc, Mutex};
570use std::time::{Duration, Instant};
571
572use chrono::{DateTime, Utc};
573use onetaskgraph_plugin_api::{
574    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
575    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
576    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
577    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
578    SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskDetailRead, TaskQuery,
579    TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField,
580    WriteSupport,
581};
582use reqwest::{Client, StatusCode, Url};
583use schemars::{Schema, schema_for};
584use secrecy::{ExposeSecret, SecretString};
585use serde::{Deserialize, Serialize};
586use serde_json::{Value, json};
587
588pub mod accounting;
589
590use accounting::Accounting;
591
592/// The registry name for this plugin.
593pub const KIND: &str = "github-projects";
594/// GitHub's maximum connection page size.
595pub const MAX_PAGE_SIZE: u32 = 100;
596/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
597/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
598/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
599pub const SEARCH_PAGE_SIZE: u32 = 20;
600/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
601/// its comments: the largest batch the node-count model prices at one point.
602///
603/// Each aliased item is resolved once, and what GitHub charges for it is the connections
604/// under it — its labels, its page of board memberships, the field values of each of those
605/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
606/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
607/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
608/// one point and fails if one item more would still be priced at one.
609pub const DETAIL_BATCH: usize = 24;
610
611/// The most nodes any one document this source sends may be asked to return.
612///
613/// GitHub's own published per-query ceiling, taken from
614/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
615/// workspace cannot hold a stale copy of somebody else's number. A query above it is
616/// **refused before it is executed**, whoever is asking and whatever board they are
617/// asking about — so this is a bound on the documents rather than a budget that runs out.
618///
619/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
620/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
621/// everything the credential does — two numbers against two limits, and this constant
622/// bounds only the first. The second is computed offline too, per document:
623/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
624/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
625/// lane. There is no constant like this one to hold a price under, because points are an
626/// hourly allowance rather than a per-call bound.
627///
628/// Neither is a session's price. What `session-cost.md` records of a whole session is its
629/// **requests** and its **worst-case nodes**; what a whole session spends in points is
630/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
631/// [`accounting`]. The module section on the three ways this source reaches an item says how
632/// the count is arrived at, and which of the page sizes below decide it.
633pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
634
635/// Nested connection size for the connections that hang off one item.
636///
637/// It multiplies through every document that reaches an item under a page — the count
638/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
639/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
640/// every document under these constants and fails naming any that reaches the limit, so
641/// raising this is caught there rather than by GitHub.
642const NESTED_PAGE_SIZE: u32 = 50;
643/// How many of one issue's board memberships are read when an issue is reached directly.
644///
645/// An issue reached through a search or through its own node id carries its board half in
646/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
647/// under a page of issues, so every point of it multiplies through the whole document and
648/// is paid for whether or not any issue is on a second board — which is why it is
649/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
650///
651/// **Three, because what a page misses is now recovered rather than refused**, and the
652/// recovery is what the value is chosen against. An issue whose entry for this board sits
653/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
654/// that page's own cursor — so the value trades a bound every read pays for a request only
655/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
656/// boards would pay that request *per issue*, which is order N against the one page per
657/// hundred issues a read costs today. At three it is only reached by an issue on four or
658/// more boards at once, which keeps the recovery path exceptional rather than routine for
659/// a plausible deployment.
660const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
661/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
662/// its two connections for.
663///
664/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
665/// second is a duplicate a copy already takes the first of. Both connections are walked to
666/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
667/// never what the answer is. It is small because every point of it is paid on every lookup,
668/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
669/// worst-case nodes than the one whole-board read they replaced.
670const ORIGIN_PAGE_SIZE: u32 = 3;
671
672pub use github_graphql_node_count::{NodeCountError, Variables};
673
674/// The largest value this source can bind to each page-size variable its documents name.
675///
676/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
677/// constant above it wherever a caller's own limit could reach it — `$first` at
678/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
679/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
680/// not one configuration of it, which is what makes a bound computed under it a bound on
681/// every read.
682pub fn largest_page_sizes() -> Variables {
683    Variables::from([
684        ("first".to_owned(), MAX_PAGE_SIZE),
685        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
686        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
687        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
688    ])
689}
690
691/// The most nodes `document` could be asked to return, by GitHub's published rules.
692///
693/// Computed offline from the document's own text under [`largest_page_sizes`] — no
694/// network, no credential and no schema — by
695/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
696/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
697/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
698///
699/// # Errors
700///
701/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
702/// no single operation, or binds a page size this source does not name — each of which is
703/// a defect in the document rather than a number.
704pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
705    node_count(document, &largest_page_sizes())
706}
707
708/// The most rate-limit points one call of `document` could spend, by GitHub's published
709/// rules.
710///
711/// Computed offline from the document's own text under [`largest_page_sizes`] — no
712/// network, no credential and no schema — by
713/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
714/// This is `cost`, metered **per hour** against the allowance one credential shares across
715/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
716/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
717/// under, so what `tests/point_cost.rs` does with it is pin every document in
718/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
719/// figures against GitHub's own reported `cost`.
720///
721/// # Errors
722///
723/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
724/// no single operation, or binds a page size this source does not name — each of which is
725/// a defect in the document rather than a number.
726pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
727    github_graphql_node_count::point_cost(document, &largest_page_sizes())
728}
729
730/// The most nodes `document` could be asked to return under `variables`.
731///
732/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
733/// [`accounting`] is this under the bindings one request really sent — one spelling of the
734/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
735/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
736///
737/// # Errors
738///
739/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
740/// no single operation, or binds a page size `variables` does not name.
741pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
742    github_graphql_node_count::node_count(document, variables)
743}
744
745/// The issue-title prefix that makes a board issue a document.
746///
747/// A GitHub Projects board has no document type — it holds issues — so the discriminator
748/// is the title, and this is the whole of it: an issue whose title begins with these bytes
749/// is a document and every other issue is the task or project the sub-issue rule makes it.
750///
751/// It is spelled **once**, here, and read rather than restated everywhere else — including
752/// by the shared journeys, which take it from this constant so a board fixture cannot
753/// drift from what this source reads. `docs/metadata.md` records the two consequences that
754/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
755/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
756/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
757pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
758
759/// Exact GraphQL query documents issued by this plugin.
760///
761/// Keeping the production documents here lets the pinned-schema test validate the same
762/// bytes that are sent to GitHub, rather than a test-only copy which could drift
763/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
764/// field, and its guarded caller always supplies the complete existing option set with ids.
765pub mod graphql {
766    /// The board half of one item: the field values every document here reads it from.
767    ///
768    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
769    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
770    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
771    /// *the same value*, because
772    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
773    /// one path. Three spellings of it is what would drift, so there is one.
774    ///
775    /// The `Status` option and this source's own origin text field are the whole of it. It
776    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
777    /// content, so it holds nothing the content's own `labels` do not already say, and it
778    /// would sit a label connection two page sizes deep.
779    macro_rules! board_item_values {
780        () => {
781            r#"fieldValues(first:$nestedFirst){nodes{
782          ... on ProjectV2ItemFieldSingleSelectValue{name field{
783            ... on ProjectV2SingleSelectField{id name options{id name}}
784          }}
785          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
786        }pageInfo{hasNextPage}}"#
787        };
788    }
789
790    /// Everything this source reads about one issue, wherever it reaches that issue.
791    ///
792    /// A macro rather than a constant so the three documents below can `concat!` it: one
793    /// spelling of these fields is what makes an issue read through the board-scoped
794    /// search, through its own node id, and through its project's sub-issue relationship
795    /// resolve to *the same* item, which is the whole of what
796    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
797    ///
798    /// `projectItems` is what carries the board half of an issue: the board item's own id
799    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
800    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
801    /// issue rather than on the board, which is what makes the cost of a read proportional
802    /// to what was asked for instead of to the board's size.
803    ///
804    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
805    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
806    /// not on that page: a page here is where the search for the entry starts rather than
807    /// where it ends.
808    ///
809    /// It does **not** select the board's `Labels` field value, and that is the whole of
810    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
811    /// a label connection there sits under `fieldValues` under `projectItems` under a page
812    /// of issues, spending `$nestedFirst` twice down one path, and took
813    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
814    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
815    /// above, and that connection is where every label this source reports comes from. No
816    /// document in this module selects the board field any longer, [`BOARD`] included; the
817    /// module documentation records why nothing it could have held is lost.
818    macro_rules! board_issue {
819        () => {
820            concat!(
821                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
822      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
823      projectItems(first:$boardItems){nodes{id project{id number}
824        "#,
825                board_item_values!(),
826                r#"}pageInfo{hasNextPage endCursor}}}"#
827            )
828        };
829    }
830
831    /// Every issue of one board, found by a search scoped to that board.
832    ///
833    /// This is how the projects a board holds are listed, and it selects no `items`
834    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
835    /// container walked page by page, so nothing nested inside a board item is paid for.
836    /// Which of the issues it returns is a project is then read off `parent` — GitHub
837    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
838    /// discriminator has to be applied to the field, which is a scalar on the issue and
839    /// costs nothing.
840    pub const SEARCH_ISSUES: &str = concat!(
841        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
842      search(query:$search,type:$type,first:$first,after:$after){
843        pageInfo{hasNextPage endCursor}
844        nodes{__typename ...BoardIssue}
845      }
846    }"#,
847        board_issue!()
848    );
849
850    /// What a dependency read selects of each far end: enough to say which kind of item it
851    /// is, its body included for the kind marker.
852    macro_rules! related_issue {
853        () => {
854            " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
855        };
856    }
857
858    /// One issue by its own node id, which is what a qualified id names here — with what a
859    /// write of it needs and the issue does not carry in `board_issue!`: the field
860    /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
861    ///
862    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
863    /// answers a write made moments ago with the value from before it, and resolving a node
864    /// id does not.
865    ///
866    /// **Why those two ride here and not on the fragment.** A copy or an update of an item
867    /// reads it by its own id, and with them that one read answers everything the write
868    /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
869    /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
870    /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
871    /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
872    /// they sit under one item, and this read is still one point.
873    pub const ISSUE: &str = concat!(
874        r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
875      node(id:$id){__typename ...BoardIssue ... on Issue{
876        boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
877          ... on ProjectV2SingleSelectField{__typename id name options{id name}}
878          ... on ProjectV2Field{__typename id name}
879        }pageInfo{hasNextPage}}}}}
880        blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
881      }}
882    }"#,
883        board_issue!(),
884        related_issue!()
885    );
886
887    /// One project's tasks: the sub-issues of the issue that project is.
888    ///
889    /// The work this costs is the project's own size. Nothing about it grows as the board
890    /// gains projects, or as those projects gain tasks.
891    pub const SUB_ISSUES: &str = concat!(
892        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
893      node(id:$id){__typename
894        ... on Issue{subIssues(first:$first,after:$after){
895          pageInfo{hasNextPage endCursor}
896          nodes{__typename ...BoardIssue}
897        }}}
898    }"#,
899        board_issue!()
900    );
901
902    /// What a read of the board's own `items` selects of each item's content.
903    ///
904    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
905    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
906    /// content by one spelling.
907    macro_rules! board_item_content {
908        () => {
909            r#" content{
910        ... 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}}}
911        ... on PullRequest{__typename id}
912        ... on DraftIssue{__typename id title body createdAt updatedAt}
913      }"#
914        };
915    }
916
917    /// Reads the board's fields and one page of its items.
918    pub const BOARD: &str = concat!(
919        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
920      owner:repositoryOwner(login:$owner){
921        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
922      }
923    } fragment Board on ProjectV2 { id title
924      fields(first:$nestedFirst){nodes{
925        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
926        ... on ProjectV2Field{__typename id name}
927      }pageInfo{hasNextPage}}
928      items(first:$first,after:$after){nodes{id "#,
929        board_item_values!(),
930        board_item_content!(),
931        r#"} pageInfo{hasNextPage endCursor}}
932    }"#
933    );
934
935    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
936    /// the board.
937    ///
938    /// **`originItems`** is the board's own items narrowed by its own field filter —
939    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
940    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
941    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
942    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
943    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
944    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
945    /// fresh `addProjectV2ItemById` the way that connection does.
946    ///
947    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
948    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
949    /// indexes that comment, and the index catches up with a write in a second or two rather
950    /// than in minutes, so it finds a carrier another process wrote that the first read is
951    /// still behind on.
952    ///
953    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
954    /// — and resumes from its own cursor; a connection already walked to its end is resumed
955    /// from its last cursor, which answers an empty page. Every candidate either read returns
956    /// is confirmed against its own origin field before it is reported, so a token match of
957    /// the search or anything else the filter admits never is.
958    ///
959    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
960    /// own whole reads counts this one among them.
961    pub const ORIGIN_LOOKUP: &str = concat!(
962        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
963      originItems:repositoryOwner(login:$owner){
964        ... on ProjectV2Owner{projectV2(number:$number){
965          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
966        board_item_values!(),
967        board_item_content!(),
968        r#"} pageInfo{hasNextPage endCursor}}
969        }}
970      }
971      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
972        pageInfo{hasNextPage endCursor}
973        nodes{__typename ...BoardIssue}
974      }
975    }"#,
976        board_issue!()
977    );
978
979    /// The board's own id and field definitions, and not one of its items.
980    ///
981    /// What a write needs of the board when the item it writes does not say: the id a field
982    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
983    /// origin fields. It selects no `items`, so what it costs is the board's field list
984    /// however many items the board holds — and it decides nothing about which items those
985    /// are, which is the question a read of one item by its own id answers instead.
986    ///
987    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
988    /// board's item reads by their root counts this one among them.
989    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
990      boardFields:repositoryOwner(login:$owner){
991        ... on ProjectV2Owner{projectV2(number:$number){id
992          fields(first:$nestedFirst){nodes{
993            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
994            ... on ProjectV2Field{__typename id name}
995          }pageInfo{hasNextPage}}
996        }}
997      }
998    }"#;
999
1000    /// One board draft by its own node id, with the board item it sits in.
1001    ///
1002    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1003    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1004    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1005    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1006    /// board listing hands it to, and nothing has to list the board to find one.
1007    pub const DRAFT: &str = concat!(
1008        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1009      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1010        projectV2Items(first:$boardItems){nodes{id project{id number}
1011        "#,
1012        board_item_values!(),
1013        r#"}pageInfo{hasNextPage endCursor}}}}
1014    }"#
1015    );
1016
1017    /// One issue's board memberships alone, walked past the page a read of it carried.
1018    ///
1019    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1020    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1021    /// boards than that page holds may have this board's entry past its end. This asks that
1022    /// one issue for its memberships and nothing else — the caller already holds the issue —
1023    /// so an answer of "this board does not hold it" is only ever given about a connection
1024    /// read to exhaustion.
1025    ///
1026    /// It selects the board item's id, its project number and the same
1027    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1028    /// very same resolver: an issue recovered this way reports the same title, the same
1029    /// status, the same labels and the same qualified id as one whose entry was on the
1030    /// page.
1031    ///
1032    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1033    /// multiplies through it and the membership connection can be walked at
1034    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1035    /// further request for any issue a person really keeps.
1036    pub const ISSUE_BOARD_ITEMS: &str = concat!(
1037        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1038      node(id:$id){
1039        ... on Issue{projectItems(first:$first,after:$after){
1040          nodes{id project{id number}
1041        "#,
1042        board_item_values!(),
1043        r#"}
1044          pageInfo{hasNextPage endCursor}}}
1045      }
1046    }"#
1047    );
1048    /// Resolves the configured repository's node id, which creating an issue requires.
1049    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1050    /// What creating an issue needs and has not read yet: the board's own id and field
1051    /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1052    /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1053    ///
1054    /// Sent at the point a create knows which repository it is for, when neither half is
1055    /// already known to this process; a create needing only one of them sends that one's own
1056    /// document. Neither half is kept past the process: a field's option ids are re-minted by
1057    /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1058    /// status.
1059    pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1060      boardFields:repositoryOwner(login:$owner){
1061        ... on ProjectV2Owner{projectV2(number:$number){id
1062          fields(first:$nestedFirst){nodes{
1063            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1064            ... on ProjectV2Field{__typename id name}
1065          }pageInfo{hasNextPage}}
1066        }}
1067      }
1068      repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1069    }"#;
1070    /// Reads both dependency directions for one issue, with each far end's own kind — and
1071    /// the issue's own body, which is where an edge to another source is recorded, so that
1072    /// half of a dependency read needs no second read of the issue or of the board.
1073    pub const ISSUE_DEPENDENCIES: &str = concat!(
1074        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1075      ... on Issue{body
1076        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1077        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1078      }}}"#,
1079        related_issue!()
1080    );
1081    /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1082    /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1083    /// answered when it was.
1084    pub const CREATE_ISSUE: &str =
1085        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1086    /// Puts an existing issue on the configured board.
1087    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1088    /// Updates an issue's visible fields and its open or closed state in one call.
1089    pub const UPDATE_ISSUE: &str =
1090        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1091    /// Updates an existing draft's user-visible fields.
1092    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1093    /// Updates a text or single-select value on one project item.
1094    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}}}}}}}}"#;
1095    /// Writes up to three board fields and an optional clear in one ordered mutation.
1096    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}}}"#;
1097    /// Clears one project item's value of one field, which is what a `none` priority is.
1098    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}}}}}}}}"#;
1099    /// Creates one single-select field with its options. Only the guarded field setup may use
1100    /// this document, and only for a field the board lacks.
1101    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1102    /// Replaces a single-select field's options. Only the guarded field setup — the
1103    /// `status-options` and `fields` operations — may use this document, because GitHub
1104    /// treats the input as the complete option list.
1105    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1106    /// A fresh snapshot of the Status field and every board item's assignment.
1107    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}}}}}}"#;
1108    /// Files one issue under another as a sub-issue, which is what project membership is.
1109    pub const ADD_SUB_ISSUE: &str =
1110        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1111    /// Takes one issue back out of its parent.
1112    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1113    /// Adds GitHub's native issue blocked-by relationship.
1114    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1115    /// Removes one native issue blocked-by relationship.
1116    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1117    /// Deletes one issue, which takes its board item with it.
1118    ///
1119    /// The engine sends this in one situation only: undoing a copy that could not finish,
1120    /// over the items that same copy created. Deleting the issue removes the board item
1121    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1122    pub const DELETE_ISSUE: &str =
1123        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1124
1125    /// Everything this source reads about one issue comment, wherever it reaches one.
1126    ///
1127    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1128    /// and a comment just edited are handed to one mapper, so they are selected by one
1129    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1130    /// longer exists, and `login` is the one member every kind of actor carries.
1131    macro_rules! issue_comment {
1132        () => {
1133            "id author{login} createdAt updatedAt body url"
1134        };
1135    }
1136
1137    /// One task's comments: a page of its issue's own `comments` connection.
1138    ///
1139    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1140    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1141    /// list every time somebody edited it; left unordered the connection answers in the order
1142    /// the comments were written, which is the order GitHub documents for the same collection
1143    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1144    /// node count and the caller's own page size is pushed straight down.
1145    pub const ISSUE_COMMENTS: &str = concat!(
1146        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1147        issue_comment!(),
1148        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1149    );
1150    /// One issue by its own node id, with a page of its comments: what `task show` and a
1151    /// comment listing read, in one request.
1152    ///
1153    /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1154    /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1155    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1156    /// connection there would multiply through both of those documents' price, and neither
1157    /// needs one.
1158    pub const ISSUE_DETAIL: &str = concat!(
1159        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1160      node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1161        issue_comment!(),
1162        r#"}pageInfo{hasNextPage endCursor}}}}
1163    }"#,
1164        board_issue!()
1165    );
1166
1167    /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1168    /// page of its comments when `$comments` asks for them.
1169    macro_rules! issue_details_alias {
1170        ($n:literal) => {
1171            concat!(
1172                "\n      i",
1173                stringify!($n),
1174                ":node(id:$id",
1175                stringify!($n),
1176                "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1177                issue_comment!(),
1178                "}pageInfo{hasNextPage endCursor}}}}"
1179            )
1180        };
1181    }
1182
1183    /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1184    /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1185    ///
1186    /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1187    /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1188    /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1189    /// neither — so every connection under it would be priced at nothing and the pin in
1190    /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1191    /// one-item read the model already prices, so the batch costs what its aliases cost.
1192    ///
1193    /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1194    /// slots it has no item for to the last item it does, and reads that item again; the
1195    /// price is the document's, whatever its variables, so a short batch costs what a full
1196    /// one does and nothing more.
1197    pub const ISSUE_DETAILS: &str = concat!(
1198        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!){"#,
1199        issue_details_alias!(0),
1200        issue_details_alias!(1),
1201        issue_details_alias!(2),
1202        issue_details_alias!(3),
1203        issue_details_alias!(4),
1204        issue_details_alias!(5),
1205        issue_details_alias!(6),
1206        issue_details_alias!(7),
1207        issue_details_alias!(8),
1208        issue_details_alias!(9),
1209        issue_details_alias!(10),
1210        issue_details_alias!(11),
1211        issue_details_alias!(12),
1212        issue_details_alias!(13),
1213        issue_details_alias!(14),
1214        issue_details_alias!(15),
1215        issue_details_alias!(16),
1216        issue_details_alias!(17),
1217        issue_details_alias!(18),
1218        issue_details_alias!(19),
1219        issue_details_alias!(20),
1220        issue_details_alias!(21),
1221        issue_details_alias!(22),
1222        issue_details_alias!(23),
1223        "\n    }",
1224        board_issue!()
1225    );
1226
1227    /// Which issue one comment is on, read before that comment is edited or removed.
1228    ///
1229    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1230    /// comment id given against the wrong task would change a comment on another issue.
1231    pub const COMMENT_ISSUE: &str =
1232        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1233    /// Adds one comment to an issue, signed as the account the token belongs to.
1234    pub const ADD_COMMENT: &str = concat!(
1235        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1236        issue_comment!(),
1237        r#"}}}}"#
1238    );
1239    /// Replaces the body of one issue comment.
1240    pub const UPDATE_COMMENT: &str = concat!(
1241        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1242        issue_comment!(),
1243        r#"}}}"#
1244    );
1245    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1246    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1247
1248    /// Every document above, with what this source is doing when it sends one.
1249    ///
1250    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1251    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1252    /// document added later with "talking to GitHub" and never say so.
1253    ///
1254    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1255    /// const` here that this list omits, so the two cannot part — which is the same guard
1256    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1257    pub const DOCUMENTS: [(&str, &str); 33] = [
1258        (SEARCH_ISSUES, "searching this board's issues"),
1259        (ISSUE, "reading one issue"),
1260        (
1261            ISSUE_BOARD_ITEMS,
1262            "reading one issue's board memberships past the page it came with",
1263        ),
1264        (SUB_ISSUES, "reading a project's tasks"),
1265        (BOARD, "reading the board"),
1266        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1267        (BOARD_FIELDS, "reading the board's fields"),
1268        (DRAFT, "reading one draft"),
1269        (REPOSITORY, "reading the destination repository"),
1270        (
1271            CREATION_CONTEXT,
1272            "reading the board's fields and the destination repository",
1273        ),
1274        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1275        (CREATE_ISSUE, "creating an issue"),
1276        (ADD_TO_BOARD, "adding an issue to the board"),
1277        (UPDATE_ISSUE, "updating an issue"),
1278        (UPDATE_DRAFT, "updating a draft item"),
1279        (UPDATE_FIELD, "writing a board field"),
1280        (UPDATE_FIELDS, "writing board fields together"),
1281        (CLEAR_FIELD, "clearing a board field"),
1282        (
1283            CREATE_FIELD,
1284            "creating a board single-select field with its options",
1285        ),
1286        (
1287            STATUS_OPTIONS_SNAPSHOT,
1288            "snapshotting board Status options and assignments",
1289        ),
1290        (
1291            STATUS_OPTIONS_UPDATE,
1292            "safely replacing the board Status option list",
1293        ),
1294        (ADD_SUB_ISSUE, "filing an issue under its project"),
1295        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1296        (ADD_BLOCKED_BY, "recording a dependency"),
1297        (REMOVE_BLOCKED_BY, "removing a dependency"),
1298        (DELETE_ISSUE, "deleting an issue"),
1299        (ISSUE_COMMENTS, "reading a task's comments"),
1300        (ISSUE_DETAIL, "reading one issue with its comments"),
1301        (
1302            ISSUE_DETAILS,
1303            "reading a batch of issues with their comments",
1304        ),
1305        (COMMENT_ISSUE, "reading which issue a comment is on"),
1306        (ADD_COMMENT, "adding a comment"),
1307        (UPDATE_COMMENT, "editing a comment"),
1308        (DELETE_COMMENT, "deleting a comment"),
1309    ];
1310}
1311
1312/// Which of GitHub's two rate limiters refused a request.
1313///
1314/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1315/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1316/// the whole reason this is carried rather than collapsed into "rate limited".
1317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum Limiter {
1319    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1320    Primary,
1321    /// The burst limiter over content-generating requests, which nothing reports.
1322    Secondary,
1323}
1324
1325/// The wordings GitHub answers a secondary rate limit with.
1326///
1327/// It sends them under a forbidden status, under a too-many-requests status, and inside
1328/// the `errors` of a *successful* response, which is why the text is what this matches on
1329/// rather than the status. `abuse detection` is the wording GitHub used before the
1330/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1331/// what a burst of content creation is refused with.
1332///
1333/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1334/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1335/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1336/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1337pub const SECONDARY_WORDINGS: [&str; 5] = [
1338    "secondary rate limit",
1339    "temporarily blocked from content creation",
1340    "abuse detection",
1341    "submitted too quickly",
1342    "exceeded a secondary",
1343];
1344
1345/// The wordings GitHub answers an exhausted primary budget with.
1346///
1347/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1348/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1349/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1350/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1351/// two phrases is a substring of it, so without it that answer read as a refusal that will
1352/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1353/// one reason.
1354pub const PRIMARY_WORDINGS: [&str; 4] = [
1355    "api rate limit exceeded",
1356    "api rate limit already exceeded",
1357    "rate limit exceeded",
1358    "rate_limited",
1359];
1360
1361/// What a response *says about itself*, which is the only place a refusal can be read.
1362///
1363/// Deliberately not the whole response body. A board is a place people write about their
1364/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1365/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1366/// reported. So the item data is never read: what is read is GitHub's own REST-style
1367/// `message` envelope, which is what a forbidden status carries, and the `message` and
1368/// `type` of each GraphQL error, which is where a *successful* response says it.
1369///
1370/// A body that is not JSON at all has nothing structured to read, so only a failing
1371/// response's own text is taken — a successful response that is not JSON is malformed
1372/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1373fn refusal_wording(status: StatusCode, body: &str) -> String {
1374    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1375        return if status.is_success() {
1376            String::new()
1377        } else {
1378            body.to_owned()
1379        };
1380    };
1381    let mut said: Vec<&str> = parsed
1382        .get("message")
1383        .and_then(Value::as_str)
1384        .into_iter()
1385        .collect();
1386    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1387        for error in errors {
1388            said.extend(
1389                ["message", "type"]
1390                    .into_iter()
1391                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1392            );
1393        }
1394    }
1395    said.join("; ")
1396}
1397
1398impl Limiter {
1399    /// Which limiter refused this response, or `None` when none of them did.
1400    ///
1401    /// The wording is read first and the status only decides what carries none of it,
1402    /// because GitHub answers a secondary limit with a forbidden status far more often
1403    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1404    /// really is a credential this token lacks.
1405    ///
1406    /// A response is a refusal because of its status or its own wording. A spent budget
1407    /// only ever explains one; it never turns an answer into a refusal.
1408    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1409        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1410        if SECONDARY_WORDINGS
1411            .iter()
1412            .any(|wording| normalized.contains(wording))
1413        {
1414            return Some(Self::Secondary);
1415        }
1416        if status == StatusCode::TOO_MANY_REQUESTS {
1417            return Some(Self::Primary);
1418        }
1419        // An exhausted budget *explains* a response that failed; it does not make one that
1420        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1421        // request the budget allowed as well as on the ones it then refuses, so reading
1422        // the header alone threw away a good answer — and, once refusals were retried,
1423        // replayed a request that had already taken effect.
1424        if !status.is_success() && budget_exhausted {
1425            return Some(Self::Primary);
1426        }
1427        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1428        // `errors` of an HTTP 200, where nothing about the status says so at all.
1429        if status.is_success()
1430            && PRIMARY_WORDINGS
1431                .iter()
1432                .any(|wording| normalized.contains(wording))
1433        {
1434            return Some(Self::Primary);
1435        }
1436        None
1437    }
1438
1439    /// What this limiter is called where an operator can look it up.
1440    const fn name(self) -> &'static str {
1441        match self {
1442            Self::Primary => "GitHub's primary API rate limit",
1443            Self::Secondary => "GitHub's secondary rate limit",
1444        }
1445    }
1446
1447    /// What the endpoint an operator would go and check says about this limiter.
1448    const fn where_to_look(self) -> &'static str {
1449        match self {
1450            Self::Primary => {
1451                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1452                 comes back."
1453            }
1454            Self::Secondary => {
1455                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1456                 primary budget and does not report this one, so budget showing there says \
1457                 nothing about this refusal, and every further attempt extends it."
1458            }
1459        }
1460    }
1461
1462    /// The next step this limiter actually calls for.
1463    const fn what_to_do(self) -> &'static str {
1464        match self {
1465            Self::Primary => {
1466                "wait for the reset `gh api rate_limit` reports, then run the command again."
1467            }
1468            Self::Secondary => {
1469                "leave this board alone for a few minutes, then run the command again — or \
1470                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1471            }
1472        }
1473    }
1474}
1475
1476/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1477#[derive(Debug, Clone, Copy)]
1478struct Limited {
1479    limiter: Limiter,
1480    hint: Option<u64>,
1481}
1482
1483impl Limited {
1484    /// What the caller is told once this source has waited as long as it may.
1485    ///
1486    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1487    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1488    /// about *which* limiter it was makes it a different kind of failure. What differs is
1489    /// the operator's next step, and that is what the message carries — a secondary
1490    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1491    /// budget looks fine, and then back to retry the very burst that was refused.
1492    fn exhausted(
1493        self,
1494        doing: &str,
1495        waits: u32,
1496        waited: Duration,
1497        needed: Duration,
1498        budget: Duration,
1499    ) -> SourceError {
1500        SourceError::RateLimited {
1501            retry_after_seconds: self.hint,
1502            message: Some(format!(
1503                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1504                 again, and the next wait of {} would take it past the {} one call may spend \
1505                 waiting. {} next: {}",
1506                self.limiter.name(),
1507                plural(waits, "refusal"),
1508                seconds(waited),
1509                seconds(needed),
1510                seconds(budget),
1511                self.limiter.where_to_look(),
1512                self.limiter.what_to_do(),
1513            )),
1514        }
1515    }
1516}
1517
1518/// One HTTP attempt's result, with what its response said about the rate limit.
1519///
1520/// The two travel together so the record and the outcome are written from the same place:
1521/// what a response said about the budget is only readable while that response is in hand,
1522/// and what the attempt *meant* is only decidable once its body has been read.
1523struct Attempted {
1524    result: Result<Value, Attempt>,
1525    limits: accounting::RateLimit,
1526    /// GitHub's own reported cost for this call, for a document that asked for it.
1527    reported_cost: Option<u64>,
1528}
1529
1530/// One attempt's outcome: an error to report, or a rate limit to wait out.
1531enum Attempt {
1532    Failed(SourceError),
1533    Limited(Limited),
1534}
1535
1536fn plural(count: u32, thing: &str) -> String {
1537    if count == 1 {
1538        format!("{count} {thing}")
1539    } else {
1540        format!("{count} {thing}s")
1541    }
1542}
1543
1544fn seconds(duration: Duration) -> String {
1545    format!("{:.1}s", duration.as_secs_f64())
1546}
1547
1548/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1549///
1550/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1551/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1552/// header, and neither is what makes a response a refusal — so the whole cost of one this
1553/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1554/// instead. Refusing the response over the header would turn a readable refusal into an
1555/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1556fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1557    value
1558        .and_then(|value| value.to_str().ok())
1559        .and_then(|value| value.trim().parse::<u64>().ok())
1560}
1561
1562/// Every mutation this source sends creates content — an issue, a board item, a field of
1563/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1564/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1565/// and what the keyword says are the same set. That is what makes the keyword a sound test
1566/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1567/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1568fn is_mutation(query: &str) -> bool {
1569    query.trim_start().starts_with("mutation")
1570}
1571
1572/// What this source was doing, for a diagnostic that has to say so.
1573///
1574/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1575/// a document added without a description is caught by that list's own gate instead of
1576/// falling through to the vague arm below.
1577fn operation_description(query: &str) -> &'static str {
1578    graphql::DOCUMENTS
1579        .iter()
1580        .find(|(document, _)| *document == query)
1581        .map_or("talking to GitHub", |(_, doing)| *doing)
1582}
1583
1584/// GitHub's published ceiling on content-generating requests, per minute.
1585///
1586/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1587/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1588/// from it, so a pacing value checked only against itself cannot go stale here.
1589pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1590/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1591/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1592/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1593pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1594/// Shortest interval between two content-creating mutations, in milliseconds.
1595///
1596/// GitHub documents two secondary limits on content-generating requests:
1597/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1598/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1599/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1600/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1601/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1602/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1603/// deliberately *not* what this paces at. An installation that wants the hourly bound
1604/// honoured for a long sequence of copies says so through
1605/// `pacing.min_mutation_interval_ms`.
1606pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1607/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1608///
1609/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1610/// own advice for a secondary limit — wait, and wait longer each time — without spending
1611/// the first minute of a transient refusal doing nothing.
1612pub const RETRY_BACKOFF_MS: u64 = 1_000;
1613/// Total time one call may spend waiting out rate limits before it reports a failure.
1614///
1615/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1616/// short enough that a command an operator is watching returns. The bound is what makes
1617/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1618/// the limiter, not in a process nobody can tell from a wedged one.
1619pub const RETRY_BUDGET_MS: u64 = 120_000;
1620
1621fn default_token_env() -> String {
1622    "GH_PROJECTS_TOKEN".to_owned()
1623}
1624fn default_endpoint() -> String {
1625    "https://api.github.com/graphql".to_owned()
1626}
1627
1628/// Where one status category lands on this board.
1629///
1630/// `null` — an absent value — disables the category for this instance, and using a
1631/// disabled status is a refusal naming the status and the instance.
1632#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1633#[serde(untagged)]
1634pub enum StatusTargetConfig {
1635    /// The name of a `Status` single-select option already on the board.
1636    Column(ColumnName),
1637}
1638
1639/// The name of a `Status` single-select option on the board.
1640///
1641/// Validated on the way in rather than checked later, so a blank option name — which
1642/// nothing on a board can be — is a state this type cannot hold.
1643#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1644#[serde(try_from = "String")]
1645#[schemars(extend("minLength" = 1))]
1646pub struct ColumnName(String);
1647
1648impl ColumnName {
1649    /// The option name, as the board spells it.
1650    fn as_str(&self) -> &str {
1651        &self.0
1652    }
1653}
1654
1655impl TryFrom<String> for ColumnName {
1656    type Error = String;
1657
1658    fn try_from(name: String) -> Result<Self, Self::Error> {
1659        if name.trim().is_empty() {
1660            return Err("a status_mapping option name cannot be blank".to_owned());
1661        }
1662        Ok(Self(name))
1663    }
1664}
1665
1666/// The two closed states this product can mean.
1667///
1668/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1669/// work nor abandoned work, so nothing here ever writes it.
1670#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1671#[serde(rename_all = "kebab-case")]
1672pub enum ClosedState {
1673    /// `COMPLETED` — precisely done.
1674    Completed,
1675    /// `NOT_PLANNED` — precisely cancelled.
1676    NotPlanned,
1677}
1678
1679impl ClosedState {
1680    const fn reason(self) -> &'static str {
1681        match self {
1682            Self::Completed => "COMPLETED",
1683            Self::NotPlanned => "NOT_PLANNED",
1684        }
1685    }
1686}
1687
1688/// Configuration for one GitHub Projects v2 board.
1689#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1690#[serde(default, deny_unknown_fields)]
1691pub struct GitHubProjectsConfig {
1692    /// Login of the user or organization which owns the board.
1693    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1694    /// The project number shown in the board's GitHub URL.
1695    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1696    // 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.
1697    /// `owner/name` of the repository this source creates an issue in when the item's own
1698    /// `repositories` field does not decide it.
1699    ///
1700    /// An item naming exactly one repository is created there; a task or a document naming
1701    /// none or several is created in its parent project's repository; and a project, or a
1702    /// task or document with no parent, naming none or several is created here. A board
1703    /// has no repository of its own and `createIssue` requires one, so a write without
1704    /// this is refused naming the field. Reads never need it.
1705    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1706    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1707    /// Environment variable containing a fine-grained token with Projects and Issues
1708    /// read/write plus Pull requests read-only access for every repository represented on
1709    /// the board.
1710    #[serde(default = "default_token_env")]
1711    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1712    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1713    #[serde(default = "default_endpoint")]
1714    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1715    /// Per-instance mapping from a status category to where it lands on this board.
1716    ///
1717    /// A category this does not mention keeps its shipped default: `backlog` to
1718    /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1719    /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1720    /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1721    /// board option; every unknown word then lands on that option and reads back as
1722    /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1723    /// word because it never creates board options.
1724    #[serde(default)]
1725    pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` parses each key into a `StatusCategory` and reports an unknown one against this instance.
1726    /// Per-instance mapping from a task's priority to an option of this board's
1727    /// single-select field named `Priority`.
1728    ///
1729    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1730    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1731    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1732    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1733    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1734    /// no two levels may name one option. Reads and writes never create the field or an
1735    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1736    /// the board lacks is refused pointing there.
1737    #[serde(default)]
1738    pub priority_mapping: Option<PriorityMappingConfig>,
1739    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1740    ///
1741    /// Every field keeps its shipped default when it is absent, and the defaults are
1742    /// GitHub's own published limits rather than taste. See [`Pacing`].
1743    #[serde(default)]
1744    pub pacing: PacingConfig,
1745}
1746
1747/// Which option of the board's `Priority` field each priority lands on.
1748///
1749/// One member per level rather than a map, so a key that is not a level is refused where
1750/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1751/// value in the field, not an option of it.
1752#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1753#[serde(default, deny_unknown_fields)]
1754pub struct PriorityMappingConfig {
1755    /// The option `urgent` lands on; `Urgent` when absent.
1756    pub urgent: Option<PriorityOptionName>,
1757    /// The option `high` lands on; `High` when absent.
1758    pub high: Option<PriorityOptionName>,
1759    /// The option `medium` lands on; `Medium` when absent.
1760    pub medium: Option<PriorityOptionName>,
1761    /// The option `low` lands on; `Low` when absent.
1762    pub low: Option<PriorityOptionName>,
1763}
1764
1765/// The name of an option of the board's `Priority` single-select field.
1766///
1767/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1768/// blank name.
1769#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1770#[serde(try_from = "String")]
1771#[schemars(extend("minLength" = 1))]
1772pub struct PriorityOptionName(String);
1773
1774impl PriorityOptionName {
1775    /// The option name, as the board spells it.
1776    fn as_str(&self) -> &str {
1777        &self.0
1778    }
1779}
1780
1781impl TryFrom<String> for PriorityOptionName {
1782    type Error = String;
1783
1784    fn try_from(name: String) -> Result<Self, Self::Error> {
1785        if name.trim().is_empty() {
1786            return Err("a priority_mapping option name cannot be blank".to_owned());
1787        }
1788        Ok(Self(name))
1789    }
1790}
1791
1792/// The name of the board field a priority is held in.
1793pub const PRIORITY_FIELD: &str = "Priority";
1794
1795/// The four priorities a board option can hold, in the order a new `Priority` field lists
1796/// them. `none` is not among them: it is the field holding no value.
1797///
1798/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1799/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1800/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1801/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1802pub const PRIORITY_LEVELS: [Priority; 4] = [
1803    Priority::Urgent,
1804    Priority::High,
1805    Priority::Medium,
1806    Priority::Low,
1807];
1808
1809/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1810/// see that list for what this pins.
1811#[must_use]
1812pub const fn level_position(priority: Priority) -> Option<usize> {
1813    match priority {
1814        Priority::None => None,
1815        Priority::Urgent => Some(0),
1816        Priority::High => Some(1),
1817        Priority::Medium => Some(2),
1818        Priority::Low => Some(3),
1819    }
1820}
1821
1822/// This instance's complete priority-to-option mapping, read in both directions.
1823///
1824/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1825/// two levels name one option.
1826#[derive(Debug, Clone)]
1827struct PriorityMapping {
1828    options: [PriorityOptionName; 4],
1829}
1830
1831impl PriorityMapping {
1832    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1833        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1834        let mapping = Self {
1835            options: [
1836                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1837                config.high.unwrap_or_else(|| shipped("High")),
1838                config.medium.unwrap_or_else(|| shipped("Medium")),
1839                config.low.unwrap_or_else(|| shipped("Low")),
1840            ],
1841        };
1842        for (index, option) in mapping.options.iter().enumerate() {
1843            if let Some(earlier) = mapping.options[..index]
1844                .iter()
1845                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1846            {
1847                return Err(SourceError::Config {
1848                    message: format!(
1849                        "priority_mapping of source {instance} sends both {} and {} to the board \
1850                         option {:?}; one option cannot read back as two priorities",
1851                        PRIORITY_LEVELS[earlier],
1852                        PRIORITY_LEVELS[index],
1853                        option.as_str()
1854                    ),
1855                });
1856            }
1857        }
1858        Ok(mapping)
1859    }
1860
1861    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1862    fn option(&self, priority: Priority) -> Option<&str> {
1863        level_position(priority).map(|index| self.options[index].as_str())
1864    }
1865
1866    /// The priority a board option name reports, or `None` when nothing maps to it.
1867    fn priority_of(&self, option: &str) -> Option<Priority> {
1868        self.options
1869            .iter()
1870            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1871            .map(|index| PRIORITY_LEVELS[index])
1872    }
1873
1874    /// Every mapped option name, in the order a new `Priority` field lists them.
1875    fn names(&self) -> impl Iterator<Item = &str> {
1876        self.options.iter().map(PriorityOptionName::as_str)
1877    }
1878}
1879
1880/// What one item's `Priority` field says, read through this instance's mapping.
1881#[derive(Debug, Clone, PartialEq, Eq)]
1882enum HeldPriority {
1883    /// A priority this source reports: an option the mapping names, or no value (`none`).
1884    Read(Priority),
1885    /// An option the mapping does not name, which is never read as a level or as `none`.
1886    Unmapped(String),
1887}
1888
1889/// How fast this source writes, and how long it waits out a rate-limit refusal.
1890///
1891/// Configurable because a GitHub Enterprise installation sets its own limits and an
1892/// operator who has already been refused may want to go slower still — not because the
1893/// defaults are guesses.
1894#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1895#[serde(default, deny_unknown_fields)]
1896pub struct PacingConfig {
1897    /// Shortest interval between two content-creating mutations, in milliseconds.
1898    ///
1899    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1900    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1901    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.
1902    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1903    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1904    /// zero while there is a budget to spend, because a schedule of zero-length waits
1905    /// consumes none of it and so never ends.
1906    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.
1907    /// Total time one call may spend waiting out rate limits, in milliseconds.
1908    ///
1909    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1910    /// the bound is what makes this a wait rather than a hang.
1911    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.
1912}
1913
1914/// The largest any pacing setting may be, in milliseconds.
1915///
1916/// One hour. GitHub's own harshest published bound on content-generating requests works
1917/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1918/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1919/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1920/// and an interval beyond it is a command that never sends its second mutation. It also
1921/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1922/// what an `Instant` can hold on every platform.
1923pub const MAX_PACING_MS: u64 = 3_600_000;
1924
1925/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1926/// source holds.
1927#[derive(Debug, Clone, Copy)]
1928struct Pacing {
1929    min_mutation_interval: Duration,
1930    retry_backoff: Duration,
1931    retry_budget: Duration,
1932}
1933
1934impl Pacing {
1935    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1936    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1937        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1938            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1939                message: format!(
1940                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1941                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1942                     GitHub's own harshest published limit"
1943                ),
1944            }),
1945            Some(value) => Ok(Duration::from_millis(value)),
1946            None => Ok(Duration::from_millis(default)),
1947        };
1948        let retry_backoff = bounded(
1949            config.retry_backoff_ms,
1950            RETRY_BACKOFF_MS,
1951            "retry_backoff_ms",
1952        )?;
1953        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1954        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1955            return Err(SourceError::Config {
1956                message: format!(
1957                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1958                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1959                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1960                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1961                     waiting at all",
1962                    retry_budget.as_millis()
1963                ),
1964            });
1965        }
1966        Ok(Self {
1967            min_mutation_interval: bounded(
1968                config.min_mutation_interval_ms,
1969                MIN_MUTATION_INTERVAL_MS,
1970                "min_mutation_interval_ms",
1971            )?,
1972            retry_backoff,
1973            retry_budget,
1974        })
1975    }
1976}
1977
1978/// Factory for [`GitHubProjectsSource`].
1979#[derive(Debug, Clone, Copy, Default)]
1980pub struct Plugin;
1981
1982impl SourcePlugin for Plugin {
1983    fn kind(&self) -> &'static str {
1984        KIND
1985    }
1986    fn config_schema(&self) -> Schema {
1987        schema_for!(GitHubProjectsConfig)
1988    }
1989    fn build(
1990        &self,
1991        name: &SourceName,
1992        config: &Value,
1993        secrets: &dyn SecretResolver,
1994    ) -> Result<Box<dyn TaskSource>, SourceError> {
1995        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1996    }
1997}
1998
1999impl Plugin {
2000    /// Build a source recording every request it sends into an accounting the caller holds.
2001    ///
2002    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2003    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2004    /// session total rather than two — see [`accounting`] and
2005    /// [`GitHubProjectsSource::recording_into`].
2006    ///
2007    /// # Errors
2008    ///
2009    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2010    /// [`SourceError::Config`] for configuration this plugin cannot use and
2011    /// [`SourceError::Auth`] for a credential it cannot find.
2012    pub fn build_recording_into(
2013        &self,
2014        name: &SourceName,
2015        config: &Value,
2016        secrets: &dyn SecretResolver,
2017        ledger: Arc<Accounting>,
2018    ) -> Result<Box<dyn TaskSource>, SourceError> {
2019        let config: GitHubProjectsConfig =
2020            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2021                message: format!("source {name}: {e}"),
2022            })?;
2023        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2024            |error| match error {
2025                SourceError::Config { message } => SourceError::Config {
2026                    message: format!("source {name}: {message}"),
2027                },
2028                SourceError::Auth { message } => SourceError::Auth {
2029                    message: format!("source {name}: {message}"),
2030                },
2031                other => other,
2032            },
2033        )?;
2034        Ok(Box::new(source))
2035    }
2036}
2037
2038/// Where a status category lands on this board, once configuration is resolved.
2039#[derive(Debug, Clone, PartialEq, Eq)]
2040enum StatusTarget {
2041    /// Not usable against this instance.
2042    Disabled,
2043    /// The board's `Status` option of this name.
2044    Column(ColumnName),
2045    /// A closed issue, with both its board option and the reason that says which closed it means.
2046    // llmlint: ignore[invalid_states_unrepresentable] The reason is fixed by the category — `done` closes as completed, `cancelled` as not planned — and this private enum is built in one place, `StatusMapping::new`, which pairs each from the category's own slot. Carrying the reason on the target is what lets every write site that holds only a target derive its `stateInput` from that one resolved model rather than re-deriving it from a category and risking a disagreement with the mapping.
2047    Terminal(ColumnName, ClosedState),
2048}
2049
2050/// Every status category, in the order the vocabulary declares them.
2051///
2052/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2053/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2054/// added to the shared vocabulary fails to compile until it is named there, and this
2055/// crate's suite reconciles this list against that enum's own derived schema, which is
2056/// generated from the variants rather than written beside them. The schema is what
2057/// catches a list left one short — a list checking only the positions it already holds
2058/// would pass while every mapping indexed by the new position panicked.
2059pub const CATEGORIES: [StatusCategory; 8] = [
2060    StatusCategory::Draft,
2061    StatusCategory::Backlog,
2062    StatusCategory::Todo,
2063    StatusCategory::Queued,
2064    StatusCategory::InProgress,
2065    StatusCategory::Done,
2066    StatusCategory::Cancelled,
2067    StatusCategory::Unknown,
2068];
2069
2070/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2071#[must_use]
2072pub const fn category_position(category: StatusCategory) -> usize {
2073    match category {
2074        StatusCategory::Draft => 0,
2075        StatusCategory::Backlog => 1,
2076        StatusCategory::Todo => 2,
2077        StatusCategory::Queued => 3,
2078        StatusCategory::InProgress => 4,
2079        StatusCategory::Done => 5,
2080        StatusCategory::Cancelled => 6,
2081        StatusCategory::Unknown => 7,
2082    }
2083}
2084
2085/// The spelling a status category is configured and reported under.
2086fn category_name(category: StatusCategory) -> &'static str {
2087    match category {
2088        StatusCategory::Draft => "draft",
2089        StatusCategory::Backlog => "backlog",
2090        StatusCategory::Todo => "todo",
2091        StatusCategory::Queued => "queued",
2092        StatusCategory::InProgress => "in-progress",
2093        StatusCategory::Done => "done",
2094        StatusCategory::Cancelled => "cancelled",
2095        StatusCategory::Unknown => "unknown",
2096    }
2097}
2098
2099/// A shipped default's option name.
2100///
2101/// The literals below are this file's own and non-blank, and they are validated by the
2102/// one constructor a configured name goes through rather than beside it.
2103fn shipped_column(name: &'static str) -> ColumnName {
2104    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2105}
2106
2107/// The shipped default for one category, before this instance's configuration.
2108fn shipped_default(category: StatusCategory) -> StatusTarget {
2109    match category {
2110        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2111        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2112        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2113        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2114        StatusCategory::Done => {
2115            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2116        }
2117        StatusCategory::Cancelled => {
2118            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2119        }
2120        StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
2121    }
2122}
2123
2124/// This instance's complete category-to-target mapping, read in both directions.
2125///
2126/// One target per category, held at that category's own [`category_position`], so a
2127/// category missing from the mapping, named twice in it, or filed out of order is a
2128/// state this type cannot hold rather than one [`Self::target`] has to defend against.
2129#[derive(Debug, Clone)]
2130struct StatusMapping {
2131    targets: [StatusTarget; CATEGORIES.len()],
2132}
2133
2134impl StatusMapping {
2135    fn resolve(
2136        configured: BTreeMap<String, Option<StatusTargetConfig>>,
2137        instance: &SourceName,
2138    ) -> Result<Self, SourceError> {
2139        let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
2140        for (key, value) in configured {
2141            let category = CATEGORIES
2142                .iter()
2143                .find(|category| category_name(**category) == key)
2144                .ok_or_else(|| SourceError::Config {
2145                    message: format!(
2146                        "status_mapping names {key:?}, which is not a status category of source \
2147                         {instance}; the categories are {}",
2148                        CATEGORIES
2149                            .iter()
2150                            .map(|category| category_name(*category))
2151                            .collect::<Vec<_>>()
2152                            .join(", ")
2153                    ),
2154                })?;
2155            overrides.insert(category_name(*category), value);
2156        }
2157        // `CATEGORIES[position] == category` for every category — the crate's suite
2158        // asserts it — so mapping the list in order fills each category's own slot.
2159        let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
2160            None => shipped_default(category),
2161            Some(None) => StatusTarget::Disabled,
2162            Some(Some(StatusTargetConfig::Column(option))) => match category {
2163                StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
2164                StatusCategory::Cancelled => {
2165                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
2166                }
2167                _ => StatusTarget::Column(option),
2168            },
2169        });
2170        let mapping = Self { targets };
2171        for (index, category) in CATEGORIES.into_iter().enumerate() {
2172            let option = match mapping.target(category) {
2173                StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
2174                StatusTarget::Disabled => continue,
2175            };
2176            if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
2177                matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2178                    if name.as_str().eq_ignore_ascii_case(option.as_str()))
2179            }) {
2180                return Err(SourceError::Config {
2181                    message: format!(
2182                        "status_mapping of source {instance} sends both {} and {} to the board \
2183                         option {:?}; one option cannot read back as two categories",
2184                        category_name(*other),
2185                        category_name(category),
2186                        option.as_str()
2187                    ),
2188                });
2189            }
2190        }
2191        Ok(mapping)
2192    }
2193
2194    fn target(&self, category: StatusCategory) -> &StatusTarget {
2195        &self.targets[category_position(category)]
2196    }
2197
2198    /// The category a board option name reports, or `None` when nothing maps to it.
2199    fn category_of(&self, option: &str) -> Option<StatusCategory> {
2200        CATEGORIES.into_iter().find(|category| {
2201            matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2202                if name.as_str().eq_ignore_ascii_case(option))
2203        })
2204    }
2205
2206    /// The status an item reports, from the three things a read of it says: its board
2207    /// `Status` option, whether its issue is closed, and the reason it was closed with.
2208    ///
2209    /// The closed state decides the category and the `Status` option decides the name, so
2210    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2211    /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2212    /// duplicate is not finished work, and calling it done is a lie the next copy would
2213    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2214    /// it is read permissively rather than refused — reads are faithful, and refusals
2215    /// belong on writes.
2216    ///
2217    /// One function of those three rather than of a response, so a narrow status write can
2218    /// answer what a re-read would report by applying it to the state it has just written.
2219    fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2220        if closed {
2221            let category = match reason {
2222                None | Some("COMPLETED") => StatusCategory::Done,
2223                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2224                Some(_) => StatusCategory::Unknown,
2225            };
2226            let fallback = match category {
2227                StatusCategory::Done => "Done",
2228                StatusCategory::Cancelled => "Cancelled",
2229                _ => "Closed",
2230            };
2231            return Status {
2232                category,
2233                name: option.unwrap_or(fallback).to_owned(),
2234            };
2235        }
2236        let name = option.unwrap_or("Open").to_owned();
2237        Status {
2238            category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2239            name,
2240        }
2241    }
2242}
2243
2244// 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.
2245/// One repository this source can create an issue in, as `owner/name`.
2246///
2247/// Every `createIssue` this source sends names one of these: the item's own single
2248/// `repositories` entry, else its parent project issue's repository, else the configured
2249/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2250/// that choice and says what it refuses before `createIssue`.
2251// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2252#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2253struct RepositoryTarget {
2254    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2255    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2256}
2257
2258impl RepositoryTarget {
2259    fn parse(value: &str) -> Result<Self, SourceError> {
2260        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2261            message: format!(
2262                "repository must be spelled owner/name; {value:?} names no repository"
2263            ),
2264        })?;
2265        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2266            return Err(SourceError::Config {
2267                message: format!(
2268                    "repository must be spelled owner/name with a GitHub login and one \
2269                     repository name; {value:?} is not"
2270                ),
2271            });
2272        }
2273        Ok(Self {
2274            owner: owner.to_owned(),
2275            name: name.to_owned(),
2276        })
2277    }
2278
2279    /// The one host whose repositories this source creates issues in, spelled once: it is
2280    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2281    const HOST: &str = "github.com";
2282
2283    fn origin(&self) -> String {
2284        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2285    }
2286
2287    /// The repository a normalized origin names, or why it is none this source can create
2288    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2289    fn from_origin(origin: &Repository) -> Result<Self, String> {
2290        let not_here = || {
2291            format!(
2292                "{} is not a {}/owner/name repository",
2293                origin.as_str(),
2294                Self::HOST
2295            )
2296        };
2297        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2298        if host != Self::HOST {
2299            return Err(not_here());
2300        }
2301        Self::parse(rest).map_err(|_| not_here())
2302    }
2303
2304    fn slug(&self) -> String {
2305        format!("{}/{}", self.owner, self.name)
2306    }
2307}
2308
2309/// A source which reads GitHub afresh for every operation.
2310pub struct GitHubProjectsSource {
2311    /// This source's configured name, used both to tell a far end naming this source
2312    /// from one naming a system it knows nothing about, and to name the instance a
2313    /// status refusal is about.
2314    name: SourceName,
2315    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2316    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2317    repository: Option<RepositoryTarget>,
2318    endpoint: Url,
2319    token: SecretString,
2320    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2321    statuses: StatusMapping,
2322    /// Where each priority lands on this board, or `None` when this instance holds none.
2323    priorities: Option<PriorityMapping>,
2324    client: Client,
2325    /// Every item this source has created since it was built, in the order it created
2326    /// them.
2327    ///
2328    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2329    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2330    /// a copy resolving a dependency on an item it had just created refused it as not
2331    /// found. A board read is completed from this — an item remembered here and absent from
2332    /// the read is added back, because the board really does hold it and only the read is
2333    /// behind.
2334    ///
2335    /// It is not a cache of a user's work: nothing is remembered that this process did not
2336    /// itself just write, it lives and dies with the process, and it is never consulted for
2337    /// an item this source did not create.
2338    created: Mutex<Vec<Resolved>>,
2339    /// Every item that already existed and that this source has written since it was built,
2340    /// as it wrote it.
2341    ///
2342    /// The other half of [`Self::created`], held on the same terms and for the reason a
2343    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2344    /// filter is an index behind a write this process made moments ago, so a query matching
2345    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2346    /// is remembered that this process did not itself just write.
2347    updated: Mutex<Vec<Resolved>>,
2348    /// How fast this source writes, and how long it waits out a refusal.
2349    pacing: Pacing,
2350    /// When the last content-creating mutation finished, or the moment the furthest-out
2351    /// reserved slot releases the next one, whichever is later — so the one after it can be
2352    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2353    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2354    /// what it is measured from.
2355    last_mutation: Mutex<Option<Instant>>,
2356    /// The board as this process last read it, for the length of one command.
2357    ///
2358    /// A copy of a project used to re-read the whole board, paged, before writing each of
2359    /// its items, which is by far the largest part of a copy's request count and none of
2360    /// its work. Nothing else changes this board while a command runs — this source's own
2361    /// writes are the only writer — so one read answers them all.
2362    ///
2363    /// It is not a store of a user's work and it is not the cache the no-persistence
2364    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2365    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2366    /// an item this command created and then depends on resolves whether or not GitHub's
2367    /// own eventually-consistent read has caught up. A write to an item already on the
2368    /// board updates the entry here too, so what this holds is the last read plus this
2369    /// process's own writes rather than a snapshot taken before them.
2370    board_cache: Mutex<Option<Board>>,
2371    /// Every issue this board's own search reported, for the length of one command.
2372    ///
2373    /// The second half of a board read, and cached for the same reason and on the same
2374    /// terms as the first: it lives and dies with the process, nothing is written down, and
2375    /// a write this process makes updates the entry here exactly as it updates the one in
2376    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2377    /// that lists this board's projects and its tasks pays for one search rather than two.
2378    search_cache: Mutex<Option<Vec<Resolved>>>,
2379    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2380    /// the length of one command.
2381    ///
2382    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2383    /// and dies with the process, nothing is written down, a write this process makes
2384    /// updates the entry here as it updates the other two, and every answer is completed
2385    /// with this process's own writes each time it is given. A command that asks the same
2386    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2387    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2388    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2389    search_next: Mutex<BTreeMap<String, Option<String>>>,
2390    /// Records already resolved in this source instance, reused by writes and
2391    /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2392    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2393    /// The board's own id and field definitions as this process last read them on their
2394    /// own, for the length of one command.
2395    ///
2396    /// What a write needs of the board and its item does not say, read once per command
2397    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2398    /// lives and dies with the process and nothing is written down. It holds no item and so
2399    /// can answer no question about one — see [`Self::board_fields`].
2400    fields_cache: Mutex<Option<BoardFields>>,
2401    /// Each destination repository's node id, resolved once per repository
2402    /// rather than per issue created.
2403    ///
2404    /// A repository's node id does not change, and re-reading it for every issue of a copy
2405    /// spent one request per item on an answer this source already had. It is a map rather
2406    /// than one entry because a copy files each item in the repository its own
2407    /// `repositories` field names, so a plan across five repositories asks GitHub five
2408    /// times and not once per item.
2409    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2410    /// What every request this source sends is recorded into.
2411    ///
2412    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2413    /// a request leaves this crate, so nothing has to be switched on for a session to be
2414    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2415    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2416    /// source's reads and writes — adds up one accounting instead of two. See
2417    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2418    ledger: Arc<Accounting>,
2419}
2420
2421/// GitHub's closed single-select color vocabulary.
2422#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2423#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2424pub enum StatusOptionColor {
2425    /// Gray.
2426    Gray,
2427    /// Blue.
2428    Blue,
2429    /// Green.
2430    Green,
2431    /// Yellow.
2432    Yellow,
2433    /// Purple.
2434    Purple,
2435    /// Red.
2436    Red,
2437    /// Orange.
2438    Orange,
2439    /// Pink.
2440    Pink,
2441}
2442
2443/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2444/// applies its additions.
2445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2446pub enum SetupMode {
2447    /// Read without mutation.
2448    Plan,
2449    /// Apply and verify.
2450    Apply,
2451}
2452
2453/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2454/// against it goes on compiling.
2455pub type StatusOptionsMode = SetupMode;
2456
2457/// The explicit result of the requested operation.
2458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2459#[serde(rename_all = "kebab-case")]
2460pub enum StatusOptionsOutcome {
2461    /// A read-only plan.
2462    Planned,
2463    /// Apply found nothing missing.
2464    Unchanged,
2465    /// Additions were applied and verified.
2466    Applied,
2467}
2468
2469/// A GitHub single-select option's opaque GraphQL node identifier.
2470#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2471#[serde(transparent)]
2472pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2473
2474impl TryFrom<String> for StatusOptionId {
2475    type Error = String;
2476
2477    fn try_from(id: String) -> Result<Self, Self::Error> {
2478        if id.trim().is_empty() {
2479            return Err("a GitHub Status option id cannot be blank".to_owned());
2480        }
2481        Ok(Self(id))
2482    }
2483}
2484
2485/// One existing or proposed option in a guarded Status-field update.
2486#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2487pub struct StatusOption {
2488    /// GitHub's stable id.
2489    pub id: StatusOptionId,
2490    /// The visible option name.
2491    pub name: ColumnName,
2492    /// GitHub's single-select color token.
2493    pub color: StatusOptionColor,
2494    /// The option description, including an empty one.
2495    pub description: String,
2496}
2497
2498/// One board item's Status assignment, retained as recovery data.
2499#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2500pub struct StatusAssignment {
2501    /// The project item id whose assignment this is.
2502    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2503    // carried verbatim as operator recovery data; introducing a semantic type would claim
2504    // validation rules GitHub does not publish and no operation here interprets.
2505    pub item_id: String,
2506    /// The selected option, absent when the item has no status.
2507    #[serde(skip_serializing_if = "Option::is_none")]
2508    pub option: Option<AssignedStatusOption>,
2509}
2510
2511/// The inseparable id and name of an assigned option.
2512#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2513pub struct AssignedStatusOption {
2514    /// GitHub's stable id.
2515    pub id: StatusOptionId,
2516    /// The visible name.
2517    pub name: ColumnName,
2518}
2519
2520/// The plan and verified outcome of reconciling configured Status options.
2521#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2522pub struct StatusOptionsReport {
2523    /// The configured source name.
2524    pub source: SourceName,
2525    /// Configured option names absent before the operation.
2526    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2527    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2528    // serialized string here preserves the report's intentionally simple public contract.
2529    pub missing: Vec<String>,
2530    /// What the requested operation did.
2531    pub outcome: StatusOptionsOutcome,
2532    /// The complete option list observed before any mutation.
2533    pub existing: Vec<StatusOption>,
2534}
2535
2536#[derive(Debug, Clone, PartialEq, Eq)]
2537struct StatusSnapshot {
2538    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2539    // passed back as the mutation's project identity; a newtype could enforce no stronger
2540    // invariant because GitHub publishes no grammar for it.
2541    board_id: String,
2542    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2543    // passed back as the mutation's field identity; a newtype could enforce no stronger
2544    // invariant because GitHub publishes no grammar for it.
2545    field_id: String,
2546    options: Vec<StatusOption>,
2547    assignments: Vec<StatusAssignment>,
2548}
2549
2550/// The name of the board field a status is held in.
2551const STATUS_FIELD: &str = "Status";
2552
2553/// Every item's value of each field `report` names, as it stood before the setup wrote
2554/// anything — what a person puts back when the setup is refused part way.
2555fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2556    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2557        .fields
2558        .iter()
2559        .map(|field| (field.field.name(), before.assignments(field.field)))
2560        .collect();
2561    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2562        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2563    })
2564}
2565
2566/// One board field the guarded setup reads and writes — every one it reads, and the only
2567/// ones it writes.
2568#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2569pub enum BoardField {
2570    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2571    Status,
2572    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2573    Priority,
2574}
2575
2576impl BoardField {
2577    /// The field's name on the board.
2578    #[must_use]
2579    pub const fn name(self) -> &'static str {
2580        match self {
2581            Self::Status => STATUS_FIELD,
2582            Self::Priority => PRIORITY_FIELD,
2583        }
2584    }
2585
2586    /// The field a board calls `name`, or `None` for one this setup does not own.
2587    fn named(name: &str) -> Option<Self> {
2588        [Self::Status, Self::Priority]
2589            .into_iter()
2590            .find(|field| field.name() == name)
2591    }
2592}
2593
2594/// What the guarded setup did to one field.
2595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2596#[serde(rename_all = "kebab-case")]
2597pub enum FieldOutcome {
2598    /// A read-only plan.
2599    Planned,
2600    /// Apply found the field there with every configured option.
2601    Unchanged,
2602    /// Missing options were added to the field that was there, and verified.
2603    Applied,
2604    /// The field was not there; it was created holding the configured options, and verified.
2605    Created,
2606}
2607
2608/// One field's plan, or its verified outcome.
2609#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2610pub struct FieldReport {
2611    /// Which field.
2612    pub field: BoardField,
2613    /// Whether the board had the field before the operation.
2614    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2615    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2616    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2617    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2618    // one constructor, and it derives `outcome` from `exists` in one match.
2619    pub exists: bool,
2620    /// Configured option names the field lacked before the operation — every one of them,
2621    /// in the order a new field lists them, when the field was not there at all.
2622    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2623    // mapping name and has therefore already passed its nonblank validation; the serialized
2624    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2625    pub missing: Vec<String>,
2626    /// What the requested operation did.
2627    pub outcome: FieldOutcome,
2628    /// The field's complete option list observed before any mutation; empty when the field
2629    /// was not there.
2630    pub existing: Vec<StatusOption>,
2631}
2632
2633/// The plan and verified outcome of setting up every field a source's configuration names.
2634#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2635pub struct FieldsReport {
2636    /// The configured source name.
2637    pub source: SourceName,
2638    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2639    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2640    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2641    // per field would change a published JSON shape. The states the list could hold and the
2642    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2643    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2644    pub fields: Vec<FieldReport>,
2645}
2646
2647/// Which options one field is configured with, in the order a new field would list them.
2648struct FieldPlan {
2649    field: BoardField,
2650    wanted: Vec<String>,
2651}
2652
2653/// One single-select field as the guarded setup snapshots it.
2654#[derive(Debug, Clone, PartialEq, Eq)]
2655struct SnapshotField {
2656    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2657    // passed back as the mutation's field identity; a newtype could enforce no stronger
2658    // invariant because GitHub publishes no grammar for it.
2659    field_id: String,
2660    options: Vec<StatusOption>,
2661}
2662
2663/// Every single-select field of a board and every item's value of each.
2664#[derive(Debug, Clone, PartialEq, Eq)]
2665struct BoardSnapshot {
2666    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2667    // passed back as the mutation's project identity; a newtype could enforce no stronger
2668    // invariant because GitHub publishes no grammar for it.
2669    board_id: String,
2670    fields: BTreeMap<BoardField, SnapshotField>,
2671    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2672    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2673}
2674
2675impl BoardSnapshot {
2676    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2677    /// carries.
2678    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2679        self.items
2680            .iter()
2681            .map(|(item_id, values)| StatusAssignment {
2682                item_id: item_id.clone(),
2683                option: values.get(&field).cloned(),
2684            })
2685            .collect()
2686    }
2687}
2688
2689impl GitHubProjectsSource {
2690    /// Report missing configured Status options and, when `apply` is true, add them with
2691    /// a whole-list mutation that preserves every existing id and verifies the result.
2692    ///
2693    /// # Errors
2694    ///
2695    /// Refuses a board without a single-select `Status` field. A post-write difference in
2696    /// any pre-existing option id or item assignment is refused with the complete pre-write
2697    /// assignment snapshot in the diagnostic for recovery.
2698    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2699    // successful mutation, both drift refusals, source selection, missing Status, casing,
2700    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2701    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2702    // responses from entering the defensive malformed-response branches below.
2703    pub async fn status_options(
2704        &self,
2705        mode: StatusOptionsMode,
2706    ) -> Result<StatusOptionsReport, SourceError> {
2707        let before = self.status_snapshot().await?;
2708        let configured = self
2709            .statuses
2710            .targets
2711            .iter()
2712            // A terminal category's option is as configured as an open one's: a terminal
2713            // write validates it before closing and refuses when the board lacks it.
2714            .filter_map(|target| match target {
2715                StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2716                    Some(name.as_str().to_owned())
2717                }
2718                StatusTarget::Disabled => None,
2719            });
2720        let missing = configured
2721            .filter(|wanted| {
2722                !before
2723                    .options
2724                    .iter()
2725                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2726            })
2727            .collect::<Vec<_>>();
2728        let report = StatusOptionsReport {
2729            source: self.name.clone(),
2730            missing: missing.clone(),
2731            outcome: match (mode, missing.is_empty()) {
2732                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2733                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2734                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2735            },
2736            existing: before.options.clone(),
2737        };
2738        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2739            return Ok(report);
2740        }
2741        let mut options = before
2742            .options
2743            .iter()
2744            .map(|option| {
2745                json!({
2746                    "id": option.id, "name": option.name, "color": option.color,
2747                    "description": option.description,
2748                })
2749            })
2750            .collect::<Vec<_>>();
2751        options.extend(missing.iter().map(|name| {
2752            json!({
2753                "name": name, "color": "GRAY", "description": ""
2754            })
2755        }));
2756        self.graphql(
2757            graphql::STATUS_OPTIONS_UPDATE,
2758            json!({"input": {
2759                "projectId": before.board_id, "fieldId": before.field_id,
2760                "singleSelectOptions": options,
2761            }}),
2762        )
2763        .await?;
2764        let after = self.status_snapshot().await?;
2765        let options_preserved = before
2766            .options
2767            .iter()
2768            .all(|old| after.options.iter().any(|new| new == old));
2769        let additions_present = missing.iter().all(|wanted| {
2770            after
2771                .options
2772                .iter()
2773                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2774        });
2775        if !options_preserved || !additions_present || after.assignments != before.assignments {
2776            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2777                SourceError::Malformed {
2778                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2779                }
2780            })?;
2781            return Err(SourceError::Refused {
2782                message: format!(
2783                    "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}"
2784                ),
2785            });
2786        }
2787        Ok(report)
2788    }
2789
2790    /// A fresh snapshot of the Status field and every board item's assignment of it.
2791    ///
2792    /// # Errors
2793    ///
2794    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2795    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2796        // Status alone, as this operation has always read it: a `Priority` field is another
2797        // operation's, so nothing about it can refuse this one.
2798        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2799        let field = board
2800            .fields
2801            .remove(&BoardField::Status)
2802            .ok_or_else(|| self.no_status_field())?;
2803        Ok(StatusSnapshot {
2804            assignments: board.assignments(BoardField::Status),
2805            board_id: board.board_id,
2806            field_id: field.field_id,
2807            options: field.options,
2808        })
2809    }
2810
2811    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2812    fn no_status_field(&self) -> SourceError {
2813        SourceError::Refused {
2814            message: format!("source {} board has no Status field", self.name),
2815        }
2816    }
2817
2818    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2819    // the real CLI loopback journey, including pagination. The individual malformed guards
2820    // are defensive validation of a schema-pinned third-party response, not separate user
2821    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2822    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2823    /// every board item's value of each, walked to the end of the board's items. A field not
2824    /// in `owned` is read past whatever it holds.
2825    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2826        let mut after: Option<String> = None;
2827        let mut snapshot: Option<BoardSnapshot> = None;
2828        loop {
2829            let data = self
2830                .graphql(
2831                    graphql::STATUS_OPTIONS_SNAPSHOT,
2832                    json!({
2833                        "owner": self.owner, "number": self.project_number,
2834                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2835                    }),
2836                )
2837                .await?;
2838            let board = data
2839                .pointer("/owner/projectV2")
2840                .filter(|board| board.is_object())
2841                .ok_or_else(|| SourceError::Refused {
2842                    message: format!(
2843                        "source {} has no accessible GitHub Projects board",
2844                        self.name
2845                    ),
2846                })?;
2847            if board
2848                .pointer("/fields/pageInfo/hasNextPage")
2849                .and_then(Value::as_bool)
2850                != Some(false)
2851            {
2852                return Err(SourceError::Malformed {
2853                    message:
2854                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2855                            .into(),
2856                });
2857            }
2858            let mut fields = BTreeMap::new();
2859            // Only the fields this setup owns, by name: a node the single-select fragment did not
2860            // match carries no name, and a person's own single-select field — a `Size`, a
2861            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2862            // `Status` or `Priority` field without its options is malformed, not absent.
2863            // 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.
2864            for (owned, field) in board
2865                .pointer("/fields/nodes")
2866                .and_then(Value::as_array)
2867                .ok_or_else(|| SourceError::Malformed {
2868                    message: "GitHub project fields.nodes is not an array".into(),
2869                })?
2870                .iter()
2871                .filter_map(|field| {
2872                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2873                    owned.contains(&named).then_some((named, field))
2874                })
2875            {
2876                let options = field
2877                    .get("options")
2878                    .and_then(Value::as_array)
2879                    .ok_or_else(|| SourceError::Malformed {
2880                        message: "GitHub single-select field options is not an array".into(),
2881                    })?
2882                    .iter()
2883                    .map(|option| {
2884                        Ok(StatusOption {
2885                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2886                                .map_err(|message| SourceError::Malformed { message })?,
2887                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2888                                .map_err(|message| SourceError::Malformed {
2889                                    message: format!(
2890                                        "GitHub single-select option name is invalid: {message}"
2891                                    ),
2892                                })?,
2893                            color: serde_json::from_value(
2894                                option.get("color").cloned().unwrap_or(Value::Null),
2895                            )
2896                            .map_err(|error| {
2897                                SourceError::Malformed {
2898                                    message: format!(
2899                                        "GitHub single-select option color is invalid: {error}"
2900                                    ),
2901                                }
2902                            })?,
2903                            description: optional_str(option, "description")?
2904                                .unwrap_or_default()
2905                                .to_owned(),
2906                        })
2907                    })
2908                    .collect::<Result<Vec<_>, SourceError>>()?;
2909                let snapshot = SnapshotField {
2910                    field_id: required_nonblank_str(field, "id")?.to_owned(),
2911                    options,
2912                };
2913                // A board's field names are unique, so a second one is an answer that cannot
2914                // say which field the setup would act on — refused rather than one chosen.
2915                if fields.insert(owned, snapshot).is_some() {
2916                    return Err(SourceError::Malformed {
2917                        message: format!(
2918                            "GitHub answered two {} fields for this board",
2919                            owned.name()
2920                        ),
2921                    });
2922                }
2923            }
2924            let board_id = required_nonblank_str(board, "id")?.to_owned();
2925            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2926                board_id,
2927                fields,
2928                items: Vec::new(),
2929            });
2930            let items = board
2931                .pointer("/items/nodes")
2932                .and_then(Value::as_array)
2933                .ok_or_else(|| SourceError::Malformed {
2934                    message: "GitHub project items.nodes is not an array".into(),
2935                })?;
2936            for item in items {
2937                let field_values =
2938                    item.get("fieldValues")
2939                        .ok_or_else(|| SourceError::Malformed {
2940                            message: "GitHub project item is missing fieldValues".into(),
2941                        })?;
2942                if field_values
2943                    .pointer("/pageInfo/hasNextPage")
2944                    .and_then(Value::as_bool)
2945                    != Some(false)
2946                {
2947                    return Err(SourceError::Malformed {
2948                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2949                    });
2950                }
2951                let values = item
2952                    .pointer("/fieldValues/nodes")
2953                    .and_then(Value::as_array)
2954                    .ok_or_else(|| SourceError::Malformed {
2955                        message: "GitHub project item fieldValues.nodes is not an array".into(),
2956                    })?;
2957                let item_id = required_nonblank_str(item, "id")?;
2958                let mut assigned = BTreeMap::new();
2959                for value in values {
2960                    let Some(field) = value
2961                        .pointer("/field/name")
2962                        .and_then(Value::as_str)
2963                        .and_then(BoardField::named)
2964                        .filter(|field| owned.contains(field))
2965                    else {
2966                        continue;
2967                    };
2968                    let held = assigned.insert(
2969                        field,
2970                        AssignedStatusOption {
2971                            id: StatusOptionId::try_from(
2972                                required_str(value, "optionId")?.to_owned(),
2973                            )
2974                            .map_err(|message| SourceError::Malformed { message })?,
2975                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2976                                .map_err(|message| SourceError::Malformed {
2977                                    message: format!(
2978                                        "GitHub assigned {} name is invalid: {message}",
2979                                        field.name()
2980                                    ),
2981                                })?,
2982                        },
2983                    );
2984                    // An item holds one value of a field, so a second one leaves no way to
2985                    // tell which it holds — and a verification or recovery built on either
2986                    // could restore the wrong one.
2987                    if held.is_some() {
2988                        return Err(SourceError::Malformed {
2989                            message: format!(
2990                                "GitHub answered two {} values for board item {item_id}",
2991                                field.name()
2992                            ),
2993                        });
2994                    }
2995                }
2996                current.items.push((item_id.to_owned(), assigned));
2997            }
2998            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2999                message: "GitHub project is missing items".into(),
3000            })?;
3001            let has_next = page
3002                .pointer("/pageInfo/hasNextPage")
3003                .and_then(Value::as_bool)
3004                .ok_or_else(|| SourceError::Malformed {
3005                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3006                })?;
3007            if !has_next {
3008                break;
3009            }
3010            let next =
3011                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3012            validate_cursor_progress(after.as_deref(), next)?;
3013            after = Some(next.to_owned());
3014        }
3015        snapshot.ok_or_else(|| SourceError::Malformed {
3016            message: "GitHub returned no board field snapshot".into(),
3017        })
3018    }
3019    // llmlint: ignore-end[changed_behavior_has_e2e]
3020
3021    /// Report every board field this source's configuration names and, with
3022    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3023    /// the `Priority` field when the board has none.
3024    ///
3025    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3026    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3027    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3028    /// color and description: the whole option list goes back with every existing id, because
3029    /// a re-minted id clears every item's value.
3030    ///
3031    /// # Errors
3032    ///
3033    /// Refuses a board without a single-select `Status` field. After an apply the board is
3034    /// read again, and a pre-existing option or any item's value of either field that moved is
3035    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3036    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3037    // unchanged apply, a created field, an added option to each field, drift refusal, a board
3038    // with no Status field and a non-github-projects source through the compiled CLI against
3039    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3040    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3041        let owned: Vec<BoardField> = if self.priorities.is_some() {
3042            vec![BoardField::Status, BoardField::Priority]
3043        } else {
3044            vec![BoardField::Status]
3045        };
3046        let before = self.board_snapshot(&owned).await?;
3047        let mut plans = vec![FieldPlan {
3048            field: BoardField::Status,
3049            wanted: self
3050                .statuses
3051                .targets
3052                .iter()
3053                .filter_map(|target| match target {
3054                    StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
3055                        Some(name.as_str().to_owned())
3056                    }
3057                    StatusTarget::Disabled => None,
3058                })
3059                .collect(),
3060        }];
3061        if !before.fields.contains_key(&BoardField::Status) {
3062            return Err(self.no_status_field());
3063        }
3064        if let Some(mapping) = &self.priorities {
3065            plans.push(FieldPlan {
3066                field: BoardField::Priority,
3067                wanted: mapping.names().map(str::to_owned).collect(),
3068            });
3069        }
3070        // The snapshot reads single-select fields alone, so a field it did not find may still
3071        // be on the board under the name, of another type: creating one beside it would fail
3072        // part way, or leave two fields of one name. Asked of the board's own field list, and
3073        // only when a field is missing.
3074        if plans
3075            .iter()
3076            .any(|plan| !before.fields.contains_key(&plan.field))
3077        {
3078            let board = self.board_fields().await?;
3079            for plan in plans
3080                .iter()
3081                .filter(|plan| !before.fields.contains_key(&plan.field))
3082            {
3083                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3084                    return Err(SourceError::Refused {
3085                        message: format!(
3086                            "source {}'s board has a {} field that is not a single-select field \
3087                             (it is a {}), so it cannot hold this source's options; next: rename \
3088                             or remove that field, then run this again",
3089                            self.name,
3090                            plan.field.name(),
3091                            optional_str(field, "__typename")?.unwrap_or("field of another type")
3092                        ),
3093                    });
3094                }
3095            }
3096        }
3097        let mut reports = Vec::new();
3098        for plan in &plans {
3099            let held = before.fields.get(&plan.field);
3100            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3101            let mut missing: Vec<String> = Vec::new();
3102            for wanted in &plan.wanted {
3103                let present = existing
3104                    .iter()
3105                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3106                    || missing
3107                        .iter()
3108                        .any(|named| named.eq_ignore_ascii_case(wanted));
3109                if !present {
3110                    missing.push(wanted.clone());
3111                }
3112            }
3113            reports.push(FieldReport {
3114                field: plan.field,
3115                exists: held.is_some(),
3116                outcome: match (mode, held.is_some(), missing.is_empty()) {
3117                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3118                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3119                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3120                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
3121                },
3122                missing,
3123                existing,
3124            });
3125        }
3126        let report = FieldsReport {
3127            source: self.name.clone(),
3128            fields: reports,
3129        };
3130        let writes: Vec<&FieldReport> = report
3131            .fields
3132            .iter()
3133            .filter(|field| !field.missing.is_empty() || !field.exists)
3134            .collect();
3135        if mode == SetupMode::Plan || writes.is_empty() {
3136            return Ok(report);
3137        }
3138        let mut landed: Vec<&str> = Vec::new();
3139        for field in &writes {
3140            let added = field
3141                .missing
3142                .iter()
3143                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3144            let sent = match before.fields.get(&field.field) {
3145                Some(held) => {
3146                    let mut options = held
3147                        .options
3148                        .iter()
3149                        .map(|option| {
3150                            json!({
3151                                "id": option.id, "name": option.name, "color": option.color,
3152                                "description": option.description,
3153                            })
3154                        })
3155                        .collect::<Vec<_>>();
3156                    options.extend(added);
3157                    self.graphql(
3158                        graphql::STATUS_OPTIONS_UPDATE,
3159                        json!({"input": {
3160                            "projectId": before.board_id, "fieldId": held.field_id,
3161                            "singleSelectOptions": options,
3162                        }}),
3163                    )
3164                    .await
3165                }
3166                None => {
3167                    self.graphql(
3168                        graphql::CREATE_FIELD,
3169                        json!({"input": {
3170                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3171                            "name": field.field.name(),
3172                            "singleSelectOptions": added.collect::<Vec<_>>(),
3173                        }}),
3174                    )
3175                    .await
3176                }
3177            };
3178            // A mutation that failed does not establish that GitHub left its field as it was,
3179            // so every failure from here on carries the recovery data a drift refusal does.
3180            match sent {
3181                Ok(_) => landed.push(field.field.name()),
3182                Err(error) => {
3183                    let changed = if landed.is_empty() {
3184                        String::new()
3185                    } else {
3186                        format!("changed the {} field and then ", landed.join(" and "))
3187                    };
3188                    return Err(SourceError::Refused {
3189                        message: format!(
3190                            "the guarded field setup {changed}failed on the {} field, which it may \
3191                             have changed part way: {error}; the pre-write item assignments \
3192                             are:\n{}",
3193                            field.field.name(),
3194                            recovery(&report, &before)?
3195                        ),
3196                    });
3197                }
3198            }
3199        }
3200        // The board has been written, so a verification read that fails leaves it unverified
3201        // rather than unchanged, and says what to put back.
3202        let after = match self.board_snapshot(&owned).await {
3203            Ok(after) => after,
3204            Err(error) => {
3205                return Err(SourceError::Refused {
3206                    message: format!(
3207                        "the guarded field setup changed the {} field and then could not read the \
3208                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3209                        landed.join(" and "),
3210                        recovery(&report, &before)?
3211                    ),
3212                });
3213            }
3214        };
3215        let mut moved = Vec::new();
3216        for field in &report.fields {
3217            let name = field.field.name();
3218            let now = after
3219                .fields
3220                .get(&field.field)
3221                .map(|held| held.options.as_slice())
3222                .unwrap_or_default();
3223            if !field.existing.iter().all(|old| now.contains(old)) {
3224                moved.push(format!(
3225                    "a pre-existing {name} option id, name, color or description"
3226                ));
3227            }
3228            if !field.missing.iter().all(|wanted| {
3229                now.iter()
3230                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3231            }) {
3232                moved.push(format!("an added {name} option"));
3233            }
3234            if after.assignments(field.field) != before.assignments(field.field) {
3235                moved.push(format!("an item's {name} value"));
3236            }
3237        }
3238        if !moved.is_empty() {
3239            return Err(SourceError::Refused {
3240                message: format!(
3241                    "GitHub changed {} after the guarded field setup; the pre-write item \
3242                     assignments are:\n{}",
3243                    moved.join(", "),
3244                    recovery(&report, &before)?
3245                ),
3246            });
3247        }
3248        Ok(report)
3249    }
3250
3251    /// Validate configuration and capture the named credential without exposing it.
3252    ///
3253    /// # Errors
3254    ///
3255    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3256    /// [`SourceError::Auth`] when the named credential is missing or empty.
3257    pub fn new(
3258        name: &SourceName,
3259        config: GitHubProjectsConfig,
3260        secrets: &dyn SecretResolver,
3261    ) -> Result<Self, SourceError> {
3262        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3263    }
3264
3265    /// The same, recording every request it sends into an accounting the caller holds too.
3266    ///
3267    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3268    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3269    /// up — passes the one it records those into, so the session total accounts for the
3270    /// whole session rather than for this source's share of it.
3271    ///
3272    /// # Errors
3273    ///
3274    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3275    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3276    pub fn recording_into(
3277        name: &SourceName,
3278        config: GitHubProjectsConfig,
3279        secrets: &dyn SecretResolver,
3280        ledger: Arc<Accounting>,
3281    ) -> Result<Self, SourceError> {
3282        if !valid_github_owner(&config.owner) {
3283            return Err(SourceError::Config {
3284                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3285            });
3286        }
3287        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3288            return Err(SourceError::Config {
3289                message: format!("project_number must be between 1 and {}", i32::MAX),
3290            });
3291        }
3292        if !valid_environment_name(&config.token_env) {
3293            return Err(SourceError::Config {
3294                message: "token_env must be a valid environment-variable name".into(),
3295            });
3296        }
3297        let repository = config
3298            .repository
3299            .as_deref()
3300            .map(RepositoryTarget::parse)
3301            .transpose()?;
3302        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3303            message: format!("endpoint is not a valid URL: {e}"),
3304        })?;
3305        if endpoint.scheme() != "https"
3306            && !(endpoint.scheme() == "http"
3307                && endpoint
3308                    .host_str()
3309                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3310        {
3311            return Err(SourceError::Config {
3312                message:
3313                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3314                        .into(),
3315            });
3316        }
3317        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3318            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),
3319        })?;
3320        Ok(Self {
3321            name: name.clone(),
3322            owner: config.owner,
3323            project_number: config.project_number,
3324            repository,
3325            endpoint,
3326            token,
3327            credential_name: config.token_env,
3328            statuses: StatusMapping::resolve(config.status_mapping, name)?,
3329            priorities: config
3330                .priority_mapping
3331                .map(|mapping| PriorityMapping::resolve(mapping, name))
3332                .transpose()?,
3333            client: Client::builder()
3334                .user_agent("onetaskgraph")
3335                .build()
3336                .map_err(|e| SourceError::Config {
3337                    message: format!("cannot build HTTP client: {e}"),
3338                })?,
3339            created: Mutex::new(Vec::new()),
3340            updated: Mutex::new(Vec::new()),
3341            pacing: Pacing::resolve(config.pacing, name)?,
3342            last_mutation: Mutex::new(None),
3343            board_cache: Mutex::new(None),
3344            search_cache: Mutex::new(None),
3345            narrowed_cache: Mutex::new(BTreeMap::new()),
3346            resolved_cache: Mutex::new(BTreeMap::new()),
3347            search_next: Mutex::new(BTreeMap::new()),
3348            fields_cache: Mutex::new(None),
3349            repository_cache: Mutex::new(BTreeMap::new()),
3350            ledger,
3351        })
3352    }
3353
3354    /// A snapshot of every request this source has sent, and what each cost.
3355    ///
3356    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3357    /// of them can sit side by side. When this source was built with
3358    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3359    /// point of building it that way.
3360    #[must_use]
3361    pub fn accounting(&self) -> accounting::Session {
3362        self.ledger.snapshot()
3363    }
3364
3365    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3366    /// rate limit rather than handing it straight back as an error.
3367    ///
3368    /// Retrying is safe for every document here, including the mutations, and the reason
3369    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3370    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3371    /// this replays has already taken effect. An outcome this source cannot know — the
3372    /// send failed, or the body could not be read, so the mutation may well have landed —
3373    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3374    /// attempt. A duplicate write would come from replaying one of those, and none is
3375    /// replayed.
3376    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3377        if is_mutation(query)
3378            && ![
3379                graphql::ADD_COMMENT,
3380                graphql::UPDATE_COMMENT,
3381                graphql::DELETE_COMMENT,
3382            ]
3383            .contains(&query)
3384        {
3385            let mut cache = self.resolved_cache()?;
3386            for argument in ["input", "second", "third", "clear"] {
3387                if let Some(input) = variables.get(argument) {
3388                    cache.retain(|id, item| {
3389                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3390                            input
3391                                .get(key)
3392                                .and_then(Value::as_str)
3393                                .is_some_and(|value| value == id.0 || value == item.item_id)
3394                        })
3395                    });
3396                }
3397            }
3398        }
3399        let doing = operation_description(query);
3400        let mut waited = Duration::ZERO;
3401        let mut waits = 0_u32;
3402        let mut backoff = self.pacing.retry_backoff;
3403        loop {
3404            if is_mutation(query) {
3405                let spacing = self.reserve_mutation_slot();
3406                if !spacing.is_zero() {
3407                    tokio::time::sleep(spacing).await;
3408                }
3409            }
3410            let attempt = self.send_once(query, &variables).await;
3411            if is_mutation(query) {
3412                self.finish_mutation();
3413            }
3414            let limited = match attempt {
3415                Ok(data) => return Ok(data),
3416                Err(Attempt::Failed(error)) => return Err(error),
3417                Err(Attempt::Limited(limited)) => limited,
3418            };
3419            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3420            // move that extends a secondary limit, so a hint below the schedule's own next
3421            // wait is raised to it.
3422            let wait = match limited.hint {
3423                Some(hint) => Duration::from_secs(hint).max(backoff),
3424                None => backoff,
3425            };
3426            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3427            // A wait of nothing spends none of the budget, so it is exhaustion rather
3428            // than a retry. `Pacing::resolve` rules out every way of configuring one
3429            // except a budget of zero, where reporting the first refusal is the ask.
3430            if wait.is_zero() || wait > remaining {
3431                return Err(limited.exhausted(
3432                    doing,
3433                    waits,
3434                    waited,
3435                    wait,
3436                    self.pacing.retry_budget,
3437                ));
3438            }
3439            tokio::time::sleep(wait).await;
3440            waited += wait;
3441            waits += 1;
3442            backoff = backoff.saturating_mul(2);
3443        }
3444    }
3445
3446    /// The next moment a content-creating mutation may leave this source, as a wait from
3447    /// now.
3448    ///
3449    /// The slot is reserved under the lock and the waiting happens outside it, so two
3450    /// callers take two slots rather than the same one — and no lock is held across an
3451    /// await.
3452    ///
3453    /// The moment it is spaced from is the previous mutation's *completion*, which
3454    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3455    /// own is the wrong thing to measure from.
3456    fn reserve_mutation_slot(&self) -> Duration {
3457        if self.pacing.min_mutation_interval.is_zero() {
3458            return Duration::ZERO;
3459        }
3460        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3461        // it would turn an earlier failure into a second one for no gain.
3462        let mut last = self
3463            .last_mutation
3464            .lock()
3465            .unwrap_or_else(std::sync::PoisonError::into_inner);
3466        let now = Instant::now();
3467        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3468        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3469        let at = last.map_or(now, |previous| {
3470            previous
3471                .checked_add(self.pacing.min_mutation_interval)
3472                .map_or(now, |earliest| earliest.max(now))
3473        });
3474        *last = Some(at);
3475        at.saturating_duration_since(now)
3476    }
3477
3478    /// Record that a content-creating mutation has finished, so the next one is spaced
3479    /// from here rather than from the moment this one was released.
3480    ///
3481    /// This source can only choose when a request *departs*; the limiter counts when it
3482    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3483    /// departure from the last therefore hands the limiter a gap of the interval less that
3484    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3485    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3486    /// slower machine while passing on a quick one.
3487    ///
3488    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3489    /// previous request had already arrived before its response came back, so its arrival
3490    /// is no later than this moment, and the next mutation is released at least the
3491    /// interval after this moment and arrives no earlier than it is released: the gap the
3492    /// limiter measures is therefore at least the interval, whatever transit costs and on
3493    /// whatever platform. The price is that a mutation's own round trip no longer counts
3494    /// towards its spacing, which makes this source slightly slower than the configured
3495    /// rate rather than slightly faster — the safe side of a limit that punishes being
3496    /// wrong by refusing reads for the next fifty minutes.
3497    ///
3498    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3499    /// and one that never left costs only a wait nobody needed.
3500    fn finish_mutation(&self) {
3501        if self.pacing.min_mutation_interval.is_zero() {
3502            return;
3503        }
3504        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3505        let mut last = self
3506            .last_mutation
3507            .lock()
3508            .unwrap_or_else(std::sync::PoisonError::into_inner);
3509        let now = Instant::now();
3510        // `max` rather than an assignment: a concurrent caller may already have reserved a
3511        // slot further out, and completing this request must never pull that slot back in.
3512        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3513    }
3514
3515    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3516    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3517    ///
3518    /// This is the one place a request leaves this crate, which is why the accounting is
3519    /// here rather than at each of the callers: a read path added later is counted without
3520    /// anybody remembering to count it, and
3521    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3522    /// when one is not.
3523    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3524        let Attempted {
3525            result,
3526            limits,
3527            reported_cost,
3528        } = self.attempt(query, variables).await;
3529        // No `otherwise` name: every document this source sends is one of its own, and the
3530        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3531        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3532        let outcome = match &result {
3533            Ok(_) => accounting::Outcome::Answered,
3534            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3535            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3536        };
3537        self.ledger.record(sending.finished(outcome, limits));
3538        result
3539    }
3540
3541    /// The attempt itself, with what its response said about the rate limit alongside.
3542    ///
3543    /// The two are returned together rather than recorded here because every one of the
3544    /// early exits below is a different outcome, and a record written at each of them is a
3545    /// record one of them can be added without.
3546    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3547        let mut limits = accounting::RateLimit::default();
3548        let mut reported_cost = None;
3549        let result = self
3550            .attempted(query, variables, &mut limits, &mut reported_cost)
3551            .await;
3552        Attempted {
3553            result,
3554            limits,
3555            reported_cost,
3556        }
3557    }
3558
3559    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3560    async fn attempted(
3561        &self,
3562        query: &str,
3563        variables: &Value,
3564        limits: &mut accounting::RateLimit,
3565        reported_cost: &mut Option<u64>,
3566    ) -> Result<Value, Attempt> {
3567        let response = self
3568            .client
3569            .post(self.endpoint.clone())
3570            .bearer_auth(self.token.expose_secret())
3571            .json(&json!({"query": query, "variables": variables}))
3572            .send()
3573            .await
3574            .map_err(|e| {
3575                Attempt::Failed(SourceError::Unavailable {
3576                    message: format!("GitHub GraphQL request failed: {e}"),
3577                })
3578            })?;
3579        let status = response.status();
3580        let header = |name: &str| whole_seconds(response.headers().get(name));
3581        *limits = accounting::RateLimit::read(|name| {
3582            response
3583                .headers()
3584                .get(name)
3585                .and_then(|value| value.to_str().ok())
3586                .map(str::to_owned)
3587        });
3588        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3589        // that are not text at all — is "not known to be exhausted". This never makes a
3590        // response a refusal on its own: it says which limiter a refusal is attributed to
3591        // and where its hint comes from, so a value this cannot read costs a hint rather
3592        // than an answer.
3593        let exhausted = response
3594            .headers()
3595            .get("x-ratelimit-remaining")
3596            .and_then(|value| value.to_str().ok())
3597            == Some("0");
3598        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3599        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3600        // which is the same question answered as an absolute time. Nothing else here is a
3601        // hint, and a schedule is what answers a refusal that carries none.
3602        let hint = header("retry-after").or_else(|| {
3603            exhausted
3604                .then(|| header("x-ratelimit-reset"))
3605                .flatten()
3606                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3607        });
3608        // Read before it is parsed, because the evidence which tells a secondary rate
3609        // limit from a rejected credential is in the body of a response whose status says
3610        // only "forbidden" — and a non-success response was never parsed at all.
3611        let body = response.text().await.map_err(|e| {
3612            Attempt::Failed(SourceError::Unavailable {
3613                message: format!("GitHub GraphQL response could not be read: {e}"),
3614            })
3615        })?;
3616        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3617            return Err(Attempt::Limited(Limited { limiter, hint }));
3618        }
3619        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3620            return Err(Attempt::Failed(SourceError::Auth {
3621                message: format!(
3622                    "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"
3623                ),
3624            }));
3625        }
3626        if !status.is_success() {
3627            return Err(Attempt::Failed(SourceError::Unavailable {
3628                message: format!("GitHub GraphQL returned HTTP {status}"),
3629            }));
3630        }
3631        // GitHub reports what a call cost only when the document asked it to, and no
3632        // document this source sends does — so this is `None` here and carries the figure
3633        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3634        // pick up is a `dryRun` probe's cost, which is some other document's.
3635        *reported_cost = serde_json::from_str::<Value>(&body)
3636            .ok()
3637            .as_ref()
3638            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3639            .and_then(Value::as_u64);
3640        self.answer(&body).map_err(Attempt::Failed)
3641    }
3642
3643    /// What one successful HTTP response says, once its GraphQL errors are read.
3644    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3645        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3646            message: format!("GitHub returned invalid JSON: {e}"),
3647        })?;
3648        let errors = body
3649            .get("errors")
3650            .map(|value| {
3651                value.as_array().ok_or_else(|| SourceError::Malformed {
3652                    message: "GitHub response errors is not an array".into(),
3653                })
3654            })
3655            .transpose()?;
3656        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3657            let messages = errors
3658                .iter()
3659                .filter_map(|e| e.get("message").and_then(Value::as_str))
3660                .collect::<Vec<_>>()
3661                .join("; ");
3662            let message = if messages.is_empty() {
3663                "GitHub returned GraphQL errors".into()
3664            } else {
3665                messages
3666            };
3667            let normalized = message.to_ascii_lowercase();
3668            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3669                return Err(SourceError::Auth {
3670                    message: format!(
3671                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3672                        self.credential_name
3673                    ),
3674                });
3675            }
3676            return Err(SourceError::Refused { message });
3677        }
3678        body.get("data")
3679            .filter(|data| data.is_object())
3680            .cloned()
3681            .ok_or_else(|| SourceError::Malformed {
3682                message: "GitHub response has no data object".into(),
3683            })
3684    }
3685
3686    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3687    // GraphQL cannot independently page them inside the outer item page. This source page is
3688    // deliberately bounded at that published maximum; the live drift journey exercises it.
3689    async fn board_page(
3690        &self,
3691        items_after: Option<&str>,
3692        items_first: u32,
3693    ) -> Result<Value, SourceError> {
3694        let data = self
3695            .graphql(
3696                graphql::BOARD,
3697                json!({"owner":self.owner,"number":self.project_number,
3698                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3699                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3700            )
3701            .await?;
3702        data.pointer("/owner/projectV2")
3703            .filter(|v| !v.is_null())
3704            .cloned()
3705            .ok_or_else(|| SourceError::Refused {
3706                message: format!(
3707                    "GitHub project {}/{} was not found or is not visible to the token",
3708                    self.owner, self.project_number
3709                ),
3710            })
3711    }
3712
3713    /// The search that finds the issues of this board, narrowed by `also` when it is
3714    /// given.
3715    ///
3716    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3717    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3718    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3719    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3720    /// from a task by the `parent` field each issue carries rather than by the search.
3721    fn board_search(&self, also: Option<&str>) -> String {
3722        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3723        match also {
3724            Some(also) => format!("{scope} {also}"),
3725            None => scope,
3726        }
3727    }
3728
3729    /// One issue this source reached directly, as the board item a read of the board would
3730    /// have produced — or `None` when this board does not hold it.
3731    ///
3732    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3733    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3734    /// item's own id, that item's field values, and the issue as its content. One resolver
3735    /// for both routes is what makes an issue read through a search, through its own node
3736    /// id, or through its project's sub-issues report the same title, the same status, the
3737    /// same labels and the same qualified id.
3738    ///
3739    /// An issue with no entry for *this* board is not this source's to report, which is
3740    /// what keeps an id naming some other repository's issue from being answered as an item
3741    /// of this board. That answer is given about an **exhausted** connection and never
3742    /// about an unread page: the entry is looked for on the page in hand, and only if that
3743    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3744    /// rest of it.
3745    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3746        if optional_str(issue, "__typename")? != Some("Issue") {
3747            return Ok(None);
3748        }
3749        let memberships = issue
3750            .get("projectItems")
3751            .ok_or_else(|| SourceError::Malformed {
3752                message: "GitHub issue is missing projectItems".into(),
3753            })?;
3754        let nodes = memberships
3755            .get("nodes")
3756            .and_then(Value::as_array)
3757            .ok_or_else(|| SourceError::Malformed {
3758                message: "GitHub issue projectItems.nodes is not an array".into(),
3759            })?;
3760        let held = match self.board_entry(nodes) {
3761            Some(held) => held.clone(),
3762            None => {
3763                let info = memberships
3764                    .get("pageInfo")
3765                    .ok_or_else(|| SourceError::Malformed {
3766                        message: "GitHub issue projectItems has no pageInfo".into(),
3767                    })?;
3768                // The page held no entry for this board. Whether that means the issue is
3769                // not on it is a question about the rest of the connection, and only a
3770                // connection with no rest answers it here.
3771                if !required_bool(info, "hasNextPage")? {
3772                    return Ok(None);
3773                }
3774                let cursor = required_str(info, "endCursor")?;
3775                validate_cursor_progress(None, cursor)?;
3776                let issue_id = required_str(issue, "id")?;
3777                match self.board_membership(issue_id, cursor).await? {
3778                    Some(held) => held,
3779                    None => return Ok(None),
3780                }
3781            }
3782        };
3783        let item = json!({
3784            "id": required_str(&held, "id")?,
3785            "project": held.get("project"),
3786            "fieldValues": held.get("fieldValues"),
3787            "content": issue,
3788        });
3789        self.resolve(&item)
3790    }
3791
3792    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3793    ///
3794    /// One spelling of *which membership is this board's*, so the page a read carries and
3795    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3796    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3797        nodes.iter().find(|node| {
3798            node.pointer("/project/number").and_then(Value::as_u64)
3799                == Some(u64::from(self.project_number))
3800        })
3801    }
3802
3803    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3804    ///
3805    /// The recovery read: a page of memberships that holds no entry for this board says
3806    /// nothing about the memberships past it, so the connection is walked to exhaustion
3807    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3808    /// positive answer — the whole connection was read and no entry named this board —
3809    /// rather than a failure, and the walk is held to
3810    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3811    /// with a cursor that does not advance is refused instead of spun on.
3812    async fn board_membership(
3813        &self,
3814        issue: &str,
3815        after: &str,
3816    ) -> Result<Option<Value>, SourceError> {
3817        let mut after = after.to_owned();
3818        loop {
3819            let data = self
3820                .graphql(
3821                    graphql::ISSUE_BOARD_ITEMS,
3822                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3823                           "nestedFirst":NESTED_PAGE_SIZE}),
3824                )
3825                .await?;
3826            let Some(connection) = data
3827                .pointer("/node/projectItems")
3828                .filter(|value| !value.is_null())
3829            else {
3830                // The id resolved to nothing, or to something with no memberships to walk —
3831                // which is the same answer as a connection holding no entry for this board.
3832                return Ok(None);
3833            };
3834            let nodes = connection
3835                .get("nodes")
3836                .and_then(Value::as_array)
3837                .ok_or_else(|| SourceError::Malformed {
3838                    message: "GitHub issue projectItems.nodes is not an array".into(),
3839                })?;
3840            if let Some(held) = self.board_entry(nodes) {
3841                return Ok(Some(held.clone()));
3842            }
3843            let info = connection
3844                .get("pageInfo")
3845                .ok_or_else(|| SourceError::Malformed {
3846                    message: "GitHub issue projectItems has no pageInfo".into(),
3847                })?;
3848            let next = required_bool(info, "hasNextPage")?
3849                .then(|| required_str(info, "endCursor"))
3850                .transpose()?;
3851            match next {
3852                Some(next) => {
3853                    validate_cursor_progress(Some(&after), next)?;
3854                    after = next.to_owned();
3855                }
3856                None => return Ok(None),
3857            }
3858        }
3859    }
3860
3861    /// One page of a board-scoped issue search, and where the next page resumes.
3862    async fn search_page(
3863        &self,
3864        search: &str,
3865        first: u32,
3866        after: Option<&str>,
3867    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3868        let data = self
3869            .graphql(
3870                graphql::SEARCH_ISSUES,
3871                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3872                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3873                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3874            )
3875            .await?;
3876        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3877            message: "GitHub search response has no search connection".into(),
3878        })?;
3879        let mut found = Vec::new();
3880        for node in connection
3881            .get("nodes")
3882            .and_then(Value::as_array)
3883            .ok_or_else(|| SourceError::Malformed {
3884                message: "GitHub search nodes is not an array".into(),
3885            })?
3886        {
3887            if let Some(resolved) = self.resolve_issue(node).await? {
3888                found.push(resolved);
3889            }
3890        }
3891        let info = connection
3892            .get("pageInfo")
3893            .ok_or_else(|| SourceError::Malformed {
3894                message: "GitHub search connection has no pageInfo".into(),
3895            })?;
3896        let next = required_bool(info, "hasNextPage")?
3897            .then(|| required_str(info, "endCursor"))
3898            .transpose()?
3899            .map(str::to_owned);
3900        if let Some(next) = &next {
3901            validate_cursor_progress(after, next)?;
3902        }
3903        Ok((found, next))
3904    }
3905
3906    /// Every issue this board holds, completed with what this run wrote.
3907    ///
3908    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3909    /// is an index and is eventually consistent, so an issue this run created seconds ago
3910    /// can be absent from it, and a project listed straight after being written would
3911    /// otherwise be missing from its own board. What is added back is only what this
3912    /// process itself wrote, out of [`Self::created`], which lives and dies with the
3913    /// process.
3914    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3915        let found = self.searched_issues().await?;
3916        self.completed_with_written(found, |_| true)
3917    }
3918
3919    /// Every issue this board's own search reports, walked to exhaustion, read once per
3920    /// source.
3921    ///
3922    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3923    /// needs it too and the two would otherwise walk the same search twice in one command.
3924    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3925    /// is.
3926    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3927        let cached = self.search_cache()?.clone();
3928        if let Some(held) = cached {
3929            return Ok(held);
3930        }
3931        let mut after: Option<String> = None;
3932        let mut found = Vec::new();
3933        let search = self.board_search(None);
3934        loop {
3935            let (page, next) = self
3936                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3937                .await?;
3938            found.extend(page);
3939            match next {
3940                Some(next) => after = Some(next),
3941                None => break,
3942            }
3943        }
3944        *self.search_cache()? = Some(found.clone());
3945        Ok(found)
3946    }
3947
3948    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3949    fn search_cache(
3950        &self,
3951    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3952        self.search_cache
3953            .lock()
3954            .map_err(|_| SourceError::Unavailable {
3955                message: "this source's view of the board's issues was left inconsistent by an \
3956                      earlier failure; next: run the command again"
3957                    .into(),
3958            })
3959    }
3960
3961    /// `found`, with everything this run wrote that `keep` accepts and the read did not
3962    /// report.
3963    ///
3964    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3965    /// at all: the search index is behind, and a node read of an item filed moments ago can
3966    /// be too.
3967    fn completed_with_written(
3968        &self,
3969        mut found: Vec<Resolved>,
3970        keep: impl Fn(&Resolved) -> bool,
3971    ) -> Result<Vec<Resolved>, SourceError> {
3972        for own in self.created()?.iter().filter(|own| keep(own)) {
3973            if !found.iter().any(|item| item.id == own.id) {
3974                found.push(own.clone());
3975            }
3976        }
3977        Ok(found)
3978    }
3979
3980    /// What resolving one node id reached.
3981    ///
3982    /// Three answers rather than an `Option`, because a board *draft* is none of the other
3983    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3984    /// is completed by a read of the draft itself rather than reported as nothing.
3985    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3986        let asked = self
3987            .graphql(
3988                graphql::ISSUE,
3989                json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
3990                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3991            )
3992            .await;
3993        let data = match asked {
3994            Ok(data) => data,
3995            // A string that is not a node id at all is not a failure to report: it is an id
3996            // this board does not hold, which is what every read of one already answers.
3997            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3998            Err(error) => return Err(error),
3999        };
4000        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4001            return Ok(Reached::Nothing);
4002        };
4003        if optional_str(node, "__typename")? == Some("DraftIssue") {
4004            return Ok(Reached::Draft);
4005        }
4006        Ok(match self.resolve_issue(node).await? {
4007            Some(item) => Reached::Held(Box::new(item)),
4008            None => Reached::Nothing,
4009        })
4010    }
4011
4012    /// One item of this board by its own id, whatever kind it is.
4013    ///
4014    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4015    /// run wrote is read first, because a node read of an item created moments ago can
4016    /// still be behind the board field values written onto it — see [`Self::created`].
4017    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4018        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4019            return Ok(Some(own.clone()));
4020        }
4021        match self.reach(id).await? {
4022            Reached::Held(item) => Ok(Some(*item)),
4023            Reached::Nothing => Ok(None),
4024            Reached::Draft => self.draft_by_id(id).await,
4025        }
4026    }
4027
4028    /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4029    /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4030    /// than one request per id.
4031    ///
4032    /// What this run wrote answers first, as it does there, and only the rest is read. One id
4033    /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4034    /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4035    /// id at a time, so that id is answered as not held and the others as themselves; a draft
4036    /// is completed by a read of the draft, exactly as there.
4037    async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4038        let mut found: Vec<Option<Option<Resolved>>> = {
4039            let created = self.created()?;
4040            ids.iter()
4041                .map(|id| {
4042                    created
4043                        .iter()
4044                        .find(|own| own.id == *id)
4045                        .map(|own| Some(own.clone()))
4046                })
4047                .collect()
4048        };
4049        let unread: Vec<NativeId> = ids
4050            .iter()
4051            .zip(&found)
4052            .filter(|(_, found)| found.is_none())
4053            .map(|(id, _)| id.clone())
4054            .collect();
4055        let mut read = Vec::with_capacity(unread.len());
4056        if let [one] = unread.as_slice() {
4057            read.push(self.item_by_id(one).await?);
4058        } else {
4059            for batch in unread.chunks(DETAIL_BATCH) {
4060                let data = match self
4061                    .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4062                    .await
4063                {
4064                    Ok(data) => data,
4065                    Err(error) if unresolvable_node(&error) => {
4066                        for id in batch {
4067                            read.push(self.item_by_id(id).await?);
4068                        }
4069                        continue;
4070                    }
4071                    Err(error) => return Err(error),
4072                };
4073                for (slot, id) in batch.iter().enumerate() {
4074                    let node =
4075                        data.get(format!("i{slot}"))
4076                            .ok_or_else(|| SourceError::Malformed {
4077                                message: format!(
4078                                    "GitHub answered a batch read with no item for {}",
4079                                    id.0
4080                                ),
4081                            })?;
4082                    read.push(if node.is_null() {
4083                        None
4084                    } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4085                        self.draft_by_id(id).await?
4086                    } else {
4087                        if optional_str(node, "__typename")? == Some("Issue")
4088                            && required_str(node, "id")? != id.0
4089                        {
4090                            return Err(SourceError::Malformed {
4091                                message: format!(
4092                                    "GitHub answered the read of {} with issue {}",
4093                                    id.0,
4094                                    required_str(node, "id")?
4095                                ),
4096                            });
4097                        }
4098                        self.resolve_issue(node).await?
4099                    });
4100                }
4101            }
4102        }
4103        let mut read = read.into_iter();
4104        Ok(found
4105            .iter_mut()
4106            .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4107            .collect())
4108    }
4109
4110    fn resolved_cache(
4111        &self,
4112    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4113        self.resolved_cache
4114            .lock()
4115            .map_err(|_| SourceError::Unavailable {
4116                message: "resolved item records were left inconsistent; run the command again"
4117                    .into(),
4118            })
4119    }
4120
4121    /// Reuse a record this invocation already resolved. The mutation sender invalidates
4122    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4123    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4124        let cached = self.resolved_cache()?.get(id).cloned();
4125        match cached {
4126            Some(item) => Ok(Some(item)),
4127            None => self.item_by_id(id).await,
4128        }
4129    }
4130
4131    /// One board draft by its own id, with the board item it sits in — or `None` when no
4132    /// item of this board is that draft's.
4133    ///
4134    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4135    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4136    /// links a draft to one board item, so the page this read carries is the whole of that
4137    /// connection, and a page that reports more than it holds is refused rather than read
4138    /// as an answer about memberships nobody read.
4139    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4140        let data = self
4141            .graphql(
4142                graphql::DRAFT,
4143                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4144                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4145            )
4146            .await?;
4147        // Gone between the two reads is an answer — the draft is no longer there. Anything
4148        // else than the draft [`Self::reach`] was just told this id is, is not one.
4149        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4150            return Ok(None);
4151        };
4152        if optional_str(draft, "__typename")? != Some("DraftIssue") {
4153            return Err(SourceError::Malformed {
4154                message: format!(
4155                    "GitHub answered {} as a draft and then as something else",
4156                    id.0
4157                ),
4158            });
4159        }
4160        if required_str(draft, "id")? != id.0 {
4161            return Err(SourceError::Malformed {
4162                message: format!("GitHub answered a different draft for {}", id.0),
4163            });
4164        }
4165        let memberships = draft
4166            .get("projectV2Items")
4167            .ok_or_else(|| SourceError::Malformed {
4168                message: format!("GitHub draft {} is missing projectV2Items", id.0),
4169            })?;
4170        let nodes = memberships
4171            .get("nodes")
4172            .and_then(Value::as_array)
4173            .ok_or_else(|| SourceError::Malformed {
4174                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4175            })?;
4176        let info = memberships
4177            .get("pageInfo")
4178            .ok_or_else(|| SourceError::Malformed {
4179                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4180            })?;
4181        // Read whether or not this board's entry is on the page: a page claiming more than
4182        // the one item GitHub links a draft to is a malformed answer either way.
4183        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4184            return Err(SourceError::Malformed {
4185                message: format!(
4186                    "GitHub draft {} reports more board items than the one GitHub links a draft \
4187                     to",
4188                    id.0
4189                ),
4190            });
4191        }
4192        if let Some(node) = nodes.first()
4193            && node
4194                .pointer("/project/number")
4195                .and_then(Value::as_u64)
4196                .is_none()
4197        {
4198            return Err(SourceError::Malformed {
4199                message: format!(
4200                    "GitHub draft {} board item has no numeric project number",
4201                    id.0
4202                ),
4203            });
4204        }
4205        let Some(held) = self.board_entry(nodes) else {
4206            return Ok(None);
4207        };
4208        if required_str(
4209            held.get("project").ok_or_else(|| SourceError::Malformed {
4210                message: format!("GitHub draft {} board item has no project", id.0),
4211            })?,
4212            "id",
4213        )? != self.board_fields().await?.id.as_str()
4214        {
4215            return Ok(None);
4216        }
4217        let item = json!({
4218            "id": required_str(held, "id")?,
4219            "project": held.get("project"),
4220            "fieldValues": held.get("fieldValues"),
4221            "content": draft,
4222        });
4223        self.resolve(&item)
4224    }
4225
4226    /// The board's own id and field definitions, for a write whose item does not carry
4227    /// them — never its items.
4228    ///
4229    /// A board this command has already listed supplies them, since it read them beside its
4230    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4231    /// is consulted about which items the board holds: see the module documentation for
4232    /// why a question about one known item is answered by reading that item.
4233    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4234        if let Some(board) = self.board_cache()?.as_ref() {
4235            return Ok(BoardFields {
4236                id: BoardId::parse(&board.id)?,
4237                fields: board.fields.clone(),
4238            });
4239        }
4240        if let Some(held) = self.fields_cache()?.clone() {
4241            return Ok(held);
4242        }
4243        let data = self
4244            .graphql(
4245                graphql::BOARD_FIELDS,
4246                json!({"owner":self.owner,"number":self.project_number,
4247                       "nestedFirst":NESTED_PAGE_SIZE}),
4248            )
4249            .await?;
4250        self.fields_read(&data)
4251    }
4252
4253    /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4254    /// the rest of this command.
4255    fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4256        let board = data
4257            .pointer("/boardFields/projectV2")
4258            .filter(|value| !value.is_null())
4259            .ok_or_else(|| SourceError::Refused {
4260                message: format!(
4261                    "GitHub project {}/{} was not found or is not visible to the token",
4262                    self.owner, self.project_number
4263                ),
4264            })?;
4265        let read = BoardFields {
4266            id: BoardId::parse(required_str(board, "id")?)?,
4267            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4268        };
4269        *self.fields_cache()? = Some(read.clone());
4270        Ok(read)
4271    }
4272
4273    /// Read what creating an issue in `repository` needs and this command has not read yet —
4274    /// the board's fields and the repository's node id — in one request when it needs both.
4275    ///
4276    /// When either is already known this sends nothing, and the other is read by its own
4277    /// document where it is asked for, so no create reads anything twice.
4278    async fn creation_context(
4279        &self,
4280        repository: &RepositoryTarget,
4281        incoming: &Incoming<'_>,
4282    ) -> Result<(), SourceError> {
4283        let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4284        if fields_known || self.repository_cache()?.contains_key(repository) {
4285            return Ok(());
4286        }
4287        let data = self
4288            .graphql(
4289                graphql::CREATION_CONTEXT,
4290                json!({"owner":self.owner,"number":self.project_number,
4291                       "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4292                       "repositoryName":repository.name}),
4293            )
4294            .await?;
4295        self.fields_read(&data)?;
4296        self.repository_read(&data, repository, incoming)?;
4297        Ok(())
4298    }
4299
4300    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4301    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4302        self.fields_cache
4303            .lock()
4304            .map_err(|_| SourceError::Unavailable {
4305                message: "this source's view of the board's fields was left inconsistent by an \
4306                      earlier failure; next: run the command again"
4307                    .into(),
4308            })
4309    }
4310
4311    /// What a write to `item` needs of the board, read off that item when it says enough and
4312    /// off [`Self::board_fields`] when it does not.
4313    ///
4314    /// A node read of an item names its board and carries the definition of every field it
4315    /// holds a value of — so an item naming its board, holding a value of the origin field,
4316    /// and, when the write carries a status, holding a `Status` value, needs no read of the
4317    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4318    /// of may still be on the board, and a view reading it as absent would refuse a write the
4319    /// board can take or skip a field write the board needs, so such an item — and a create,
4320    /// which has no item yet — takes the board's fields from their own read instead.
4321    async fn fields_for(
4322        &self,
4323        item: Option<&Resolved>,
4324        writes_status: bool,
4325        selects_priority: bool,
4326    ) -> Result<BoardFields, SourceError> {
4327        if let Some(board) = item.and_then(Resolved::carried_board) {
4328            return Ok(board);
4329        }
4330        if let Some(item) = item
4331            && let Some(board_id) = item.named_board()
4332            && item.defines(ORIGIN_FIELD)
4333            && (!writes_status || item.defines("Status"))
4334            && (!selects_priority || item.defines(PRIORITY_FIELD))
4335        {
4336            return Ok(BoardFields {
4337                id: board_id,
4338                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4339            });
4340        }
4341        self.board_fields().await
4342    }
4343
4344    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4345    /// when that id names nothing here with a sub-issue relationship to walk.
4346    ///
4347    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4348    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4349    /// empty vector is a project that holds nothing.
4350    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4351        let mut after: Option<String> = None;
4352        let mut children = Vec::new();
4353        loop {
4354            let asked = self
4355                .graphql(
4356                    graphql::SUB_ISSUES,
4357                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4358                           "nestedFirst":NESTED_PAGE_SIZE,
4359                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4360                )
4361                .await;
4362            let data = match asked {
4363                Ok(data) => data,
4364                // A string that is not a node id at all is not a failure to report: it is
4365                // the ordinary answer to a selector naming a project by its name.
4366                Err(error) if unresolvable_node(&error) => return Ok(None),
4367                Err(error) => return Err(error),
4368            };
4369            let Some(connection) = data
4370                .pointer("/node/subIssues")
4371                .filter(|value| !value.is_null())
4372            else {
4373                // No such node, or one with no sub-issue relationship — a board draft is
4374                // the one this board can really hold.
4375                return Ok(None);
4376            };
4377            for node in connection
4378                .get("nodes")
4379                .and_then(Value::as_array)
4380                .ok_or_else(|| SourceError::Malformed {
4381                    message: "GitHub subIssues.nodes is not an array".into(),
4382                })?
4383            {
4384                if let Some(resolved) = self.resolve_issue(node).await? {
4385                    children.push(resolved);
4386                }
4387            }
4388            let info = connection
4389                .get("pageInfo")
4390                .ok_or_else(|| SourceError::Malformed {
4391                    message: "GitHub subIssues connection has no pageInfo".into(),
4392                })?;
4393            let next = required_bool(info, "hasNextPage")?
4394                .then(|| required_str(info, "endCursor"))
4395                .transpose()?;
4396            match next {
4397                Some(next) => {
4398                    validate_cursor_progress(after.as_deref(), next)?;
4399                    after = Some(next.to_owned());
4400                }
4401                None => return Ok(Some(children)),
4402            }
4403        }
4404    }
4405
4406    /// Which issue of this board a project *name* is, or `None` when none is.
4407    ///
4408    /// One bounded query which filters on that name at the server, rather than a walk of
4409    /// every issue the board holds. The name is compared again here: the qualifier narrows
4410    /// what GitHub sends, and this source decides what it names.
4411    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4412        let search = self.board_search(Some(&title_qualifier(name)));
4413        let mut after = None;
4414        loop {
4415            let (candidates, next) = self
4416                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4417                .await?;
4418            if let Some(item) = candidates.into_iter().find(|item| {
4419                item.kind == BoardKind::Work(ItemKind::Project)
4420                    && item.title.eq_ignore_ascii_case(name)
4421            }) {
4422                return Ok(Some(item.id));
4423            }
4424            match next {
4425                Some(next) => after = Some(next),
4426                None => return Ok(None),
4427            }
4428        }
4429    }
4430
4431    /// Everything filed under one project of this board: the sub-issues of the issue that
4432    /// project is.
4433    ///
4434    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4435    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4436    /// gains projects, or as another project gains tasks.
4437    ///
4438    /// A qualified id names the issue and is asked for its sub-issues directly: one
4439    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4440    /// read as a project *name*, which costs the one bounded search
4441    /// [`Self::project_by_name`] makes.
4442    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4443        let (project, children) = match self.sub_issues(selector).await? {
4444            Some(children) => (selector.clone(), children),
4445            None => match self.project_by_name(&selector.0).await? {
4446                Some(project) => {
4447                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4448                    (project, children)
4449                }
4450                None => return Ok(Vec::new()),
4451            },
4452        };
4453        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4454    }
4455
4456    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4457    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4458    ///
4459    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4460    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4461    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4462    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4463    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4464    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4465    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4466    /// rather than silently narrowing a caller's answer.
4467    ///
4468    /// The instant is written to the second, rounded down, which can only widen what the
4469    /// search returns; confirmation against each candidate's own comments is what makes the
4470    /// answer exact. The search is an index that lags a write by a second or two — the module
4471    /// documentation records it — so a caller that asks again from its last instant should
4472    /// overlap the two by more than that.
4473    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4474        let found = self.searched(&updated_qualifier(since)).await?;
4475        self.completed_with_written(found, |_| true)
4476    }
4477
4478    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4479    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4480    ///
4481    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4482    /// own record is the fresher of the two.
4483    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4484        let search = self.board_search(Some(also));
4485        let mut after: Option<String> = None;
4486        let mut found = Vec::new();
4487        loop {
4488            let (page, next) = self
4489                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4490                .await?;
4491            found.extend(page);
4492            match next {
4493                Some(next) => after = Some(next),
4494                None => return Ok(found),
4495            }
4496        }
4497    }
4498
4499    /// A bounded task answer; the versioned cursor carries the connection position, how
4500    /// many rows of the page starting there were already handed out, and the own-write ids
4501    /// already observed, including across a new source instance.
4502    ///
4503    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4504    /// sliced from the pages it needs; why is the module documentation's paging contract.
4505    async fn search_tasks(
4506        &self,
4507        query: &TaskQuery,
4508        page: &PageRequest,
4509        also: &str,
4510    ) -> Result<Page<Task>, SourceError> {
4511        let mut position = match &page.cursor {
4512            None => SearchPosition::default(),
4513            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4514                .ok()
4515                .filter(|position| {
4516                    position.version == SEARCH_CURSOR_VERSION
4517                        && position.connection.valid_resume(position.offset)
4518                })
4519                .ok_or_else(|| SourceError::Config {
4520                    message: "page cursor is invalid".into(),
4521                })?,
4522        };
4523        let search = self.board_search(Some(also));
4524        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4525        let own = self.with_own_writes(Vec::new())?;
4526        for item in &own {
4527            if !position.own.contains(&item.id) {
4528                position.own.push(item.id.clone());
4529            }
4530        }
4531        let mut tasks = Vec::new();
4532        while !position.connection.exhausted() && tasks.len() < limit {
4533            let first = SEARCH_PAGE_SIZE;
4534            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4535            let key =
4536                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4537                    .expect("search page key is serializable");
4538            let cached = if query.commented_since.is_none() {
4539                self.narrowed_cache()?.get(&key).cloned()
4540            } else {
4541                None
4542            };
4543            let (found, next) = match cached {
4544                Some(found) => {
4545                    let next = self
4546                        .search_next
4547                        .lock()
4548                        .map_err(|_| SourceError::Unavailable {
4549                            message:
4550                                "search pagination was left inconsistent; run the command again"
4551                                    .into(),
4552                        })?
4553                        .get(&key)
4554                        .cloned()
4555                        .flatten();
4556                    (found, next)
4557                }
4558                None => {
4559                    let (found, next) = self
4560                        .search_page(&search, first, position.connection.after())
4561                        .await?;
4562                    if query.commented_since.is_none() {
4563                        self.search_next
4564                            .lock()
4565                            .map_err(|_| SourceError::Unavailable {
4566                                message:
4567                                    "search pagination was left inconsistent; run the command again"
4568                                        .into(),
4569                            })?
4570                            .insert(key.clone(), next.clone());
4571                        self.narrowed_cache()?.insert(key, found.clone());
4572                    }
4573                    (found, next)
4574                }
4575            };
4576            let rows = found.len();
4577            for mut item in found.into_iter().skip(position.offset) {
4578                if tasks.len() == limit {
4579                    break;
4580                }
4581                position.offset += 1;
4582                if position.own.contains(&item.id) {
4583                    if position.seen.contains(&item.id) {
4584                        continue;
4585                    }
4586                    position.seen.push(item.id.clone());
4587                    let updated_at = item.updated_at;
4588                    let Some(written) = self.search_written(&own, &item.id).await? else {
4589                        continue;
4590                    };
4591                    item = written;
4592                    item.updated_at = item.updated_at.max(updated_at);
4593                    self.resolved_cache()?.insert(item.id.clone(), item.clone());
4594                }
4595                if item.kind == BoardKind::Work(ItemKind::Task) {
4596                    let task = item.task()?;
4597                    if task_matches(&task, query, &query.project)
4598                        && self.commented_since(&item, query.commented_since).await?
4599                    {
4600                        tasks.push(task);
4601                    }
4602                }
4603            }
4604            if position.offset < rows {
4605                continue;
4606            }
4607            position.offset = 0;
4608            position.connection = match next {
4609                Some(after) => SearchConnection::Continuing {
4610                    after: Cursor(after),
4611                },
4612                None => SearchConnection::Exhausted {},
4613            };
4614        }
4615        if position.connection.exhausted() {
4616            for id in position.own.clone() {
4617                if position.seen.contains(&id) {
4618                    continue;
4619                }
4620                if tasks.len() == limit {
4621                    break;
4622                }
4623                position.seen.push(id.clone());
4624                let Some(item) = self.search_written(&own, &id).await? else {
4625                    continue;
4626                };
4627                if item.kind == BoardKind::Work(ItemKind::Task) {
4628                    let task = item.task()?;
4629                    if task_matches(&task, query, &query.project)
4630                        && self.commented_since(&item, query.commented_since).await?
4631                    {
4632                        tasks.push(task);
4633                    }
4634                }
4635            }
4636        }
4637        let more = !position.connection.exhausted()
4638            || position.own.iter().any(|id| !position.seen.contains(id));
4639        Ok(Page {
4640            items: tasks,
4641            next: more.then(|| {
4642                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4643            }),
4644        })
4645    }
4646
4647    /// A resumed process has the ids but no write records; resolve only a record the
4648    /// current page needs, by its uncached node read rather than the lagging search index.
4649    async fn search_written(
4650        &self,
4651        own: &[Resolved],
4652        id: &NativeId,
4653    ) -> Result<Option<Resolved>, SourceError> {
4654        match own.iter().find(|item| item.id == *id) {
4655            Some(item) => Ok(Some(item.clone())),
4656            None => self.item_by_id(id).await,
4657        }
4658    }
4659
4660    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4661    /// without enumerating the board — or `None` for a query carrying none of the three, which
4662    /// keeps the reads it always had.
4663    ///
4664    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4665    /// because it names at most a handful of items. Text and metadata are answered by one
4666    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4667    /// further by `updated:>=` when the query also asks for comment activity, since both
4668    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4669    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4670    ///
4671    /// Completed with what this process wrote, its own record winning over the index's copy
4672    /// of the same item: see [`Self::with_own_writes`].
4673    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4674        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4675            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4676            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4677                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4678                None => qualifiers,
4679            }),
4680            (None, None) => return Ok(None),
4681        };
4682        // A question about comment activity is asked afresh every time, as it always was: it
4683        // is the one a caller polls from one source while waiting for the index, and an
4684        // answer held from the first poll would be the answer to every later one.
4685        let key = query.commented_since.is_none().then(|| asked.key());
4686        let cached = match &key {
4687            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4688            None => None,
4689        };
4690        let found = match cached {
4691            Some(found) => found,
4692            None => {
4693                let found = match &asked {
4694                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4695                    Narrowing::Search(also) => self.searched(also).await?,
4696                };
4697                if let Some(key) = key {
4698                    self.narrowed_cache()?.insert(key, found.clone());
4699                }
4700                found
4701            }
4702        };
4703        self.with_own_writes(found).map(Some)
4704    }
4705
4706    /// The candidates for a project or unscoped document query carrying a searchable text,
4707    /// read without enumerating the board — or `None` for a query with no text or a blank one,
4708    /// which keeps the read it always had.
4709    ///
4710    /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4711    /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4712    /// costs is the issues that match and never the board. Its answer is held for the command
4713    /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4714    /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4715    /// substring rule, exactly as an item of the wider read was, and is completed with what this
4716    /// process wrote: see [`Self::with_own_writes`].
4717    async fn text_searched(
4718        &self,
4719        text: Option<&TextQuery>,
4720    ) -> Result<Option<Vec<Resolved>>, SourceError> {
4721        let Some(also) = text_qualifiers(text) else {
4722            return Ok(None);
4723        };
4724        let key = Narrowing::Search(also.clone()).key();
4725        let cached = self.narrowed_cache()?.get(&key).cloned();
4726        let found = match cached {
4727            Some(found) => found,
4728            None => {
4729                let found = self.searched(&also).await?;
4730                self.narrowed_cache()?.insert(key, found.clone());
4731                found
4732            }
4733        };
4734        self.with_own_writes(found).map(Some)
4735    }
4736
4737    /// Every item of this board that may carry `origin` — a superset of those that do — found
4738    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4739    ///
4740    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4741    /// which reads the field every carrier holds, whichever release wrote it — and the
4742    /// board-scoped issue search for the same id as a phrase in the body, where this source
4743    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4744    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4745    /// query's, exactly.
4746    ///
4747    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4748    /// already ended is sent its last cursor again, which answers an empty page, so the one
4749    /// document serves every page of either. What the two leave is stated in the module
4750    /// documentation: a carrier another process added within the last second or two, before
4751    /// either index has it.
4752    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4753        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4754        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4755        let mut items_after: Option<String> = None;
4756        let mut search_after: Option<String> = None;
4757        let mut found: Vec<Resolved> = Vec::new();
4758        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4759            if !found.iter().any(|held| held.id == resolved.id) {
4760                found.push(resolved);
4761            }
4762        };
4763        loop {
4764            let data = self
4765                .graphql(
4766                    graphql::ORIGIN_LOOKUP,
4767                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4768                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4769                           "itemsAfter":items_after,"searchAfter":search_after,
4770                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4771                           "duplicates":true}),
4772                )
4773                .await?;
4774            let items = data
4775                .pointer("/originItems/projectV2/items")
4776                .filter(|value| !value.is_null())
4777                .ok_or_else(|| SourceError::Refused {
4778                    message: format!(
4779                        "GitHub project {}/{} was not found or is not visible to the token",
4780                        self.owner, self.project_number
4781                    ),
4782                })?;
4783            for item in optional_nodes(Some(items), "project items")?
4784                .into_iter()
4785                .flatten()
4786            {
4787                // The board's own items list its drafts too, and a draft is not an issue: no
4788                // narrowed read answers with one, whatever its origin field holds.
4789                if let Some(resolved) = self.resolve(item)?
4790                    && resolved.content_kind == ContentKind::Issue
4791                {
4792                    keep(resolved, &mut found);
4793                }
4794            }
4795            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4796                message: "GitHub search response has no search connection".into(),
4797            })?;
4798            for node in optional_nodes(Some(searched), "search")?
4799                .into_iter()
4800                .flatten()
4801            {
4802                if let Some(resolved) = self.resolve_issue(node).await? {
4803                    keep(resolved, &mut found);
4804                }
4805            }
4806            let items_next = resumed(items, items_after.as_deref())?;
4807            let search_next = resumed(searched, search_after.as_deref())?;
4808            if !items_next.has_more() && !search_next.has_more() {
4809                return Ok(found);
4810            }
4811            items_after = items_next.cursor();
4812            search_after = search_next.cursor();
4813        }
4814    }
4815
4816    /// `found`, with every item this process created or wrote in its place, and every one of
4817    /// them the read did not report added.
4818    ///
4819    /// This process's own record wins over the read's copy of the same item, because a read
4820    /// of an item written moments ago can still be behind what was written onto it — the
4821    /// origin field included, which is the one a narrowed read is confirmed against — and a
4822    /// read that still names an item under a predicate this process's write moved it out of
4823    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4824    /// last saw the item change, which is what a comment-activity read rules a candidate out
4825    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4826    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4827    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4828        // A board draft is not an issue, so no narrowed read returns one, and this process
4829        // having written one does not make it an answer either.
4830        let own: Vec<Resolved> = self
4831            .created()?
4832            .iter()
4833            .chain(self.updated()?.iter())
4834            .filter(|own| own.content_kind == ContentKind::Issue)
4835            .cloned()
4836            .collect();
4837        for mut own in own {
4838            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4839            match found.iter_mut().find(|read| read.id == own.id) {
4840                Some(read) => {
4841                    own.updated_at = own.updated_at.max(read.updated_at);
4842                    *read = own;
4843                }
4844                None => found.push(own),
4845            }
4846        }
4847        Ok(found)
4848    }
4849
4850    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4851    /// there is no instant to hold it to.
4852    ///
4853    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4854    /// or after the instant moved it there: an issue not updated since holds no such comment,
4855    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4856    /// only as far as the first that matches. A board draft is not an issue and has no
4857    /// comments, so it never matches.
4858    async fn commented_since(
4859        &self,
4860        item: &Resolved,
4861        since: Option<DateTime<Utc>>,
4862    ) -> Result<bool, SourceError> {
4863        let Some(since) = since else {
4864            return Ok(true);
4865        };
4866        if item.content_kind == ContentKind::DraftIssue
4867            || item.updated_at.is_some_and(|updated| updated < since)
4868        {
4869            return Ok(false);
4870        }
4871        let query = TaskQuery {
4872            commented_since: Some(since),
4873            ..TaskQuery::default()
4874        };
4875        let mut after: Option<String> = None;
4876        loop {
4877            let data = self
4878                .graphql(
4879                    graphql::ISSUE_COMMENTS,
4880                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4881                )
4882                .await?;
4883            let Some(connection) = data
4884                .get("node")
4885                .filter(|value| !value.is_null())
4886                .and_then(|node| node.get("comments"))
4887                .filter(|value| !value.is_null())
4888            else {
4889                // Removed since the search reported it: no longer an issue with comments.
4890                return Ok(false);
4891            };
4892            let comments = optional_nodes(Some(connection), "issue comments")?
4893                .into_iter()
4894                .flatten()
4895                .map(comment_from)
4896                .collect::<Result<Vec<_>, _>>()?;
4897            if query.comments_match(&comments) {
4898                return Ok(true);
4899            }
4900            match next_cursor(connection)? {
4901                Some(next) => {
4902                    validate_cursor_progress(after.as_deref(), &next.0)?;
4903                    after = Some(next.0);
4904                }
4905                None => return Ok(false),
4906            }
4907        }
4908    }
4909
4910    /// Every item on the board: the union of both enumerations GitHub offers of one.
4911    ///
4912    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4913    /// board **draft** and reads the board's own fields beside its items, and only the search
4914    /// reports an item that connection is behind on. The module documentation is where the lag and the
4915    /// measurements behind it are written down.
4916    ///
4917    /// A search result is admitted on the same terms as any other issue this source reaches
4918    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4919    /// names *this* board — so an issue the index still believes is here after it was taken
4920    /// off is refused rather than reported.
4921    ///
4922    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4923    /// which is what the cache could otherwise have broken.
4924    async fn board(&self) -> Result<Board, SourceError> {
4925        let cached = self.board_cache()?.clone();
4926        let mut board = match cached {
4927            Some(board) => board,
4928            None => {
4929                let read = self.read_board().await?;
4930                *self.board_cache()? = Some(read.clone());
4931                read
4932            }
4933        };
4934        for held in self.searched_issues().await? {
4935            if !board.items.iter().any(|item| item.id == held.id) {
4936                board.items.push(held);
4937            }
4938        }
4939        for own in self.created()?.iter() {
4940            if !board.items.iter().any(|item| item.id == own.id) {
4941                board.items.push(own.clone());
4942            }
4943        }
4944        Ok(board)
4945    }
4946
4947    /// This process's own view of the board, or the refusal a poisoned lock is.
4948    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4949        self.board_cache
4950            .lock()
4951            .map_err(|_| SourceError::Unavailable {
4952                message: "this source's view of the board was left inconsistent by an earlier \
4953                      failure; next: run the command again"
4954                    .into(),
4955            })
4956    }
4957
4958    /// Bring this process's own view of the board up to an item it has just written.
4959    ///
4960    /// A created item goes to `created`, which is what completes a board read GitHub's own
4961    /// eventual consistency has left behind. An item that was already there is replaced
4962    /// where it sits, so a second write of it in the same command reads its real parent
4963    /// rather than the one it had before the first write.
4964    ///
4965    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4966    /// that wins: an item this same run created is held in `created` and not in the cached
4967    /// board, and `board` completes the cached board *from* `created`, so replacing only
4968    /// the cached copy of such an item replaces nothing and the read still reports the
4969    /// title it was created with. The search is the third, and it is the one an item the
4970    /// board's own projection is behind on sits in *alone* — which is exactly the item this
4971    /// source is least able to re-read, so leaving it out would put the stale title back on
4972    /// the only items the completion in [`Self::board`] exists for.
4973    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4974        self.resolved_cache()?.insert(item.id.clone(), item.clone());
4975        if created {
4976            self.created()?.push(item);
4977            return Ok(());
4978        }
4979        {
4980            let mut own = self.created()?;
4981            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4982                *held = item;
4983                return Ok(());
4984            }
4985        }
4986        {
4987            let mut own = self.updated()?;
4988            match own.iter_mut().find(|held| held.id == item.id) {
4989                Some(held) => *held = item.clone(),
4990                None => own.push(item.clone()),
4991            }
4992        }
4993        if let Some(board) = self.board_cache()?.as_mut()
4994            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4995        {
4996            *held = item.clone();
4997        }
4998        if let Some(found) = self.search_cache()?.as_mut()
4999            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5000        {
5001            *held = item.clone();
5002        }
5003        for found in self.narrowed_cache()?.values_mut() {
5004            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5005                *held = item.clone();
5006            }
5007        }
5008        Ok(())
5009    }
5010
5011    /// Forget one item this process has just deleted, from every half of its own view.
5012    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5013        self.resolved_cache()?.remove(id);
5014        self.created()?.retain(|own| own.id != *id);
5015        self.updated()?.retain(|own| own.id != *id);
5016        if let Some(board) = self.board_cache()?.as_mut() {
5017            board.items.retain(|item| item.id != *id);
5018        }
5019        if let Some(found) = self.search_cache()?.as_mut() {
5020            found.retain(|item| item.id != *id);
5021        }
5022        for found in self.narrowed_cache()?.values_mut() {
5023            found.retain(|item| item.id != *id);
5024        }
5025        Ok(())
5026    }
5027
5028    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5029    fn narrowed_cache(
5030        &self,
5031    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5032        self.narrowed_cache
5033            .lock()
5034            .map_err(|_| SourceError::Unavailable {
5035                message: "this source's view of a narrowed read was left inconsistent by an \
5036                      earlier failure; next: run the command again"
5037                    .into(),
5038            })
5039    }
5040
5041    /// Every page of the board, read from GitHub.
5042    async fn read_board(&self) -> Result<Board, SourceError> {
5043        let mut after: Option<String> = None;
5044        let mut items = Vec::new();
5045        let mut board;
5046        loop {
5047            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5048            for item in page
5049                .pointer("/items/nodes")
5050                .and_then(Value::as_array)
5051                .ok_or_else(|| SourceError::Malformed {
5052                    message: "GitHub project items.nodes is not an array".into(),
5053                })?
5054            {
5055                if let Some(resolved) = self.resolve(item)? {
5056                    items.push(resolved);
5057                }
5058            }
5059            let info = page
5060                .pointer("/items/pageInfo")
5061                .ok_or_else(|| SourceError::Malformed {
5062                    message: "GitHub project items have no pageInfo".into(),
5063                })?;
5064            let has_next = required_bool(info, "hasNextPage")?;
5065            let next = has_next
5066                .then(|| required_str(info, "endCursor"))
5067                .transpose()?;
5068            board = page.clone();
5069            match next {
5070                Some(next) => {
5071                    validate_cursor_progress(after.as_deref(), next)?;
5072                    after = Some(next.to_owned());
5073                }
5074                None => break,
5075            }
5076        }
5077        Ok(Board {
5078            id: required_str(&board, "id")?.to_owned(),
5079            fields: board.get("fields").cloned().unwrap_or(Value::Null),
5080            items,
5081        })
5082    }
5083
5084    /// The existing items this source has written, for completing a narrowed read that is
5085    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5086    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5087        self.updated.lock().map_err(|_| SourceError::Unavailable {
5088            message: "this source's record of what it wrote in this run was left inconsistent \
5089                      by an earlier failure; next: run the command again"
5090                .into(),
5091        })
5092    }
5093
5094    /// The items this source has created, for completing a board read that is behind.
5095    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5096        self.created.lock().map_err(|_| SourceError::Unavailable {
5097            message: "this source's record of what it created in this run was left \
5098                      inconsistent by an earlier failure; next: run the command again"
5099                .into(),
5100        })
5101    }
5102
5103    /// One board item as this source reports it, or `None` for content it ignores.
5104    ///
5105    /// A pull request is neither a project nor a task — it is somebody's change, not a
5106    /// unit of plan — and an item whose content the token cannot see has nothing to
5107    /// report at all.
5108    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5109        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5110            message: "GitHub project item is missing content".into(),
5111        })?;
5112        if content.is_null() {
5113            return Ok(None);
5114        }
5115        let content_kind = match required_str(content, "__typename")? {
5116            "Issue" => ContentKind::Issue,
5117            "DraftIssue" => ContentKind::DraftIssue,
5118            _ => return Ok(None),
5119        };
5120        let field_values = item
5121            .get("fieldValues")
5122            .ok_or_else(|| SourceError::Malformed {
5123                message: "GitHub project item is missing fieldValues".into(),
5124            })?;
5125        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5126        let nodes = field_values
5127            .get("nodes")
5128            .and_then(Value::as_array)
5129            .ok_or_else(|| SourceError::Malformed {
5130                message: "GitHub project item fieldValues.nodes is not an array".into(),
5131            })?;
5132        if let Some(labels) = content.get("labels") {
5133            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5134        }
5135        let raw_body = optional_str(content, "body")?.map(str::to_owned);
5136        let (body, slot) = metadata_body(raw_body.clone())?;
5137        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5138            .map(|id| NativeId(id.to_owned()));
5139        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5140        // to read one from; it is a task, and never a project.
5141        let sub_issues = match content_kind {
5142            ContentKind::Issue => sub_issue_total(content)?,
5143            ContentKind::DraftIssue => 0,
5144        };
5145        let content_id = required_str(content, "id")?;
5146        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5147            message: format!("GitHub issue {content_id}: {message}"),
5148        })?;
5149        let raw_title = required_str(content, "title")?;
5150        // The design prefix is read *first*, before either of the two rules that separate
5151        // a project from a task. A document is not work whatever sub-issues it has and
5152        // whatever marker it carries, and reading the prefix later would make a design
5153        // issue with none of either an empty project.
5154        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5155            BoardKind::Document
5156        } else if parent.is_some() {
5157            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5158            // under a project is that project's task even when it has sub-issues of its
5159            // own.
5160            BoardKind::Work(ItemKind::Task)
5161        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5162            BoardKind::Work(ItemKind::Project)
5163        } else {
5164            BoardKind::Work(ItemKind::Task)
5165        };
5166        // The title a person wrote, which for a document is the one without the prefix —
5167        // the same way `content` above is the body without this source's metadata slot.
5168        let title = match kind {
5169            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5170            BoardKind::Work(_) => raw_title.to_owned(),
5171        };
5172        let own_repository = content
5173            .pointer("/repository/nameWithOwner")
5174            .and_then(Value::as_str)
5175            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5176            .transpose()
5177            .map_err(|message| SourceError::Malformed { message })?;
5178        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5179            Repository::from_metadata(&slot)
5180                .map_err(|message| SourceError::Malformed { message })?
5181        } else {
5182            own_repository.clone().into_iter().collect()
5183        };
5184        let id = NativeId(content_id.to_owned());
5185        // Read only for a task, because only a task has either list: a project or a
5186        // document holding one of these keys holds nothing this source reports, and the
5187        // keys are left out of its caller-visible metadata all the same.
5188        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5189            let listed = |key: &str| {
5190                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5191                    .map_err(|message| SourceError::Malformed { message })
5192            };
5193            (
5194                listed(TaskRef::DELIVERS_KEY)?,
5195                listed(TaskRef::DELIVERED_BY_KEY)?,
5196            )
5197        } else {
5198            (Vec::new(), Vec::new())
5199        };
5200        let (option, closed, reason) = Self::status_parts(nodes, content)?;
5201        let priority = self.held_priority(nodes)?;
5202        // Present when the item was reached through its own issue, whose board entry
5203        // names the board; a read of the board's own items has the board already. An
5204        // empty id names nothing a field write could address, so it is read as absent and
5205        // the write goes back to reading the board.
5206        let board_id = item
5207            .pointer("/project/id")
5208            .and_then(Value::as_str)
5209            .filter(|id| !id.is_empty());
5210        let resolved = Resolved {
5211            item_id: required_str(item, "id")?.to_owned(),
5212            id,
5213            content_kind,
5214            kind,
5215            title,
5216            body: body.filter(|value| !value.is_empty()),
5217            raw_body,
5218            status: self.statuses.status(option, closed, reason),
5219            option: option.map(str::to_owned),
5220            priority,
5221            closed,
5222            delivers,
5223            delivered_by,
5224            labels: labels(content)?,
5225            parent,
5226            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5227            number: match content_kind {
5228                ContentKind::Issue => Some(issue_number(content)?),
5229                // A draft is filed in no repository, so nothing ever numbered it:
5230                // `DraftIssue` declares no `number` at all, exactly as it declares no
5231                // `subIssuesSummary` the branch above reads.
5232                ContentKind::DraftIssue => None,
5233            },
5234            url: optional_str(content, "url")?.map(str::to_owned),
5235            created_at: optional_time(content, "createdAt")?,
5236            updated_at: optional_time(content, "updatedAt")?,
5237            own_repository,
5238            repositories,
5239            slot,
5240            board_id: board_id.map(str::to_owned),
5241            fields: field_definitions(nodes),
5242            board_fields: Self::carried_board_fields(content, board_id)?,
5243            blocked_by: carried_blocked_by(content)?,
5244        };
5245        self.resolved_cache()?
5246            .insert(resolved.id.clone(), resolved.clone());
5247        Ok(Some(resolved))
5248    }
5249
5250    /// The field definitions of the board `board_id` names — the project this issue's own
5251    /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5252    /// `None` when the read carried none, carried no entry for that board, or the board item
5253    /// named no board, which a write then answers by reading the board's fields itself.
5254    ///
5255    /// Matched by the board's node id and never by its number alone: a project number is
5256    /// unique only within its owner, so another owner's board numbered alike can sit on the
5257    /// same page, and its field and option ids address nothing on this one.
5258    fn carried_board_fields(
5259        content: &Value,
5260        board_id: Option<&str>,
5261    ) -> Result<Option<Value>, SourceError> {
5262        let (Some(nodes), Some(board_id)) = (
5263            content.pointer("/boards/nodes").and_then(Value::as_array),
5264            board_id,
5265        ) else {
5266            return Ok(None);
5267        };
5268        let Some(board) = nodes.iter().find_map(|node| {
5269            let project = node.get("project")?;
5270            (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5271        }) else {
5272            return Ok(None);
5273        };
5274        let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5275            return Ok(None);
5276        };
5277        complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5278        Ok(Some(fields.clone()))
5279    }
5280
5281    /// What one board item's `Priority` field says, through this instance's mapping.
5282    ///
5283    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5284    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5285    /// option the mapping does not name is kept as itself — never read as a level or as
5286    /// `none` — for a read of the task to report by name.
5287    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5288        let Some(mapping) = &self.priorities else {
5289            return Ok(HeldPriority::Read(Priority::None));
5290        };
5291        // A value of the field that names no option — a text field someone called `Priority` —
5292        // is malformed rather than `none`: reading it as no priority would let the next copy
5293        // clear one a person set.
5294        let Some(option) = field_values
5295            .iter()
5296            .find(|value| {
5297                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5298            })
5299            .map(|value| required_str(value, "name"))
5300            .transpose()?
5301        else {
5302            return Ok(HeldPriority::Read(Priority::None));
5303        };
5304        Ok(mapping.priority_of(option).map_or_else(
5305            || HeldPriority::Unmapped(option.to_owned()),
5306            HeldPriority::Read,
5307        ))
5308    }
5309
5310    /// What one board item's status is read from: its `Status` option, whether its issue
5311    /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
5312    /// three into the status it reports.
5313    fn status_parts<'a>(
5314        field_values: &'a [Value],
5315        content: &'a Value,
5316    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5317        let option = field_values
5318            .iter()
5319            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5320            .map(|value| required_str(value, "name"))
5321            .transpose()?;
5322        let closed = optional_str(content, "state")? == Some("CLOSED");
5323        Ok((option, closed, optional_str(content, "stateReason")?))
5324    }
5325
5326    /// The board Status option this write selects, or the refusal that says why not.
5327    ///
5328    /// The mapped option is required for both open and terminal targets. A terminal write
5329    /// validates it before changing either representation, so it can never fall back to
5330    /// closing an issue whose board cannot display the matching status.
5331    ///
5332    /// Answers the field's id, the option's id, and the option's name as the board spells
5333    /// it — which is the name a read of the item reports once it sits there.
5334    fn column_for(
5335        &self,
5336        fields: &Value,
5337        status: &Status,
5338        target: &StatusTarget,
5339    ) -> Result<Option<(String, String, String)>, SourceError> {
5340        let wanted = match target {
5341            StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
5342            StatusTarget::Disabled => return Ok(None),
5343        };
5344        let missing = |detail: &str| SourceError::Refused {
5345            message: format!(
5346                "status {} of source {} needs the board Status option {wanted:?}, and {detail};                  add that option to the board, or point status_mapping.{} of this source at one                  it has",
5347                category_name(status.category),
5348                self.name,
5349                category_name(status.category)
5350            ),
5351        };
5352        let Some(field) = Board::field(fields, "Status")? else {
5353            return Err(missing("this board has no Status field"));
5354        };
5355        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5356            return Err(missing(
5357                "this board's Status field is not a single-select field",
5358            ));
5359        }
5360        let option = field
5361            .get("options")
5362            .and_then(Value::as_array)
5363            .and_then(|options| {
5364                options.iter().find(|option| {
5365                    option
5366                        .get("name")
5367                        .and_then(Value::as_str)
5368                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5369                })
5370            });
5371        match option {
5372            None => Err(missing("this board does not have it")),
5373            Some(option) => Ok(Some((
5374                required_str(field, "id")?.to_owned(),
5375                required_str(option, "id")?.to_owned(),
5376                required_str(option, "name")?.to_owned(),
5377            ))),
5378        }
5379    }
5380
5381    /// The refusal a status that closes an issue is answered with over a board draft.
5382    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5383        SourceError::Refused {
5384            message: format!(
5385                "status {} of source {} closes the item's issue, and GitHub draft items have \
5386                 no open or closed state",
5387                category_name(category),
5388                self.name
5389            ),
5390        }
5391    }
5392
5393    /// What a status write to one item needs of the board: the board's id and the
5394    /// definition of its `Status` field, read off the item when the item says both.
5395    ///
5396    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5397    /// and its `Status` value carries that field's definition, options and all. An item that
5398    /// does not say — no board id, or no `Status` value to read the field off — takes them
5399    /// from [`Self::board_fields`], which reads no item.
5400    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5401        if let Some(board) = item.carried_board() {
5402            return Ok(board);
5403        }
5404        if item.defines("Status")
5405            && let Some(board_id) = item.named_board()
5406        {
5407            return Ok(BoardFields {
5408                id: board_id,
5409                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5410            });
5411        }
5412        self.board_fields().await
5413    }
5414
5415    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5416    async fn set_status(
5417        &self,
5418        id: &NativeId,
5419        category: StatusCategory,
5420    ) -> Result<Option<Status>, SourceError> {
5421        // Refused before anything is read, in the words a write of the same status is.
5422        let target = self.resolved_target(category)?;
5423        let Some(mut item) = self
5424            .bound_item(id)
5425            .await?
5426            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5427        else {
5428            return Ok(None);
5429        };
5430        let board = self.status_board(&item).await?;
5431        let wanted = Status {
5432            category,
5433            name: category_name(category).to_owned(),
5434        };
5435        let (field, option, name) = self
5436            .column_for(&board.fields, &wanted, &target)?
5437            .ok_or_else(|| SourceError::Malformed {
5438                message: format!(
5439                    "status {} of source {} names no board Status option",
5440                    category_name(category),
5441                    self.name
5442                ),
5443            })?;
5444        if item.status.category == category && item.option.as_deref() == Some(&name) {
5445            return Ok(Some(item.status));
5446        }
5447        match &target {
5448            StatusTarget::Terminal(_, reason) => {
5449                if item.content_kind == ContentKind::DraftIssue {
5450                    return Err(self.closes_a_draft(category));
5451                }
5452                self.set_item_field(
5453                    board.id.as_str(),
5454                    &item.item_id,
5455                    &field,
5456                    json!({"singleSelectOptionId": option}),
5457                )
5458                .await?;
5459                self.update_content(
5460                    ContentKind::Issue,
5461                    &item.id,
5462                    json!({"stateInput": state_input(Some(&target))}),
5463                )
5464                .await?;
5465                item.closed = true;
5466                item.status = self
5467                    .statuses
5468                    .status(Some(&name), true, Some(reason.reason()));
5469                item.option = Some(name);
5470            }
5471            StatusTarget::Column(_) => {
5472                // An option is what an open item's status is, so a closed issue is reopened
5473                // first — sitting closed in the column, it would read back as closed. A draft has
5474                // no state to reopen.
5475                if item.content_kind == ContentKind::Issue && item.closed {
5476                    self.update_content(
5477                        ContentKind::Issue,
5478                        &item.id,
5479                        json!({"stateInput": state_input(Some(&target))}),
5480                    )
5481                    .await?;
5482                    item.closed = false;
5483                }
5484                self.set_item_field(
5485                    board.id.as_str(),
5486                    &item.item_id,
5487                    &field,
5488                    json!({"singleSelectOptionId": option}),
5489                )
5490                .await?;
5491                item.status = self.statuses.status(Some(&name), false, None);
5492                item.option = Some(name);
5493            }
5494            StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5495        }
5496        let status = item.status.clone();
5497        self.remember_written(item, false)?;
5498        Ok(Some(status))
5499    }
5500
5501    /// Replace one task's `delivered_by` and nothing else; see
5502    /// [`TaskSource::set_delivered_by`].
5503    ///
5504    /// One update of the body, which differs from the body GitHub holds only inside the
5505    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5506    async fn replace_delivered_by(
5507        &self,
5508        id: &NativeId,
5509        delivered_by: &[TaskRef],
5510    ) -> Result<Option<()>, SourceError> {
5511        let entries = TaskRef::listed(
5512            TaskRef::DELIVERED_BY_KEY,
5513            id,
5514            Some(&self.name),
5515            delivered_by.to_vec(),
5516        )
5517        .map_err(|message| SourceError::Refused { message })?;
5518        let Some(mut item) = self
5519            .bound_item(id)
5520            .await?
5521            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5522        else {
5523            return Ok(None);
5524        };
5525        let mut slot = item.slot.clone();
5526        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5527        self.write_slot(&mut item, &slot).await?;
5528        item.delivered_by = entries;
5529        self.remember_written(item, false)?;
5530        Ok(Some(()))
5531    }
5532
5533    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5534    /// see [`TaskSource::set_task_metadata`].
5535    ///
5536    /// `None` when this board holds no item by that id, or holds one of another kind. The
5537    /// answer is the item as this source now reads it, so what a caller is told the key
5538    /// holds is what the slot holds.
5539    ///
5540    /// A key already holding the value is answered without a write, compared as JSON rather
5541    /// than as the body's bytes: a slot a person spelled with other whitespace would
5542    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5543    async fn set_slot_key(
5544        &self,
5545        id: &NativeId,
5546        kind: BoardKind,
5547        key: &MetadataKey,
5548        value: &Value,
5549    ) -> Result<Option<Resolved>, SourceError> {
5550        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5551            return Ok(None);
5552        };
5553        if item.slot.get(key.as_str()) == Some(value) {
5554            return Ok(Some(item));
5555        }
5556        let mut slot = item.slot.clone();
5557        slot.insert(key.as_str().to_owned(), value.clone());
5558        self.write_slot(&mut item, &slot).await?;
5559        self.remember_written(item.clone(), false)?;
5560        Ok(Some(item))
5561    }
5562
5563    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5564    /// `item` up to what that write left.
5565    ///
5566    /// The body sent differs from the body GitHub holds only inside the slot — see
5567    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5568    /// the mutation the item's content takes, so a board draft's body is written with
5569    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5570    async fn write_slot(
5571        &self,
5572        item: &mut Resolved,
5573        slot: &BTreeMap<String, Value>,
5574    ) -> Result<(), SourceError> {
5575        let held = item.raw_body.clone().unwrap_or_default();
5576        let body = with_slot(&held, slot)?;
5577        if body != held {
5578            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5579                .await?;
5580        }
5581        let (visible, slot) = metadata_body(Some(body.clone()))?;
5582        item.body = visible.filter(|value| !value.is_empty());
5583        item.raw_body = Some(body);
5584        item.slot = slot;
5585        Ok(())
5586    }
5587
5588    /// This instance's target for a category, refusing one it has disabled.
5589    ///
5590    /// Nothing here mutates the board's option set to make room for a status. GitHub
5591    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5592    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5593    /// field and every item's status.
5594    fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5595        let target = self.statuses.target(category).clone();
5596        if target != StatusTarget::Disabled {
5597            return Ok(target);
5598        }
5599        Err(SourceError::Refused {
5600            message: if category == StatusCategory::Draft {
5601                format!(
5602                    "status draft is disabled for source {}: draft is incompatible with this \
5603                     integration because GitHub draft issues cannot have sub-issues, and this \
5604                     source stores a project's tasks as its issue's sub-issues",
5605                    self.name
5606                )
5607            } else if category == StatusCategory::Unknown {
5608                format!(
5609                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5610                     to one board Status option name; every word classified unknown is written \
5611                     to that one option",
5612                    category_name(category),
5613                    self.name,
5614                    category_name(category)
5615                )
5616            } else {
5617                format!(
5618                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5619                     to a board Status option name",
5620                    category_name(category),
5621                    self.name,
5622                    category_name(category)
5623                )
5624            },
5625        })
5626    }
5627
5628    /// What writing `priority` does to one item's `Priority` field on this board, or the
5629    /// refusal naming what the board lacks.
5630    ///
5631    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5632    /// none already, or of an item not created yet. Every other priority selects the option
5633    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5634    /// without that option, is refused rather than given one: reads and writes never create
5635    /// a field or an option.
5636    fn priority_write(
5637        &self,
5638        fields: &Value,
5639        existing: Option<&Resolved>,
5640        priority: Priority,
5641    ) -> Result<Option<PriorityWrite>, SourceError> {
5642        let Some(mapping) = &self.priorities else {
5643            return Err(self.holds_no_priority());
5644        };
5645        let Some(wanted) = mapping.option(priority) else {
5646            if !existing.is_some_and(Resolved::holds_priority) {
5647                return Ok(None);
5648            }
5649            let field =
5650                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5651                    message: format!(
5652                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5653                    ),
5654                })?;
5655            return Ok(Some(PriorityWrite::Clear {
5656                field: required_str(field, "id")?.to_owned(),
5657            }));
5658        };
5659        let missing = |detail: &str| SourceError::Refused {
5660            message: format!(
5661                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5662                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5663                 it, or point priority_mapping.{priority} of this source at an option the board \
5664                 has",
5665                self.name, self.name
5666            ),
5667        };
5668        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5669            return Err(missing(&format!(
5670                "this board has no {PRIORITY_FIELD} field"
5671            )));
5672        };
5673        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5674            return Err(missing(&format!(
5675                "this board's {PRIORITY_FIELD} field is not a single-select field"
5676            )));
5677        }
5678        // An options list that is absent or not a list is an answer this source cannot read,
5679        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5680        let option = field
5681            .get("options")
5682            .and_then(Value::as_array)
5683            .ok_or_else(|| SourceError::Malformed {
5684                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5685            })?
5686            .iter()
5687            .find(|option| {
5688                option
5689                    .get("name")
5690                    .and_then(Value::as_str)
5691                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5692            })
5693            .ok_or_else(|| missing("this board does not have it"))?;
5694        Ok(Some(PriorityWrite::Select {
5695            field: required_str(field, "id")?.to_owned(),
5696            option: required_str(option, "id")?.to_owned(),
5697        }))
5698    }
5699
5700    /// Apply one priority write to one board item.
5701    async fn write_priority(
5702        &self,
5703        board_id: &str,
5704        item_id: &str,
5705        write: &PriorityWrite,
5706    ) -> Result<(), SourceError> {
5707        match write {
5708            PriorityWrite::Select { field, option } => {
5709                self.set_item_field(
5710                    board_id,
5711                    item_id,
5712                    field,
5713                    json!({"singleSelectOptionId": option}),
5714                )
5715                .await
5716            }
5717            PriorityWrite::Clear { field } => {
5718                let data = self
5719                    .graphql(
5720                        graphql::CLEAR_FIELD,
5721                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5722                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5723                    )
5724                    .await?;
5725                let returned = data
5726                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5727                    .ok_or_else(|| SourceError::Malformed {
5728                        message: "GitHub field clear returned no project item".into(),
5729                    })?;
5730                if required_str(returned, "id")? != item_id {
5731                    return Err(SourceError::Malformed {
5732                        message: "GitHub field clear returned the wrong project item".into(),
5733                    });
5734                }
5735                Ok(())
5736            }
5737        }
5738    }
5739
5740    /// The refusal a priority is answered with by an instance configured with no
5741    /// `priority_mapping`, which holds none.
5742    fn holds_no_priority(&self) -> SourceError {
5743        SourceError::Refused {
5744            message: format!(
5745                "source {} holds no task priority: its configuration sets no priority_mapping; \
5746                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5747                 fields {} --apply` to set its board up",
5748                self.name, self.name
5749            ),
5750        }
5751    }
5752
5753    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5754    ///
5755    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5756    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5757    async fn set_priority(
5758        &self,
5759        id: &NativeId,
5760        priority: Priority,
5761    ) -> Result<Option<Priority>, SourceError> {
5762        if self.priorities.is_none() {
5763            return Err(self.holds_no_priority());
5764        }
5765        let Some(mut item) = self
5766            .bound_item(id)
5767            .await?
5768            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5769        else {
5770            return Ok(None);
5771        };
5772        if priority == Priority::None && !item.holds_priority() {
5773            return Ok(Some(priority));
5774        }
5775        // The item's own read carries the field's definition whenever it holds a value of
5776        // it, which a clear always does; a select onto an item holding none reads the board.
5777        let board = match (item.carried_board(), item.named_board()) {
5778            (Some(board), _) => board,
5779            (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5780                id,
5781                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5782            },
5783            _ => self.board_fields().await?,
5784        };
5785        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5786            return Ok(Some(priority));
5787        };
5788        let (document, root, input) = match write {
5789            PriorityWrite::Select { field, option } => (
5790                graphql::UPDATE_FIELD,
5791                "updateProjectV2ItemFieldValue",
5792                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5793            ),
5794            PriorityWrite::Clear { field } => (
5795                graphql::CLEAR_FIELD,
5796                "clearProjectV2ItemFieldValue",
5797                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5798            ),
5799        };
5800        let data = self
5801            .graphql(
5802                document,
5803                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5804            )
5805            .await?;
5806        let returned = data
5807            .get(root)
5808            .and_then(|value| value.get("projectV2Item"))
5809            .ok_or_else(|| SourceError::Malformed {
5810                message: "GitHub priority write returned no project item".into(),
5811            })?;
5812        if required_str(returned, "id")? != item.item_id {
5813            return Err(SourceError::Malformed {
5814                message: "GitHub priority write returned the wrong project item".into(),
5815            });
5816        }
5817        let value = returned
5818            .get("fieldValueByName")
5819            .ok_or_else(|| SourceError::Malformed {
5820                message: "GitHub priority write returned no priority read-back".into(),
5821            })?;
5822        if !value.is_null()
5823            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5824        {
5825            return Err(SourceError::Malformed {
5826                message: "GitHub priority read-back is not a Priority field value".into(),
5827            });
5828        }
5829        let values = if value.is_null() {
5830            Vec::new()
5831        } else {
5832            vec![value.clone()]
5833        };
5834        item.priority = self.held_priority(&values)?;
5835        let answer = item.task()?.priority;
5836        self.remember_written(item, false)?;
5837        Ok(Some(answer))
5838    }
5839
5840    /// Replace one task's visible body and nothing else; see
5841    /// [`TaskSource::set_task_content`].
5842    ///
5843    /// One update of the body, which differs from the body GitHub holds only outside the
5844    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5845    /// this source keeps there reads back as it was. A body that would not change is not
5846    /// sent at all.
5847    async fn replace_content(
5848        &self,
5849        id: &NativeId,
5850        content: &str,
5851    ) -> Result<Option<()>, SourceError> {
5852        let Some(mut item) = self
5853            .bound_item(id)
5854            .await?
5855            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5856        else {
5857            return Ok(None);
5858        };
5859        let held = item.raw_body.clone().unwrap_or_default();
5860        let body = with_content(&held, content)?;
5861        // Checked before anything is sent: content ending in what this source reads as its own
5862        // metadata slot would read back as metadata rather than as the content it was.
5863        let (visible, slot) = metadata_body(Some(body.clone()))?;
5864        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5865            return Err(SourceError::Refused {
5866                message: format!(
5867                    "this content ends in what source {} reads as its own metadata slot \
5868                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5869                     as content; next: remove that trailing block from the content",
5870                    self.name
5871                ),
5872            });
5873        }
5874        if body != held {
5875            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5876                .await?;
5877        }
5878        item.body = visible.filter(|value| !value.is_empty());
5879        item.raw_body = Some(body);
5880        item.slot = slot;
5881        self.remember_written(item, false)?;
5882        Ok(Some(()))
5883    }
5884
5885    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5886    ///
5887    /// One read of the item — which carries the board's field definitions and the issue's
5888    /// `blockedBy`, so neither is read again — and then only what differs from it: the
5889    /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5890    /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5891    /// the title, the body — visible content and metadata slot together — and a state change.
5892    /// So an update naming any of title, body, metadata, status and priority is one read and
5893    /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5894    /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5895    /// as a whole write does; an open one selects its option and then reopens. The origin
5896    /// field is never written: an update is of an item that already exists, whose origin is
5897    /// what it is.
5898    ///
5899    /// The task answered is the item as those writes left it, built from the read and what was
5900    /// sent rather than read again — the same record a later read in this run answers from.
5901    async fn targeted_update(
5902        &self,
5903        id: &NativeId,
5904        update: &TaskUpdate,
5905    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5906        // Everything this source can refuse without reading the item is refused first, in the
5907        // words a whole write of the same fields is refused with.
5908        update.consistent()?;
5909        if update
5910            .title
5911            .as_deref()
5912            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5913        {
5914            return Err(SourceError::Refused {
5915                message: format!(
5916                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5917                     spells a document, so it would read back as one rather than as a task; \
5918                     retitle it",
5919                    self.name
5920                ),
5921            });
5922        }
5923        if let Some(delivers) = &update.delivers {
5924            TaskRef::listed(
5925                TaskRef::DELIVERS_KEY,
5926                id,
5927                Some(&self.name),
5928                delivers.clone(),
5929            )
5930            .map_err(|message| SourceError::Refused { message })?;
5931        }
5932        if self.priorities.is_none()
5933            && update
5934                .priority
5935                .is_some_and(|priority| priority != Priority::None)
5936        {
5937            return Err(self.holds_no_priority());
5938        }
5939        let target = update
5940            .status
5941            .as_ref()
5942            .map(|status| self.resolved_target(status.category))
5943            .transpose()?;
5944        let Some(mut item) = self
5945            .bound_item(id)
5946            .await?
5947            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5948        else {
5949            return Ok(None);
5950        };
5951        let before = item.task()?;
5952
5953        let mut status_move = None;
5954        if let (Some(status), Some(target)) = (&update.status, target) {
5955            let board = self.status_board(&item).await?;
5956            let (field, option, name) = self
5957                .column_for(&board.fields, status, &target)?
5958                .ok_or_else(|| SourceError::Malformed {
5959                    message: format!(
5960                        "status {} of source {} names no board Status option",
5961                        category_name(status.category),
5962                        self.name
5963                    ),
5964                })?;
5965            let terminal = matches!(target, StatusTarget::Terminal(_, _));
5966            if terminal && item.content_kind == ContentKind::DraftIssue {
5967                return Err(self.closes_a_draft(status.category));
5968            }
5969            let landed = match &target {
5970                StatusTarget::Terminal(_, reason) => {
5971                    self.statuses
5972                        .status(Some(&name), true, Some(reason.reason()))
5973                }
5974                _ => self.statuses.status(Some(&name), false, None),
5975            };
5976            let option_moves = item
5977                .option
5978                .as_deref()
5979                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5980            let state_moves = item.content_kind == ContentKind::Issue
5981                && (item.closed != terminal || (terminal && item.status != landed));
5982            if let Some(moves) = Moves::of(option_moves, state_moves) {
5983                status_move = Some(StatusMove {
5984                    board: board.id,
5985                    field,
5986                    option,
5987                    name,
5988                    target,
5989                    landed,
5990                    moves,
5991                });
5992            }
5993        }
5994
5995        let mut priority_move = None;
5996        if let Some(priority) = update.priority
5997            && self.priorities.is_some()
5998            && item.priority != HeldPriority::Read(priority)
5999        {
6000            let board = match (item.carried_board(), item.named_board()) {
6001                (Some(board), _) => board,
6002                (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6003                    id: board,
6004                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6005                },
6006                _ => self.board_fields().await?,
6007            };
6008            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6009                priority_move = Some((board.id, write, priority));
6010            }
6011        }
6012
6013        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6014        // recorded in the slot, and the slot travels in the one body update below.
6015        let edges = match &update.depends_on {
6016            Some(edges) => Some(
6017                self.partition_edges(
6018                    BoardKind::Work(ItemKind::Task),
6019                    item.content_kind,
6020                    item.blocked_by.as_deref(),
6021                    edges,
6022                )
6023                .await?,
6024            ),
6025            None => None,
6026        };
6027
6028        let mut slot = item.slot.clone();
6029        for (key, value) in &update.metadata_set {
6030            slot.insert(key.as_str().to_owned(), value.clone());
6031        }
6032        for key in &update.metadata_remove {
6033            slot.remove(key.as_str());
6034        }
6035        if let Some(delivers) = &update.delivers {
6036            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6037        }
6038        if let Some((_, recorded)) = &edges {
6039            record_edges(&mut slot, recorded);
6040        }
6041        let held = item.raw_body.clone().unwrap_or_default();
6042        let content = match &update.content {
6043            Some(content) => with_content(&held, content)?,
6044            None => held.clone(),
6045        };
6046        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6047        // the body's bytes, as a metadata write compares it: a slot a person spelled with
6048        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6049        let body = if slot == item.slot {
6050            content
6051        } else {
6052            with_slot(&content, &slot)?
6053        };
6054        // Checked before anything is sent, as a content write checks it: content ending in
6055        // what this source reads as its own slot would read back as metadata.
6056        let (visible, read) = metadata_body(Some(body.clone()))?;
6057        let wanted = update.content.as_deref().or(item.body.as_deref());
6058        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6059            return Err(SourceError::Refused {
6060                message: format!(
6061                    "this content ends in what source {} reads as its own metadata slot \
6062                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6063                     as content; next: remove that trailing block from the content",
6064                    self.name
6065                ),
6066            });
6067        }
6068        let recorded_moves =
6069            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6070
6071        // One `updateIssue` carries all three, because every mutation spends the secondary
6072        // limiter and the title, body and state are one mutation's inputs.
6073        let mut fields = serde_json::Map::new();
6074        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6075            fields.insert("title".to_owned(), json!(title));
6076        }
6077        if body != held {
6078            fields.insert("body".to_owned(), json!(body));
6079        }
6080        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6081            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6082        }
6083        // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6084        // GitHub runs no two requests as one, and runs one document's mutation fields in order
6085        // without undoing an earlier field when a later one fails — so a body written before a
6086        // board field the board then refused would be left changed. Written after every other
6087        // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6088        // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6089        // first, together in one request — a terminal option selected before the issue
6090        // closes, as a whole write does — then the `blockedBy` difference, then the body.
6091        let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6092        let mut clear: Option<(&BoardId, &str)> = None;
6093        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6094            board_writes.push((
6095                &moving.board,
6096                (
6097                    moving.field.clone(),
6098                    json!({"singleSelectOptionId": moving.option}),
6099                ),
6100            ));
6101        }
6102        match &priority_move {
6103            Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6104                board,
6105                (field.clone(), json!({"singleSelectOptionId": option})),
6106            )),
6107            Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6108            None => {}
6109        }
6110        let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6111        boards.extend(clear.map(|(board, _)| board));
6112        boards.dedup_by(|one, other| one.as_str() == other.as_str());
6113        for board in boards {
6114            let writes = board_writes
6115                .iter()
6116                .filter(|(on, _)| on.as_str() == board.as_str())
6117                .map(|(_, write)| write.clone())
6118                .collect::<Vec<_>>();
6119            let cleared = clear
6120                .filter(|(on, _)| on.as_str() == board.as_str())
6121                .map(|(_, field)| field);
6122            self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6123                .await?;
6124        }
6125        let mut blocked_by_moved = false;
6126        if let Some((native, _)) = &edges
6127            && item.content_kind == ContentKind::Issue
6128        {
6129            blocked_by_moved = self
6130                .reconcile_blocked_by(
6131                    &item.id,
6132                    native,
6133                    Issue::Existing(item.blocked_by.as_deref()),
6134                )
6135                .await?;
6136        }
6137        if !fields.is_empty() {
6138            self.update_content(item.content_kind, &item.id, Value::Object(fields))
6139                .await?;
6140        }
6141
6142        if let Some(title) = &update.title {
6143            item.title.clone_from(title);
6144        }
6145        item.body = visible.filter(|value| !value.is_empty());
6146        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6147        item.slot = slot;
6148        if let Some(delivers) = &update.delivers {
6149            item.delivers.clone_from(delivers);
6150        }
6151        if let Some(moving) = status_move {
6152            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6153                && item.content_kind == ContentKind::Issue;
6154            item.status = moving.landed;
6155            item.option = Some(moving.name);
6156        }
6157        if let Some((_, _, priority)) = priority_move {
6158            item.priority = HeldPriority::Read(priority);
6159        }
6160        let task = item.task()?;
6161        let mut written = update.changed(&before, &task);
6162        if blocked_by_moved || recorded_moves {
6163            written.insert(UpdatedField::DependsOn);
6164        }
6165        self.remember_written(item, false)?;
6166        Ok(Some(TaskUpdateOutcome {
6167            task,
6168            written,
6169            delivers_before: before.delivers,
6170        }))
6171    }
6172
6173    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6174    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6175    ///
6176    /// One update of the body: the content outside the slot, and inside it that one entry,
6177    /// every other entry kept as it was. This source keeps no template answers — an issue has
6178    /// no room beside itself that is not its body, and answers written there would duplicate
6179    /// what the content already says and count against GitHub's body limit — so `answers`
6180    /// reaches nothing here. A body that would not change is not sent at all.
6181    async fn replace_rendering(
6182        &self,
6183        id: &NativeId,
6184        kind: BoardKind,
6185        content: &str,
6186        provenance: &Value,
6187    ) -> Result<Option<()>, SourceError> {
6188        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6189            return Ok(None);
6190        };
6191        let held = item.raw_body.clone().unwrap_or_default();
6192        let mut slot = item.slot.clone();
6193        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6194        let body = with_slot(&with_content(&held, content)?, &slot)?;
6195        // Checked before anything is sent, as a content write checks it.
6196        let (visible, read) = metadata_body(Some(body.clone()))?;
6197        if visible.as_deref().unwrap_or_default() != content || read != slot {
6198            return Err(SourceError::Refused {
6199                message: format!(
6200                    "this content ends in what source {} reads as its own metadata slot \
6201                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6202                     as content; next: remove that trailing block from the template",
6203                    self.name
6204                ),
6205            });
6206        }
6207        if body != held {
6208            self.update_content(item.content_kind, &item.id, json!({"body": body}))
6209                .await?;
6210        }
6211        item.body = visible.filter(|value| !value.is_empty());
6212        item.raw_body = Some(body);
6213        item.slot = read;
6214        self.remember_written(item, false)?;
6215        Ok(Some(()))
6216    }
6217
6218    async fn set_item_field(
6219        &self,
6220        board_id: &str,
6221        item_id: &str,
6222        field_id: &str,
6223        value: Value,
6224    ) -> Result<(), SourceError> {
6225        let data = self
6226            .graphql(
6227                graphql::UPDATE_FIELD,
6228                json!({"input":{
6229                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6230                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6231            )
6232            .await?;
6233        let returned = data
6234            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6235            .ok_or_else(|| SourceError::Malformed {
6236                message: "GitHub field update returned no project item".into(),
6237            })?;
6238        if required_str(returned, "id")? != item_id {
6239            return Err(SourceError::Malformed {
6240                message: "GitHub field update returned the wrong project item".into(),
6241            });
6242        }
6243        Ok(())
6244    }
6245
6246    /// GitHub accepts one value per field mutation; aliases combine those mutations in
6247    /// one request. Every returned item id is checked, including optional aliases.
6248    async fn set_item_fields(
6249        &self,
6250        board: &str,
6251        item: &str,
6252        fields: &[(String, Value)],
6253        clear: Option<&str>,
6254    ) -> Result<(), SourceError> {
6255        if fields.len() <= 1 && clear.is_none() {
6256            if let Some((field, value)) = fields.first() {
6257                self.set_item_field(board, item, field, value.clone())
6258                    .await?;
6259            }
6260            return Ok(());
6261        }
6262        if fields.is_empty() {
6263            if let Some(field) = clear {
6264                self.write_priority(
6265                    board,
6266                    item,
6267                    &PriorityWrite::Clear {
6268                        field: field.to_owned(),
6269                    },
6270                )
6271                .await?;
6272            }
6273            return Ok(());
6274        }
6275        let input = |index: usize| {
6276            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6277            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6278        };
6279        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6280            "input":input(0),"second":input(1),"third":input(2),
6281            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6282            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6283        })).await?;
6284        for alias in [
6285            Some("updateProjectV2ItemFieldValue"),
6286            (fields.len() > 1).then_some("second"),
6287            (fields.len() > 2).then_some("third"),
6288            clear.map(|_| "cleared"),
6289        ]
6290        .into_iter()
6291        .flatten()
6292        {
6293            let returned = data
6294                .get(alias)
6295                .and_then(|value| value.get("projectV2Item"))
6296                .ok_or_else(|| SourceError::Malformed {
6297                    message: format!("GitHub field update {alias} returned no project item"),
6298                })?;
6299            if required_str(returned, "id")? != item {
6300                return Err(SourceError::Malformed {
6301                    message: format!("GitHub field update {alias} returned the wrong project item"),
6302                });
6303            }
6304        }
6305        Ok(())
6306    }
6307
6308    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6309        let mut after: Option<String> = None;
6310        let mut ids = Vec::new();
6311        loop {
6312            let data = self
6313                .graphql(
6314                    graphql::ISSUE_DEPENDENCIES,
6315                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6316                )
6317                .await?;
6318            let connection =
6319                data.pointer("/node/blockedBy")
6320                    .ok_or_else(|| SourceError::Malformed {
6321                        message: "GitHub dependency response has no blockedBy connection".into(),
6322                    })?;
6323            ids.extend(
6324                connection
6325                    .get("nodes")
6326                    .and_then(Value::as_array)
6327                    .ok_or_else(|| SourceError::Malformed {
6328                        message: "GitHub dependency response nodes is not an array".into(),
6329                    })?
6330                    .iter()
6331                    .map(|value| required_str(value, "id").map(str::to_owned))
6332                    .collect::<Result<Vec<_>, _>>()?,
6333            );
6334            let next = next_cursor(connection)?;
6335            if let Some(next) = &next {
6336                validate_cursor_progress(after.as_deref(), &next.0)?;
6337            }
6338            after = next.map(|cursor| cursor.0);
6339            if after.is_none() {
6340                return Ok(ids);
6341            }
6342        }
6343    }
6344
6345    async fn dependencies(
6346        &self,
6347        id: &NativeId,
6348        near_kind: ItemKind,
6349        direction: Direction,
6350        page: &PageRequest,
6351    ) -> Result<Page<DependencyEdge>, SourceError> {
6352        validate_page(page)?;
6353        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6354        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6355        let recorded = recorded_offset(cursor, direction)?;
6356        // What this issue is blocked by, when a read of it by its own id in this command
6357        // already carried the whole connection — a copy reads the item it writes before it
6358        // reads its edges — and the page asked for is the whole of it, or the recorded tail
6359        // after it. Answered from that read, in the shape the dependency read answers in;
6360        // anything else is asked of GitHub.
6361        let carried = match direction {
6362            Direction::DependsOn => self
6363                .resolved_cache()?
6364                .get(id)
6365                .filter(|item| item.content_kind == ContentKind::Issue)
6366                .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6367            Direction::DependedOnBy => None,
6368        }
6369        .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6370        // Asked for even in the recorded phase, whose page reads nothing from the
6371        // connection: `__typename` is what says whether this item has a native
6372        // relationship at all, and that is what decides which far ends the reserved key is
6373        // allowed to hold.
6374        let data = match carried {
6375            Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6376                "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6377            None => {
6378                self.graphql(
6379                    graphql::ISSUE_DEPENDENCIES,
6380                    json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6381                           "after":if recorded.is_some() {None} else {cursor}}),
6382                )
6383                .await?
6384            }
6385        };
6386        let node =
6387            data.get("node")
6388                .filter(|v| !v.is_null())
6389                .ok_or_else(|| SourceError::Refused {
6390                    message: format!(
6391                        "GitHub item {} was not found or does not support dependencies",
6392                        id.0
6393                    ),
6394                })?;
6395        let connection_name = match direction {
6396            Direction::DependsOn => "blockedBy",
6397            Direction::DependedOnBy => "blocking",
6398        };
6399        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6400        // named natively and the reserved key may hold any far end. An issue's connections
6401        // hold issues, and this source reads them at the near item's own level.
6402        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6403        if let Some(offset) = recorded {
6404            return Ok(recorded_page(
6405                self.recorded_edges(id, near_kind, direction, natively_names, node)
6406                    .await?,
6407                offset,
6408                limit,
6409            ));
6410        }
6411        if natively_names.is_none() {
6412            return Ok(recorded_page(
6413                self.recorded_edges(id, near_kind, direction, natively_names, node)
6414                    .await?,
6415                0,
6416                limit,
6417            ));
6418        }
6419        let connection = node
6420            .get(connection_name)
6421            .ok_or_else(|| SourceError::Malformed {
6422                message: "GitHub dependency response is missing its connection".into(),
6423            })?;
6424        let nodes = connection
6425            .get("nodes")
6426            .and_then(Value::as_array)
6427            .ok_or_else(|| SourceError::Malformed {
6428                message: "GitHub dependency response nodes is not an array".into(),
6429            })?;
6430        // `from` depends on `to`, always. GitHub spells the same relationship from either
6431        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6432        // it — so the near item is `from` in one direction and `to` in the other.
6433        let items = nodes
6434            .iter()
6435            .map(|value| {
6436                let related = NativeId(required_str(value, "id")?.into());
6437                let related_kind = related_kind(value)?;
6438                let (from, to) = match direction {
6439                    Direction::DependsOn => (
6440                        DependencyEndpoint::from_native(id.clone(), near_kind),
6441                        DependencyEndpoint::from_native(related, related_kind),
6442                    ),
6443                    Direction::DependedOnBy => (
6444                        DependencyEndpoint::from_native(related, related_kind),
6445                        DependencyEndpoint::from_native(id.clone(), near_kind),
6446                    ),
6447                };
6448                Ok(DependencyEdge {
6449                    from,
6450                    to,
6451                    kind: DependencyKind::Blocks,
6452                })
6453            })
6454            .collect::<Result<Vec<_>, SourceError>>()?;
6455        let mut next = next_cursor(connection)?;
6456        if let Some(next) = &next {
6457            validate_cursor_progress(cursor, &next.0)?;
6458        }
6459        if next.is_none()
6460            && !self
6461                .recorded_edges(id, near_kind, direction, natively_names, node)
6462                .await?
6463                .is_empty()
6464        {
6465            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6466        }
6467        Ok(Page { items, next })
6468    }
6469
6470    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6471    /// a far end in another source has to live: no GitHub issue relationship can name one.
6472    ///
6473    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6474    /// source never writes one down.
6475    ///
6476    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6477    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6478    /// request beyond the read already made, and reading the board for them would be a
6479    /// walk of every item for one field of one. A draft has no body in that answer, because
6480    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6481    /// listing of the board, which can be behind on the very item asked about.
6482    async fn recorded_edges(
6483        &self,
6484        id: &NativeId,
6485        near_kind: ItemKind,
6486        direction: Direction,
6487        natively_names: Option<ItemKind>,
6488        node: &Value,
6489    ) -> Result<Vec<DependencyEdge>, SourceError> {
6490        if direction != Direction::DependsOn {
6491            return Ok(Vec::new());
6492        }
6493        let slot = match node.get("body") {
6494            Some(body) if natively_names.is_some() => {
6495                metadata_body(body.as_str().map(str::to_owned))?.1
6496            }
6497            _ => {
6498                let Some(item) = self.bound_item(id).await? else {
6499                    return Ok(Vec::new());
6500                };
6501                item.slot
6502            }
6503        };
6504        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6505            .map_err(|message| SourceError::Malformed { message })
6506    }
6507
6508    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6509        self.repository
6510            .as_ref()
6511            .ok_or_else(|| SourceError::Refused {
6512                message: format!(
6513                    "source {} has no repository configured, and a GitHub Projects board has no \
6514                 repository of its own to create an issue in; set repository: owner/name on \
6515                 this source",
6516                    self.name
6517                ),
6518            })
6519    }
6520
6521    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6522    /// states.
6523    ///
6524    /// The fallback is demanded first, whichever arm answers: a write without a configured
6525    /// repository is refused naming the field exactly as it was before the rule existed,
6526    /// so a source that could not write before cannot write now, rather than writing for
6527    /// the one item whose own field happens to decide it.
6528    ///
6529    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6530    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6531    /// entry owned by someone other than the owner of the parent issue's repository —
6532    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6533    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6534    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6535    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6536    /// and is visible to the token is checked where its node id is resolved, still before
6537    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6538    /// looked up in a listing of the board, which can be minutes behind an issue its own
6539    /// `projectItems` already places on it — and that read answers first from this process's
6540    /// own record, so a project created moments ago in this command answers though GitHub
6541    /// has not caught up.
6542    async fn creation_target(
6543        &self,
6544        incoming: &Incoming<'_>,
6545    ) -> Result<RepositoryTarget, SourceError> {
6546        let fallback = self.configured_repository()?;
6547        let what = |incoming: &Incoming<'_>| {
6548            format!(
6549                "{} {:?}",
6550                incoming.written.kind().describes(),
6551                incoming.title
6552            )
6553        };
6554        let parent = match incoming.parent {
6555            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6556                SourceError::Refused {
6557                    message: format!(
6558                        "GitHub project issue {} was not found on the board of source {}, so {} \
6559                         cannot be filed under it",
6560                        parent.0,
6561                        self.name,
6562                        what(incoming)
6563                    ),
6564                }
6565            })?),
6566            None => None,
6567        };
6568        let parents_repository = parent
6569            .as_ref()
6570            .map(|parent| {
6571                // A draft is on the board and so is found, but it has no repository to
6572                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6573                // would refuse the task only once `createIssue` had made it.
6574                if parent.content_kind == ContentKind::DraftIssue {
6575                    return Err(SourceError::Refused {
6576                        message: format!(
6577                            "GitHub project item {} on the board of source {} is a draft, \
6578                             which cannot have sub-issues, so {} cannot be filed under it",
6579                            parent.id.0,
6580                            self.name,
6581                            what(incoming)
6582                        ),
6583                    });
6584                }
6585                // An issue's repository is where a sub-issue is placed and whose owner it
6586                // is compared against, so a parent whose repository this source cannot
6587                // spell as `owner/name` — GitHub's login grammar is wider than this
6588                // source's floor — is one nothing can be filed under.
6589                parent
6590                    .own_repository
6591                    .as_ref()
6592                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6593                    .ok_or_else(|| SourceError::Malformed {
6594                        message: format!(
6595                            "GitHub project issue {} on the board of source {} is in {}, which \
6596                             is not a {}/owner/name repository this source can place {} in",
6597                            parent.id.0,
6598                            self.name,
6599                            parent
6600                                .own_repository
6601                                .as_ref()
6602                                .map_or("no repository", Repository::as_str),
6603                            RepositoryTarget::HOST,
6604                            what(incoming)
6605                        ),
6606                    })
6607            })
6608            .transpose()?;
6609        match incoming.repositories {
6610            [named] => {
6611                let target =
6612                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6613                        message: format!(
6614                            "{} names repository {}, which is not a {}/owner/name repository \
6615                             source {} can create an issue in; name one that is, or name none",
6616                            what(incoming),
6617                            named.as_str(),
6618                            RepositoryTarget::HOST,
6619                            self.name
6620                        ),
6621                    })?;
6622                if let Some(parents) = &parents_repository
6623                    && parents.owner != target.owner
6624                {
6625                    return Err(SourceError::Refused {
6626                        message: format!(
6627                            "{} names repository {}, owned by {}, but its project's issue is in \
6628                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6629                             of the same owner as its parent issue; name a repository of {}, or \
6630                             name none",
6631                            what(incoming),
6632                            target.slug(),
6633                            target.owner,
6634                            parents.slug(),
6635                            parents.owner,
6636                            parents.owner
6637                        ),
6638                    });
6639                }
6640                Ok(target)
6641            }
6642            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6643        }
6644    }
6645
6646    /// The node id of the repository `incoming` is being created in, or the refusal naming
6647    /// the item and the repository the token cannot see.
6648    ///
6649    /// Resolved once per command per repository; see [`Self::repository_cache`].
6650    async fn repository_id(
6651        &self,
6652        repository: &RepositoryTarget,
6653        incoming: &Incoming<'_>,
6654    ) -> Result<String, SourceError> {
6655        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6656            return Ok(id);
6657        }
6658        let data = self
6659            .graphql(
6660                graphql::REPOSITORY,
6661                json!({"owner":repository.owner,"name":repository.name}),
6662            )
6663            .await?;
6664        self.repository_read(&data, repository, incoming)
6665    }
6666
6667    /// The repository's node id out of an answer carrying the `repository` root, held for
6668    /// the rest of this command, or the refusal naming the item that cannot be created in it.
6669    fn repository_read(
6670        &self,
6671        data: &Value,
6672        repository: &RepositoryTarget,
6673        incoming: &Incoming<'_>,
6674    ) -> Result<String, SourceError> {
6675        let node = data
6676            .get("repository")
6677            .filter(|value| !value.is_null())
6678            .ok_or_else(|| SourceError::Refused {
6679                message: format!(
6680                    "GitHub repository {} was not found or is not visible to the token, so {} \
6681                     {:?} cannot be created in it",
6682                    repository.slug(),
6683                    incoming.written.kind().describes(),
6684                    incoming.title
6685                ),
6686            })?;
6687        let id = required_str(node, "id")?.to_owned();
6688        self.repository_cache()?
6689            .insert(repository.clone(), id.clone());
6690        Ok(id)
6691    }
6692
6693    fn repository_cache(
6694        &self,
6695    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6696        self.repository_cache
6697            .lock()
6698            .map_err(|_| SourceError::Unavailable {
6699                message: "this source's record of the destination repository was left \
6700                          inconsistent by an earlier failure; next: run the command again"
6701                    .into(),
6702            })
6703    }
6704
6705    /// Create or update one board item, whichever kind it is.
6706    async fn write_item(
6707        &self,
6708        incoming: &Incoming<'_>,
6709        target: Option<&NativeId>,
6710        depends_on: &[DependencyEdge],
6711    ) -> Result<NativeId, SourceError> {
6712        // Refused before anything is read or written: a task or a project titled the way
6713        // this board spells a document would land as an issue this same source reads back
6714        // as a document, so the field this destination cannot carry is named rather than
6715        // written and silently reclassified.
6716        if let Written::Work(kind, _) = incoming.written
6717            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6718        {
6719            return Err(SourceError::Refused {
6720                message: format!(
6721                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6722                     spells a document, so it would read back as one rather than as a {}; \
6723                     retitle it, or copy it as a document",
6724                    kind.marker(),
6725                    self.name,
6726                    kind.marker()
6727                ),
6728            });
6729        }
6730        // The destination is read by its own id, and whether this board holds it is decided
6731        // by that read — its own `projectItems` — rather than by whether a listing of the
6732        // board happens to include it yet. See the module documentation.
6733        let existing = match target {
6734            Some(target) => {
6735                Some(
6736                    self.bound_item(target)
6737                        .await?
6738                        .ok_or_else(|| SourceError::Refused {
6739                            message: format!("GitHub destination item {} was not found", target.0),
6740                        })?,
6741                )
6742            }
6743            None => None,
6744        };
6745        let existing = existing.as_ref();
6746        // An existing issue is never moved; a new one is created where the rule says — and
6747        // knowing where is what lets the board's fields and that repository's id be read
6748        // together, before anything below needs either.
6749        let creation_target = match existing {
6750            Some(_) => None,
6751            None => {
6752                let target = self.creation_target(incoming).await?;
6753                self.creation_context(&target, incoming).await?;
6754                Some(target)
6755            }
6756        };
6757        let board = self
6758            .fields_for(
6759                existing,
6760                incoming.written.status().is_some(),
6761                incoming
6762                    .priority
6763                    .is_some_and(|priority| priority != Priority::None),
6764            )
6765            .await?;
6766        let status_target = incoming
6767            .written
6768            .status()
6769            .map(|status| self.resolved_target(status.category))
6770            .transpose()?;
6771        let column = match (incoming.written.status(), status_target.as_ref()) {
6772            (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6773            _ => None,
6774        };
6775        // Resolved before anything is created, for the reason the column above is: a
6776        // priority this board has no option for is refused while nothing has been written.
6777        let priority_write = match incoming.priority {
6778            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6779            None => None,
6780        };
6781        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6782        if content_kind == ContentKind::DraftIssue {
6783            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6784                (status_target.as_ref(), incoming.written.status())
6785            {
6786                return Err(self.closes_a_draft(status.category));
6787            }
6788            if incoming.parent.is_some() {
6789                return Err(SourceError::Refused {
6790                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6791                });
6792            }
6793        }
6794        match existing {
6795            Some(item) if content_kind == ContentKind::Issue => {
6796                if item.labels != incoming.labels {
6797                    return Err(SourceError::Refused {
6798                        message: "GitHub issue labels differ from the labels being written".into(),
6799                    });
6800                }
6801            }
6802            _ => {
6803                if !incoming.labels.is_empty() {
6804                    return Err(SourceError::Refused {
6805                        message: "GitHub items created by this destination carry no labels".into(),
6806                    });
6807                }
6808            }
6809        }
6810
6811        // The repository the issue really lives in is what the slot below is written against,
6812        // so a single entry that is where the issue is created travels as no key at all, and
6813        // the read side derives it back from the issue.
6814        let own_repository = match (existing, &creation_target) {
6815            (Some(item), _) => item.own_repository.clone(),
6816            (None, Some(target)) => Some(
6817                Repository::try_from(target.origin())
6818                    .map_err(|message| SourceError::Config { message })?,
6819            ),
6820            (None, None) => None,
6821        };
6822        let (native, fallback) = self
6823            .partition_edges(
6824                incoming.written.kind(),
6825                content_kind,
6826                existing.and_then(|item| item.blocked_by.as_deref()),
6827                depends_on,
6828            )
6829            .await?;
6830        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6831        let body = compose_body(incoming.content, &slot)?;
6832        // Read before anything is created, for the reason the field below is: a value
6833        // this destination cannot store has to refuse, and refusing after `createIssue`
6834        // would leave an issue behind that nothing asked for. The engine writes a
6835        // qualified id here; a caller handing this key anything else is told so rather
6836        // than having it silently stored as no origin at all.
6837        // 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.
6838        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6839            None => "",
6840            Some(Value::String(origin)) => origin.as_str(),
6841            Some(other) => {
6842                return Err(SourceError::Refused {
6843                    message: format!(
6844                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6845                         is {other}"
6846                    ),
6847                });
6848            }
6849        };
6850        // Resolved before anything is created: a board that cannot carry the copy origin
6851        // has to refuse the write, and refusing it after `createIssue` would leave an
6852        // issue behind that nothing asked for.
6853        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6854            Some(field) => {
6855                if required_str(field, "__typename")? != "ProjectV2Field" {
6856                    return Err(SourceError::Refused {
6857                        message: format!(
6858                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6859                        ),
6860                    });
6861                }
6862                Some(required_str(field, "id")?.to_owned())
6863            }
6864            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6865                return Err(SourceError::Refused {
6866                    message: format!(
6867                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6868                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6869                         the board"
6870                    ),
6871                });
6872            }
6873            None => None,
6874        };
6875
6876        let Landed {
6877            content_id,
6878            item_id,
6879            url,
6880            number,
6881        } = match existing {
6882            // Its content is written last, below, once everything else has landed.
6883            Some(item) => Landed {
6884                content_id: item.id.clone(),
6885                item_id: item.item_id.clone(),
6886                url: item.url.clone(),
6887                number: item.number,
6888            },
6889            None => {
6890                let target = creation_target
6891                    .as_ref()
6892                    .ok_or_else(|| SourceError::Malformed {
6893                        message: "a new item was decided without a repository to create it in"
6894                            .into(),
6895                    })?;
6896                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6897                    .await?
6898            }
6899        };
6900
6901        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6902        let column = column
6903            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6904            .map(|(field, option, _)| (field, option));
6905        // Creating an item here is several calls — `createIssue`, which files it on the
6906        // board, then its board fields, the parent and the dependencies — and GitHub can fail
6907        // at any of them. Everything this source can refuse *before* the first of those is
6908        // already checked above, so what is left is GitHub itself failing part way. When it
6909        // does over an item this call created, the issue is taken back: a write that
6910        // refused must not leave an item behind that nobody asked for, and one that does
6911        // makes the retry create a second.
6912        // Whether the board-field write carrying a moved origin was answered as landing whole.
6913        // When it was refused, GitHub does not say which of its fields ran before the one that
6914        // failed, so the origin may or may not have moved.
6915        let mut origin_landed = false;
6916        let landed = self
6917            .finish_write(
6918                board.id.as_str(),
6919                incoming,
6920                &content_id,
6921                &item_id,
6922                content_kind,
6923                existing,
6924                origin_field.as_deref(),
6925                origin,
6926                column,
6927                status_target.as_ref(),
6928                priority_write.as_ref(),
6929                &native,
6930                &mut origin_landed,
6931            )
6932            .await;
6933        // An existing item's title, body and state go last, in one `updateIssue`, once its board
6934        // fields and its relationships have landed: a refusal of any of those then leaves its
6935        // body — and the metadata slot inside it — exactly as it stood.
6936        let landed = match (landed, existing) {
6937            (Ok(()), Some(item)) => {
6938                self.update_existing(item, incoming, &body, status_target.as_ref())
6939                    .await
6940            }
6941            (landed, _) => landed,
6942        };
6943        if let Err(error) = landed {
6944            match existing {
6945                // Best effort, and the write's own failure is what the caller is told: a
6946                // refusal naming the tidy-up would hide why the write failed at all.
6947                None => {
6948                    let _ = self.delete_issue(&content_id).await;
6949                }
6950                // The origin field is the one piece of an existing item's metadata written
6951                // before its body, so a write refused after it puts it back as it was. When
6952                // that is refused too, the write's own failure is still what the caller is
6953                // told — with what it left behind added, because the item's metadata is then
6954                // not as it stood and a caller retrying has to know which key moved.
6955                Some(item) => {
6956                    let before = item.origin.as_deref().unwrap_or("");
6957                    if let Some(field) = origin_field.as_deref()
6958                        && before != origin
6959                        && let Err(restore) = self
6960                            .set_item_field(
6961                                board.id.as_str(),
6962                                &item.item_id,
6963                                field,
6964                                json!({"text": before}),
6965                            )
6966                            .await
6967                    {
6968                        let left = if origin_landed {
6969                            format!(
6970                                "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
6971                                 not be put back to {before:?} ({restore}), so item {} still \
6972                                 holds {origin:?} there",
6973                                item.id.0
6974                            )
6975                        } else {
6976                            format!(
6977                                "the refused write carried its {ORIGIN_KEY} from {before:?} to \
6978                                 {origin:?}, GitHub does not say whether that part of it ran, \
6979                                 and putting it back to {before:?} was refused ({restore}), so \
6980                                 item {} holds {origin:?} or {before:?} there",
6981                                item.id.0
6982                            )
6983                        };
6984                        return Err(noting(
6985                            error,
6986                            &format!(
6987                                "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
6988                                 run the write again"
6989                            ),
6990                        ));
6991                    }
6992                }
6993            }
6994            return Err(error);
6995        }
6996
6997        let written_status = match (incoming.written.status(), status_target.as_ref()) {
6998            (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6999                self.statuses
7000                    .status(written_option.as_deref(), true, Some(reason.reason()))
7001            }
7002            (Some(_), Some(StatusTarget::Column(_))) => {
7003                self.statuses.status(written_option.as_deref(), false, None)
7004            }
7005            (Some(status), _) => status.clone(),
7006            (None, _) => Status {
7007                category: StatusCategory::Unknown,
7008                name: "Open".to_owned(),
7009            },
7010        };
7011
7012        // So the rest of this command reads what it just did rather than what the board
7013        // said before it. See `remember_written` for which half takes it.
7014        let remembered = Resolved {
7015            item_id,
7016            id: content_id.clone(),
7017            content_kind,
7018            kind: incoming.written.kind(),
7019            title: incoming.title.to_owned(),
7020            // The visible half of the body this write composed, split back off it the
7021            // way a read splits it — so what this record reports is what a read of the
7022            // same issue reports, rather than the person's text with the metadata slot
7023            // still on the end of it.
7024            body: metadata_body(body.clone())?.0,
7025            raw_body: body.clone(),
7026            // A document has no status of its own; what it reads back as is whatever
7027            // the issue's own state says, which is what a re-read reports.
7028            status: written_status,
7029            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7030            priority: match incoming.priority {
7031                Some(priority) => HeldPriority::Read(priority),
7032                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7033                    item.priority.clone()
7034                }),
7035            },
7036            // What `state_input` asked for: closed for a terminal target, open for any other
7037            // status, and the issue's own state left as it was by a document write.
7038            closed: content_kind == ContentKind::Issue
7039                && match status_target.as_ref() {
7040                    Some(StatusTarget::Terminal(_, _)) => true,
7041                    Some(_) => false,
7042                    None => existing.is_some_and(|item| item.closed),
7043                },
7044            delivers: incoming.delivers.to_vec(),
7045            delivered_by: incoming.delivered_by.to_vec(),
7046            labels: incoming.labels.to_vec(),
7047            parent: incoming.parent.cloned(),
7048            origin: (!origin.is_empty()).then(|| origin.to_owned()),
7049            number,
7050            // In the update path this is the item's own url, read off `existing` where the
7051            // record above was bound, so one expression serves both halves.
7052            url,
7053            created_at: existing.and_then(|item| item.created_at),
7054            updated_at: existing.and_then(|item| item.updated_at),
7055            own_repository,
7056            repositories: incoming.repositories.to_vec(),
7057            slot,
7058            board_id: Some(board.id.as_str().to_owned()),
7059            fields: board
7060                .fields
7061                .get("nodes")
7062                .and_then(Value::as_array)
7063                .cloned()
7064                .unwrap_or_default(),
7065            board_fields: Some(board.fields.clone()),
7066            // What this write left the relationship holding is known by id alone, and a
7067            // later read of its edges needs each far end's kind, so it reads them again.
7068            blocked_by: None,
7069        };
7070        self.remember_written(remembered, existing.is_none())?;
7071        Ok(content_id)
7072    }
7073
7074    /// Everything a write does after the item exists: its board fields, its parent, and
7075    /// its dependencies.
7076    ///
7077    /// Split out of `write_item` so there is one place a failure past the point of no
7078    /// return is caught, rather than a tidy-up repeated at each `?` above.
7079    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7080    // so there is one place a failure past the point of no return is caught, and its
7081    // arguments are exactly the values that tail already had in scope. Bundling them into a
7082    // struct would describe no concept — it would be "the arguments of this function" — and
7083    // would put the whole of `write_item`'s locals behind one more indirection.
7084    #[allow(clippy::too_many_arguments)]
7085    async fn finish_write(
7086        &self,
7087        board_id: &str,
7088        incoming: &Incoming<'_>,
7089        content_id: &NativeId,
7090        item_id: &str,
7091        content_kind: ContentKind,
7092        existing: Option<&Resolved>,
7093        origin_field: Option<&str>,
7094        origin: &str,
7095        column: Option<(String, String)>,
7096        status_target: Option<&StatusTarget>,
7097        priority: Option<&PriorityWrite>,
7098        native: &[String],
7099        origin_landed: &mut bool,
7100    ) -> Result<(), SourceError> {
7101        let mut fields = Vec::new();
7102        if let Some(field_id) = origin_field
7103            && existing.map_or(!origin.is_empty(), |item| {
7104                item.origin.as_deref().unwrap_or("") != origin
7105            })
7106        {
7107            fields.push((field_id.to_owned(), json!({"text":origin})));
7108        }
7109        if let Some((field_id, option_id)) = column {
7110            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7111        }
7112        let clear = match priority {
7113            Some(PriorityWrite::Select { field, option }) => {
7114                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7115                None
7116            }
7117            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7118            None => None,
7119        };
7120        self.set_item_fields(board_id, item_id, &fields, clear)
7121            .await?;
7122        *origin_landed = true;
7123
7124        // An existing issue closes in the `updateIssue` its write ends with; one created just
7125        // now closes here, once its option is selected.
7126        if existing.is_none()
7127            && content_kind == ContentKind::Issue
7128            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7129        {
7130            self.update_content(
7131                ContentKind::Issue,
7132                content_id,
7133                json!({"stateInput":state_input(status_target)}),
7134            )
7135            .await?;
7136        }
7137
7138        if content_kind == ContentKind::Issue {
7139            self.reparent(
7140                existing.and_then(|item| item.parent.clone()),
7141                content_id,
7142                incoming.parent,
7143            )
7144            .await?;
7145            // A document takes part in no dependency graph, so writing one neither reads
7146            // nor changes the issue's own `blockedBy` relationships. Reconciling them
7147            // against the empty list a document write carries would *delete* whatever
7148            // relationships a person had made on that issue, which is a write nobody
7149            // asked for.
7150            if incoming.written.kind() != BoardKind::Document {
7151                let issue = match existing {
7152                    Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7153                    None => Issue::Created,
7154                };
7155                self.reconcile_blocked_by(content_id, native, issue).await?;
7156            }
7157        }
7158        Ok(())
7159    }
7160
7161    /// Delete one issue, which takes its board item with it.
7162    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7163        let data = self
7164            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7165            .await?;
7166        data.pointer("/deleteIssue/repository")
7167            .filter(|value| !value.is_null())
7168            .ok_or_else(|| SourceError::Malformed {
7169                message: "GitHub issue deletion returned no repository".into(),
7170            })?;
7171        self.forget(id)?;
7172        Ok(())
7173    }
7174
7175    /// Remove one item this copy created, so a copy that could not finish leaves the board
7176    /// as it found it.
7177    ///
7178    /// Deleting the issue takes its board item with it, so there is no second mutation to
7179    /// keep in step. An id the board does not hold is not an error: the item is already
7180    /// gone, which is the state this asks for. Which that is, is decided by reading the item
7181    /// by its own id — a listing of the board can still be missing an item it holds, and
7182    /// reading that as *already gone* would leave behind the very item this was asked to
7183    /// take back.
7184    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7185        let Some(item) = self.bound_item(id).await? else {
7186            return Ok(());
7187        };
7188        if item.content_kind == ContentKind::DraftIssue {
7189            return Err(SourceError::Refused {
7190                message: format!(
7191                    "GitHub item {} is a draft, and this source removes an item by deleting \
7192                     its issue; next: remove it from the board by hand",
7193                    id.0
7194                ),
7195            });
7196        }
7197        let data = self
7198            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7199            .await?;
7200        data.pointer("/deleteIssue/repository")
7201            .filter(|value| !value.is_null())
7202            .ok_or_else(|| SourceError::Malformed {
7203                message: "GitHub issue deletion returned no repository".into(),
7204            })?;
7205        self.forget(id)?;
7206        Ok(())
7207    }
7208
7209    /// The issue a comment call on `task` is about, or `None` when this board holds no such
7210    /// task.
7211    ///
7212    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7213    /// read of the task cannot disagree about which ids name one: a project or a document of
7214    /// this board is not a task here either.
7215    ///
7216    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7217    /// issues and a draft is not one. It is refused rather than answered with an empty page,
7218    /// which would read as a task nobody has commented on yet.
7219    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7220        let cached = self.resolved_cache()?.get(task).cloned();
7221        let Some(item) = (match cached {
7222            Some(item) => Some(item),
7223            None => self.item_by_id(task).await?,
7224        })
7225        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7226            return Ok(None);
7227        };
7228        if item.content_kind == ContentKind::DraftIssue {
7229            return Err(self.draft_has_no_comments(task));
7230        }
7231        Ok(Some(item.id))
7232    }
7233
7234    /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7235    /// issues, and a draft is not one.
7236    fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7237        SourceError::Refused {
7238            message: format!(
7239                "task {} of source {} is a draft item on the board, and GitHub keeps \
7240                 comments on issues alone, so a draft has none to read or write; next: \
7241                 convert the draft to an issue on the board, then comment on the issue it \
7242                 becomes",
7243                task.0, self.name
7244            ),
7245        }
7246    }
7247
7248    /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7249    /// request — or `None` when this board holds no task by that id.
7250    ///
7251    /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7252    /// is answered with the draft and the refusal, at the price of the draft's own read.
7253    async fn issue_detail(
7254        &self,
7255        id: &NativeId,
7256        page: &PageRequest,
7257    ) -> Result<Option<TaskDetailRead>, SourceError> {
7258        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7259        let asked = self
7260            .graphql(
7261                graphql::ISSUE_DETAIL,
7262                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7263                       "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7264                       "duplicates":true}),
7265            )
7266            .await;
7267        let data = match asked {
7268            Ok(data) => data,
7269            Err(error) if unresolvable_node(&error) => return Ok(None),
7270            Err(error) => return Err(error),
7271        };
7272        // `node` is null for an id that names nothing, and absent only from an answer this
7273        // source cannot read — never the same thing.
7274        let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7275            message: format!("GitHub answered the read of {} with no node", id.0),
7276        })?;
7277        self.detail_of(id, node, true, after).await
7278    }
7279
7280    /// Several tasks, each with the first page of its comments when `comments` is set, read
7281    /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7282    /// order.
7283    ///
7284    /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7285    /// one item at a time, so that id is answered as missing and the others as themselves; any
7286    /// other refusal is every id of that batch's answer.
7287    async fn issue_details(
7288        &self,
7289        ids: &[NativeId],
7290        comments: Option<&PageRequest>,
7291    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7292        let mut read = Vec::with_capacity(ids.len());
7293        for batch in ids.chunks(DETAIL_BATCH) {
7294            match self
7295                .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7296                .await
7297            {
7298                Ok(data) => {
7299                    for (slot, id) in batch.iter().enumerate() {
7300                        // Every alias asked for is answered, null for an id naming nothing;
7301                        // one missing is an answer this source cannot read.
7302                        let read_one = match data.get(format!("i{slot}")) {
7303                            Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7304                            None => Err(SourceError::Malformed {
7305                                message: format!(
7306                                    "GitHub answered a batch read with no item for {}",
7307                                    id.0
7308                                ),
7309                            }),
7310                        };
7311                        read.push(read_one);
7312                    }
7313                }
7314                Err(error) if unresolvable_node(&error) => {
7315                    for id in batch {
7316                        read.push(match comments {
7317                            Some(page) => self.issue_detail(id, page).await,
7318                            None => self.task_read(id).await,
7319                        });
7320                    }
7321                }
7322                Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7323            }
7324        }
7325        read
7326    }
7327
7328    /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7329    async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7330        Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7331            task,
7332            comments: None,
7333        }))
7334    }
7335
7336    /// What one node a detail read reached says: the task this board holds by `id`, with the
7337    /// page of comments the node carries when `commented` — or `None` for a node that is no
7338    /// task of this board.
7339    ///
7340    /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7341    /// and an item this process created answers from this process's own record, which a node
7342    /// read taken moments after the write can still be behind.
7343    async fn detail_of(
7344        &self,
7345        id: &NativeId,
7346        node: &Value,
7347        commented: bool,
7348        after: Option<&str>,
7349    ) -> Result<Option<TaskDetailRead>, SourceError> {
7350        if node.is_null() {
7351            return Ok(None);
7352        }
7353        let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7354        // An issue answered under one id is that id's, or the answer is not one this source
7355        // can report: reporting another issue's task and comments under the qualified id asked
7356        // for would be the one wrong answer here. A draft's own read checks the same.
7357        if !draft
7358            && optional_str(node, "__typename")? == Some("Issue")
7359            && required_str(node, "id")? != id.0
7360        {
7361            return Err(SourceError::Malformed {
7362                message: format!(
7363                    "GitHub answered the read of {} with issue {}",
7364                    id.0,
7365                    required_str(node, "id")?
7366                ),
7367            });
7368        }
7369        let item = if draft {
7370            self.draft_by_id(id).await?
7371        } else {
7372            self.resolve_issue(node).await?
7373        };
7374        let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7375            return Ok(None);
7376        };
7377        let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7378        let task = own.unwrap_or(item).task()?;
7379        let comments = match (commented, draft) {
7380            (false, _) => None,
7381            (true, true) => Some(Err(self.draft_has_no_comments(id))),
7382            (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7383        };
7384        Ok(Some(TaskDetailRead { task, comments }))
7385    }
7386
7387    /// Whether the comment `comment` is one of `issue`'s own.
7388    ///
7389    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7390    /// comment's id and nothing else: a comment id given against the wrong task would
7391    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7392    /// names something that is not an issue comment, is a comment this task does not have —
7393    /// which is what GitHub refusing to resolve it means too.
7394    async fn comment_is_on(
7395        &self,
7396        issue: &NativeId,
7397        comment: &NativeId,
7398    ) -> Result<bool, SourceError> {
7399        let asked = self
7400            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7401            .await;
7402        let data = match asked {
7403            Ok(data) => data,
7404            Err(error) if unresolvable_node(&error) => return Ok(false),
7405            Err(error) => return Err(error),
7406        };
7407        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7408            return Ok(false);
7409        };
7410        if optional_str(node, "__typename")? != Some("IssueComment") {
7411            return Ok(false);
7412        }
7413        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7414            message: format!("GitHub issue comment {} names no issue", comment.0),
7415        })?;
7416        Ok(required_str(on, "id")? == issue.0)
7417    }
7418
7419    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7420    async fn partition_edges(
7421        &self,
7422        near_kind: BoardKind,
7423        near_content: ContentKind,
7424        carried: Option<&[Value]>,
7425        depends_on: &[DependencyEdge],
7426    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7427        let mut native = Vec::new();
7428        let mut fallback = Vec::new();
7429        let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7430            .iter()
7431            .map(|edge| {
7432                let same_source = edge
7433                    .to
7434                    .source()
7435                    .is_none_or(|source| source == self.name.as_str());
7436                // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7437                // `DependencyEndpoint::source` both read it that way — and a native id may hold
7438                // colons of its own, so the far end is everything after that one separator.
7439                // Splitting at the last would truncate `work:urn:task:7` to `7`.
7440                let far_id = if edge.to.is_qualified() {
7441                    edge.to
7442                        .id()
7443                        .split_once(':')
7444                        .map_or(edge.to.id(), |(_, native)| native)
7445                } else {
7446                    edge.to.id()
7447                };
7448                // One that already blocks the near issue was answered by that issue's own
7449                // read, which carried each of its blockers' kinds — an issue every one — so it
7450                // is not read again.
7451                let blocking = carried.and_then(|nodes| {
7452                    nodes
7453                        .iter()
7454                        .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7455                });
7456                (edge, far_id, same_source, blocking)
7457            })
7458            .collect();
7459        // Every other same-source far end is read by its own id, exactly as the item it is a
7460        // far end of is: whether this board holds it is that read's answer, never a listing's.
7461        // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7462        let mut unread: Vec<NativeId> = Vec::new();
7463        for (_, far_id, same_source, blocking) in &far_ends {
7464            let id = NativeId((*far_id).to_owned());
7465            if *same_source && blocking.is_none() && !unread.contains(&id) {
7466                unread.push(id);
7467            }
7468        }
7469        let read: BTreeMap<NativeId, Option<Resolved>> = unread
7470            .iter()
7471            .cloned()
7472            .zip(self.items_by_ids(&unread).await?)
7473            .collect();
7474        for (edge, far_id, same_source, blocking) in far_ends {
7475            let far = match (same_source, blocking) {
7476                (false, _) => None,
7477                (true, Some(node)) => Some(FarEnd {
7478                    kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7479                        BoardKind::Document
7480                    } else {
7481                        BoardKind::Work(related_kind(node)?)
7482                    },
7483                    content_kind: ContentKind::Issue,
7484                }),
7485                (true, None) => {
7486                    let read = read
7487                        .get(&NativeId(far_id.to_owned()))
7488                        .cloned()
7489                        .flatten()
7490                        .ok_or_else(|| SourceError::Refused {
7491                            message: format!("GitHub dependency item {far_id} was not found"),
7492                        })?;
7493                    Some(FarEnd {
7494                        kind: read.kind,
7495                        content_kind: read.content_kind,
7496                    })
7497                }
7498            };
7499            let far = far.as_ref();
7500            // The caller says which kind the far end is, and this board holds the far end
7501            // itself, so a disagreement is settled here rather than stored: recorded, the
7502            // wrong kind would read back as a cross-level edge that never existed; written
7503            // natively, it would name a relationship of a different level than the caller
7504            // asked for.
7505            //
7506            // A far end this board holds as a *document* fails the same comparison and is
7507            // refused by the same sentence: `ItemKind` has no document variant because
7508            // nothing may point at one, so no caller can name it correctly and the refusal
7509            // is the only honest answer.
7510            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7511                return Err(SourceError::Refused {
7512                    message: format!(
7513                        "GitHub dependency item {far_id} is a {} of this board, and this item \
7514                         names it as a {}; record the kind it is",
7515                        disagreeing.kind.describes(),
7516                        edge.to.kind.marker()
7517                    ),
7518                });
7519            }
7520            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7521            // however the far end is spelled — and one classified native here would be
7522            // written nowhere at all, because a draft's native reconciliation never runs.
7523            let native_here = near_content == ContentKind::Issue
7524                && far.is_some_and(|far| {
7525                    far.content_kind == ContentKind::Issue
7526                        && BoardKind::Work(edge.to.kind) == near_kind
7527                });
7528            if native_here {
7529                native.push(far_id.to_owned());
7530            } else {
7531                fallback.push(edge.clone());
7532            }
7533        }
7534        Ok((native, fallback))
7535    }
7536
7537    async fn update_existing(
7538        &self,
7539        item: &Resolved,
7540        incoming: &Incoming<'_>,
7541        body: &Option<String>,
7542        status_target: Option<&StatusTarget>,
7543    ) -> Result<(), SourceError> {
7544        let title = incoming.written_title();
7545        // A terminal status closes the issue here, in the same mutation as its body: its board
7546        // option was selected before this, so a close never lands on an item whose board cannot
7547        // show it.
7548        let fields = match item.content_kind {
7549            ContentKind::DraftIssue => json!({"title":title,"body":body}),
7550            ContentKind::Issue => json!({"title":title,"body":body,
7551                                         "stateInput":state_input(status_target)}),
7552        };
7553        self.update_content(item.content_kind, &item.id, fields)
7554            .await
7555    }
7556
7557    /// Update one board item's content with exactly `fields` beside its id, through the
7558    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7559    /// a draft.
7560    ///
7561    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7562    /// is what lets a narrow write carry the one thing it changes and nothing else.
7563    async fn update_content(
7564        &self,
7565        kind: ContentKind,
7566        id: &NativeId,
7567        fields: Value,
7568    ) -> Result<(), SourceError> {
7569        let (operation, id_key, pointer) = match kind {
7570            ContentKind::DraftIssue => (
7571                graphql::UPDATE_DRAFT,
7572                "draftIssueId",
7573                "/updateProjectV2DraftIssue/draftIssue",
7574            ),
7575            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7576        };
7577        let mut input = fields;
7578        input[id_key] = json!(id.0);
7579        let data = self.graphql(operation, json!({"input":input})).await?;
7580        let returned = data
7581            .pointer(pointer)
7582            .ok_or_else(|| SourceError::Malformed {
7583                message: "GitHub item update returned no item".into(),
7584            })?;
7585        if required_str(returned, "id")? != id.0 {
7586            return Err(SourceError::Malformed {
7587                message: "GitHub item update returned the wrong item".into(),
7588            });
7589        }
7590        Ok(())
7591    }
7592
7593    /// Creates one issue, files it on the board, and reports what a read of it would say:
7594    /// its content id, its board item id, and the web address GitHub gave it.
7595    ///
7596    /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7597    /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7598    /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7599    /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7600    /// "Content already exists in this project". A terminal status is not written here:
7601    /// `finish_write` selects its option first and closes the issue after, so a close never
7602    /// lands on an item whose board cannot show it.
7603    ///
7604    /// The address and the number come back here because this is the only place either is
7605    /// known before GitHub's own board read catches up — an item this run created answers
7606    /// the reads that follow it out of the record below, and one remembered without them
7607    /// would report no location and no key for the rest of the run.
7608    async fn create_and_file_issue(
7609        &self,
7610        board_id: &str,
7611        repository: &RepositoryTarget,
7612        incoming: &Incoming<'_>,
7613        body: &Option<String>,
7614    ) -> Result<Landed, SourceError> {
7615        let repository_id = self.repository_id(repository, incoming).await?;
7616        let data = self
7617            .graphql(
7618                graphql::CREATE_ISSUE,
7619                json!({"input":{
7620                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7621                }}),
7622            )
7623            .await?;
7624        let created = data
7625            .pointer("/createIssue/issue")
7626            .filter(|value| !value.is_null())
7627            .ok_or_else(|| SourceError::Malformed {
7628                message: "GitHub issue creation returned no issue".into(),
7629            })?;
7630        let content_id = NativeId(required_str(created, "id")?.to_owned());
7631        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7632        // a response without it is not worth failing a landed write over — the item simply
7633        // reports no location until the board read catches up, which is what it did before.
7634        let url = optional_str(created, "url")?.map(str::to_owned);
7635        // The issue exists from here on, so an unreadable number and a refused board
7636        // filing below each try, best effort, to take it back: an issue in the repository
7637        // that is on no board is an item nobody asked for and nothing here would find again.
7638        //
7639        // Its number is optional on the same terms its address is — a landed write is not
7640        // worth failing over a member that came back missing, and such an item reports no
7641        // handle until a board read catches up. A number that is *present* and is not an
7642        // unsigned integer is still a response this source cannot read.
7643        let number = match created_issue_number(created) {
7644            Ok(number) => number,
7645            Err(error) => {
7646                let _ = self.delete_issue(&content_id).await;
7647                return Err(error);
7648            }
7649        };
7650        let added = match self
7651            .graphql(
7652                graphql::ADD_TO_BOARD,
7653                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7654            )
7655            .await
7656        {
7657            Ok(added) => added,
7658            Err(error) => {
7659                let _ = self.delete_issue(&content_id).await;
7660                return Err(error);
7661            }
7662        };
7663        let item = added
7664            .pointer("/addProjectV2ItemById/item")
7665            .filter(|value| !value.is_null())
7666            .ok_or_else(|| SourceError::Malformed {
7667                message: "GitHub board addition returned no project item".into(),
7668            })?;
7669        Ok(Landed {
7670            content_id,
7671            item_id: required_str(item, "id")?.to_owned(),
7672            url,
7673            number,
7674        })
7675    }
7676
7677    /// Move one issue under the project it now belongs to, or out of the one it left.
7678    async fn reparent(
7679        &self,
7680        held: Option<NativeId>,
7681        child: &NativeId,
7682        wanted: Option<&NativeId>,
7683    ) -> Result<(), SourceError> {
7684        if held.as_ref() == wanted {
7685            return Ok(());
7686        }
7687        if let Some(held) = &held {
7688            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7689                .await?;
7690        }
7691        if let Some(wanted) = wanted {
7692            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7693                .await?;
7694        }
7695        Ok(())
7696    }
7697
7698    async fn sub_issue(
7699        &self,
7700        operation: &str,
7701        parent: &NativeId,
7702        child: &NativeId,
7703        root: &str,
7704    ) -> Result<(), SourceError> {
7705        let data = self
7706            .graphql(
7707                operation,
7708                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7709            )
7710            .await?;
7711        let issue =
7712            data.pointer(&format!("/{root}/issue"))
7713                .ok_or_else(|| SourceError::Malformed {
7714                    message: "GitHub sub-issue update returned no issue".into(),
7715                })?;
7716        let sub =
7717            data.pointer(&format!("/{root}/subIssue"))
7718                .ok_or_else(|| SourceError::Malformed {
7719                    message: "GitHub sub-issue update returned no sub-issue".into(),
7720                })?;
7721        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7722            return Err(SourceError::Malformed {
7723                message: "GitHub sub-issue update returned the wrong issues".into(),
7724            });
7725        }
7726        Ok(())
7727    }
7728
7729    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7730    /// whether there was one.
7731    ///
7732    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7733    /// relationships are not read: there is nothing a read of them could find.
7734    async fn reconcile_blocked_by(
7735        &self,
7736        content_id: &NativeId,
7737        native: &[String],
7738        issue: Issue<'_>,
7739    ) -> Result<bool, SourceError> {
7740        let current = match issue {
7741            Issue::Created => Vec::new(),
7742            Issue::Existing(Some(held)) => held
7743                .iter()
7744                .map(|far| required_str(far, "id").map(str::to_owned))
7745                .collect::<Result<Vec<_>, _>>()?,
7746            Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7747        };
7748        let mut changed = false;
7749        for (operation, far_id) in current
7750            .iter()
7751            .filter(|id| !native.contains(id))
7752            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7753            .chain(
7754                native
7755                    .iter()
7756                    .filter(|id| !current.contains(id))
7757                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7758            )
7759        {
7760            let data = self
7761                .graphql(
7762                    operation,
7763                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7764                )
7765                .await?;
7766            let root = if operation == graphql::ADD_BLOCKED_BY {
7767                "addBlockedBy"
7768            } else {
7769                "removeBlockedBy"
7770            };
7771            let issue =
7772                data.pointer(&format!("/{root}/issue"))
7773                    .ok_or_else(|| SourceError::Malformed {
7774                        message: "GitHub dependency update returned no issue".into(),
7775                    })?;
7776            let blocker = data
7777                .pointer(&format!("/{root}/blockingIssue"))
7778                .ok_or_else(|| SourceError::Malformed {
7779                    message: "GitHub dependency update returned no blocking issue".into(),
7780                })?;
7781            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7782            {
7783                return Err(SourceError::Malformed {
7784                    message: "GitHub dependency update returned the wrong issues".into(),
7785                });
7786            }
7787            changed = true;
7788        }
7789        Ok(changed)
7790    }
7791}
7792
7793/// What a write needs to know of one far end it names: which kind of item it is, and whether
7794/// it is an issue a native relationship can name.
7795struct FarEnd {
7796    kind: BoardKind,
7797    content_kind: ContentKind,
7798}
7799
7800/// Whether the issue one write reconciles was created by that write or was already there.
7801#[derive(Clone, Copy, PartialEq, Eq)]
7802enum Issue<'a> {
7803    /// Created by this write, so it holds no relationships yet.
7804    Created,
7805    /// On the board before this write, holding whatever relationships it holds — the far
7806    /// ends of its whole `blockedBy`, when the read that reached it carried them.
7807    Existing(Option<&'a [Value]>),
7808}
7809
7810/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7811enum Reached {
7812    /// An issue this board holds, resolved into everything this source reports about it.
7813    Held(Box<Resolved>),
7814    /// Nothing this board holds: no such node, or a node on some other board.
7815    Nothing,
7816    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7817    /// again by [`GitHubProjectsSource::draft_by_id`].
7818    Draft,
7819}
7820
7821/// What GitHub says when a string is not a node id it can resolve.
7822///
7823/// Matched because it is the ordinary answer to a project selector naming a project by its
7824/// *name*, and reporting that as a failure would make naming one impossible. It is read
7825/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7826/// does not define the syntax of a GitHub node id and would be wrong about it.
7827const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7828
7829/// `error` with `note` added to the end of what it says, its kind and every other member
7830/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7831/// what that failure left behind.
7832fn noting(error: SourceError, note: &str) -> SourceError {
7833    match error {
7834        SourceError::Config { message } => SourceError::Config {
7835            message: message + note,
7836        },
7837        SourceError::Auth { message } => SourceError::Auth {
7838            message: message + note,
7839        },
7840        SourceError::Refused { message } => SourceError::Refused {
7841            message: message + note,
7842        },
7843        SourceError::RateLimited {
7844            retry_after_seconds,
7845            message,
7846        } => SourceError::RateLimited {
7847            retry_after_seconds,
7848            message: Some(message.unwrap_or_default() + note),
7849        },
7850        SourceError::Unavailable { message } => SourceError::Unavailable {
7851            message: message + note,
7852        },
7853        SourceError::Malformed { message } => SourceError::Malformed {
7854            message: message + note,
7855        },
7856    }
7857}
7858
7859/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7860/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7861/// for them.
7862///
7863/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7864/// is read again at no added price.
7865fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7866    let mut variables = serde_json::Map::new();
7867    for slot in 0..DETAIL_BATCH {
7868        let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7869        variables.insert(format!("id{slot}"), json!(id));
7870    }
7871    variables.insert(
7872        "first".to_owned(),
7873        json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7874    );
7875    variables.insert("comments".to_owned(), json!(comments.is_some()));
7876    variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7877    variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7878    variables.insert("duplicates".to_owned(), json!(true));
7879    Value::Object(variables)
7880}
7881
7882/// Whether this refusal is GitHub saying the id names no node at all.
7883fn unresolvable_node(error: &SourceError) -> bool {
7884    matches!(error, SourceError::Refused { message }
7885        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7886}
7887
7888/// One project name, as a search qualifier which filters on it at the server.
7889///
7890/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7891/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7892/// the way it documents. A title matched here is still compared for equality afterwards:
7893/// the qualifier narrows what the server sends, and this source decides what it names.
7894fn title_qualifier(name: &str) -> String {
7895    format!("in:title {}", quoted(name))
7896}
7897
7898/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7899/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7900/// it documents — so a value holding a qualifier's spelling is searched for rather than
7901/// obeyed.
7902fn quoted(value: &str) -> String {
7903    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7904    format!("\"{escaped}\"")
7905}
7906
7907/// The search qualifier for the issues updated at or after `since`.
7908///
7909/// Written to the second, rounded down, which can only widen what the search returns.
7910fn updated_qualifier(since: DateTime<Utc>) -> String {
7911    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7912}
7913
7914/// The search terms that narrow a board-scoped issue search to a task query's text and
7915/// metadata predicates, or `None` when it carries neither.
7916///
7917/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7918/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7919/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7920/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7921/// a metadata value searches both fields for both — wider than asked, never narrower, and
7922/// every candidate is confirmed in process afterwards.
7923///
7924/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7925/// matches whole tokens where a substring rule would match inside a word, so an item holding
7926/// the text only inside a longer word is not returned. A text of nothing but whitespace
7927/// matches every item, so it narrows nothing and is not sent.
7928fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7929    let text = query
7930        .text
7931        .as_ref()
7932        .filter(|text| !text.terms.trim().is_empty());
7933    if text.is_none() && query.metadata.is_empty() {
7934        return None;
7935    }
7936    let (title, body) = match text.map(|text| text.fields) {
7937        None => (false, true),
7938        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7939        Some(TextFields::Content) => (false, true),
7940        Some(TextFields::TitleOrContent) => (true, true),
7941    };
7942    let fields = match (title, body) {
7943        (true, true) => "in:title,body",
7944        (true, false) => "in:title",
7945        _ => "in:body",
7946    };
7947    let phrases = text
7948        .map(|text| text.terms.clone())
7949        .into_iter()
7950        .chain(
7951            query
7952                .metadata
7953                .iter()
7954                .map(|wanted| as_stored(wanted.value())),
7955        )
7956        .map(|phrase| quoted(&phrase))
7957        .collect::<Vec<_>>();
7958    Some(format!("{fields} {}", phrases.join(" ")))
7959}
7960
7961/// The search terms that narrow a board-scoped issue search to a project or document query's
7962/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
7963/// carrying that text alone is sent as by [`narrowing_qualifiers`].
7964fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
7965    narrowing_qualifiers(&TaskQuery {
7966        text: text.cloned(),
7967        ..TaskQuery::default()
7968    })
7969}
7970
7971/// Refuses a project or document query's text GitHub's issue search cannot find, before
7972/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
7973/// query's.
7974fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
7975    refuse_unsearchable(&TaskQuery {
7976        text: text.cloned(),
7977        ..TaskQuery::default()
7978    })
7979}
7980
7981/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7982/// before anything is asked of GitHub.
7983///
7984/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7985/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7986/// left out, the search is every issue of the board. So this source says it cannot answer
7987/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7988/// nothing GitHub could search for, and keeps the board read it always had.
7989fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7990    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7991                       letter or digit with a bounded query";
7992    if let Some(text) = &query.text
7993        && !text.terms.trim().is_empty()
7994        && !has_words(&text.terms)
7995    {
7996        return Err(SourceError::Refused {
7997            message: format!(
7998                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7999                text.terms
8000            ),
8001        });
8002    }
8003    if let Some(wanted) = query
8004        .metadata
8005        .iter()
8006        .find(|wanted| !has_words(wanted.value()))
8007    {
8008        return Err(SourceError::Refused {
8009            message: format!(
8010                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8011                wanted.value(),
8012                std::iter::once(wanted.key())
8013                    .chain(wanted.path().iter().map(String::as_str))
8014                    .collect::<Vec<_>>()
8015                    .join("/"),
8016            ),
8017        });
8018    }
8019    Ok(())
8020}
8021
8022/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8023fn has_words(phrase: &str) -> bool {
8024    phrase.chars().any(char::is_alphanumeric)
8025}
8026
8027/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8028///
8029/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8030/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8031/// which GitHub's word match would read as different words.
8032fn as_stored(value: &str) -> String {
8033    let encoded = Value::String(value.to_owned()).to_string();
8034    encoded[1..encoded.len() - 1].to_owned()
8035}
8036
8037/// The one narrower question a task query carrying a text, metadata or origin predicate is
8038/// sent as.
8039enum Narrowing {
8040    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8041    Origin(String),
8042    /// The board-scoped issue search narrowed by these qualifiers.
8043    Search(String),
8044}
8045
8046impl Narrowing {
8047    /// What this question is remembered under for the length of one command.
8048    fn key(&self) -> String {
8049        match self {
8050            Self::Origin(origin) => format!("origin {origin}"),
8051            Self::Search(also) => format!("search {also}"),
8052        }
8053    }
8054}
8055
8056/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8057enum Resumed {
8058    /// It reported another page, which starts after this cursor.
8059    More(String),
8060    /// It has ended. Sending this cursor again — the page's own end when it had one, and
8061    /// otherwise the cursor it was reached from — answers an empty page, so the one document
8062    /// can go on walking the other connection.
8063    Ended(Option<String>),
8064}
8065
8066impl Resumed {
8067    /// Whether the connection has another page.
8068    const fn has_more(&self) -> bool {
8069        matches!(self, Self::More(_))
8070    }
8071
8072    /// The cursor to send this connection next.
8073    fn cursor(self) -> Option<String> {
8074        match self {
8075            Self::More(next) => Some(next),
8076            Self::Ended(last) => last,
8077        }
8078    }
8079}
8080
8081/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8082/// with no cursor to it, or from a cursor that does not advance.
8083fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8084    let info = connection
8085        .get("pageInfo")
8086        .ok_or_else(|| SourceError::Malformed {
8087            message: "GitHub connection has no pageInfo".into(),
8088        })?;
8089    let end = optional_str(info, "endCursor")?;
8090    if required_bool(info, "hasNextPage")? {
8091        let next = end.ok_or_else(|| SourceError::Malformed {
8092            message: "GitHub connection reports another page and no endCursor".into(),
8093        })?;
8094        validate_cursor_progress(after, next)?;
8095        return Ok(Resumed::More(next.to_owned()));
8096    }
8097    Ok(Resumed::Ended(
8098        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8099    ))
8100}
8101
8102/// The board, and every item on it this source reports.
8103#[derive(Clone)]
8104struct Board {
8105    id: String,
8106    fields: Value,
8107    items: Vec<Resolved>,
8108}
8109
8110/// What a write needs of the board and nothing more: its node id and its field
8111/// definitions, in the shape a read of the board's own `fields` gives them.
8112///
8113/// Deliberately no items. A write decides which item it writes, which parent it files
8114/// under and which far ends it names by reading each of them by its own id; this is the
8115/// half of the board those reads cannot carry, and holding no item is what keeps it from
8116/// ever being asked whether an item is there.
8117#[derive(Clone)]
8118struct BoardFields {
8119    id: BoardId,
8120    fields: Value,
8121}
8122
8123/// A board's node id: what a field write and `addProjectV2ItemById` address.
8124///
8125/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8126/// refused where it is read, and one an item names blank is read as not named at all.
8127#[derive(Clone)]
8128struct BoardId(String);
8129
8130/// Where one write left its item, for the record the rest of the command reads it out of.
8131///
8132/// A named record rather than a tuple because the update arm and the create arm each fill
8133/// all four, and two `Option`s of different meaning side by side in a tuple are two
8134/// positions a reader has to count.
8135struct Landed {
8136    /// The issue's own node id, which is the [`NativeId`] this source reports.
8137    content_id: NativeId,
8138    /// The board item's id, which is what a field write addresses.
8139    // 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.
8140    item_id: String,
8141    /// The web address GitHub gave the issue, when it gave one.
8142    // 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.
8143    url: Option<String>,
8144    /// The issue's number on its repository, when GitHub reported one.
8145    number: Option<u64>,
8146}
8147
8148impl BoardId {
8149    fn parse(id: &str) -> Result<Self, SourceError> {
8150        if id.trim().is_empty() {
8151            return Err(SourceError::Malformed {
8152                message: "GitHub named a board with a blank node id".into(),
8153            });
8154        }
8155        Ok(Self(id.to_owned()))
8156    }
8157
8158    fn as_str(&self) -> &str {
8159        &self.0
8160    }
8161}
8162
8163impl Board {
8164    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8165        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8166        let nodes = fields
8167            .get("nodes")
8168            .and_then(Value::as_array)
8169            .ok_or_else(|| SourceError::Malformed {
8170                message: "GitHub project fields.nodes is not an array".into(),
8171            })?;
8172        Ok(nodes
8173            .iter()
8174            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8175    }
8176}
8177
8178/// One board item, resolved into everything this source reports about it.
8179#[derive(Clone)]
8180struct Resolved {
8181    item_id: String,
8182    id: NativeId,
8183    content_kind: ContentKind,
8184    kind: BoardKind,
8185    title: String,
8186    body: Option<String>,
8187    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8188    /// that changes the slot alone has to keep byte for byte outside it.
8189    raw_body: Option<String>,
8190    status: Status,
8191    /// The name of the board `Status` option this item sits in, as the board spells it.
8192    option: Option<String>,
8193    /// What its `Priority` field says, read through this instance's mapping.
8194    priority: HeldPriority,
8195    /// Whether this item's issue is closed. A draft has no such state and is never closed.
8196    closed: bool,
8197    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8198    delivers: Vec<TaskRef>,
8199    /// Every task that delivers this one, read out of its slot. Empty for anything not a
8200    /// task.
8201    delivered_by: Vec<TaskRef>,
8202    labels: Vec<Label>,
8203    parent: Option<NativeId>,
8204    // 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.
8205    origin: Option<String>,
8206    /// The issue's own number on its repository, as GitHub reports it.
8207    ///
8208    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8209    /// declares none, and a draft is not filed in a repository to be numbered by one — and
8210    /// an issue this run created whose creating mutation answered without one, which is a
8211    /// response GitHub's own schema says cannot happen and which a landed write is not
8212    /// worth failing over. An `Issue` read off the board always has one.
8213    number: Option<u64>,
8214    url: Option<String>,
8215    created_at: Option<DateTime<Utc>>,
8216    updated_at: Option<DateTime<Utc>>,
8217    own_repository: Option<Repository>,
8218    repositories: Vec<Repository>,
8219    slot: BTreeMap<String, Value>,
8220    /// The node id of the board this item sits on, when the read that reached it said.
8221    board_id: Option<String>,
8222    /// The definition of every board field this item holds a value of, in the shape a read
8223    /// of the board's own `fields` gives one.
8224    ///
8225    /// Only the fields this item has a value in: a field it holds nothing of is not here,
8226    /// which says nothing about whether the board has it.
8227    fields: Vec<Value>,
8228    /// Every field the board this item sits on defines, as its own read of the board's
8229    /// `fields` gives them — when the read that reached the item carried them, which a read
8230    /// of it by its own id does. What a write of it needs of the board, then, needs no read
8231    /// of the board.
8232    board_fields: Option<Value>,
8233    /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8234    /// selects one — when the read that reached it carried the connection to its end, which a
8235    /// read of it by its own id does for any issue blocked by no more than a page. What a
8236    /// write reconciles that relationship against, and what a read of its forward edges in
8237    /// the same command answers with.
8238    blocked_by: Option<Vec<Value>>,
8239}
8240
8241impl Resolved {
8242    /// The board this item's own read names it on, when that read named one this source can
8243    /// address.
8244    fn named_board(&self) -> Option<BoardId> {
8245        self.board_id
8246            .as_deref()
8247            .and_then(|id| BoardId::parse(id).ok())
8248    }
8249
8250    /// The board's id and every field it defines, when the read that reached this item
8251    /// carried both — which a read of it by its own id does.
8252    fn carried_board(&self) -> Option<BoardFields> {
8253        Some(BoardFields {
8254            id: self.named_board()?,
8255            fields: self.board_fields.clone()?,
8256        })
8257    }
8258
8259    /// Whether this item holds a value of the board field called `name`, and so carries
8260    /// that field's definition. `false` says nothing about whether the board has the field.
8261    fn defines(&self, name: &str) -> bool {
8262        self.fields
8263            .iter()
8264            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8265    }
8266
8267    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8268    /// in a field of its own, and none of the five keys that are only an encoding.
8269    ///
8270    /// The two delivery keys are left out for every kind, not only for a task: they are
8271    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8272    /// document carrying one holds nothing a caller's own metadata could mean by it.
8273    fn metadata(&self) -> BTreeMap<String, Value> {
8274        let mut metadata = self.slot.clone();
8275        metadata.remove(Repository::METADATA_KEY);
8276        metadata.remove(DependencyEdge::RECORDED_KEY);
8277        metadata.remove(ItemKind::METADATA_KEY);
8278        metadata.remove(TaskRef::DELIVERS_KEY);
8279        metadata.remove(TaskRef::DELIVERED_BY_KEY);
8280        // The board field is the origin, and the body's copy of it is only a mirror for the
8281        // issue search to find: an item whose field holds none has none, whatever its body
8282        // says, so no reader ever sees two answers.
8283        metadata.remove(ORIGIN_KEY);
8284        if let Some(origin) = &self.origin {
8285            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8286        }
8287        metadata
8288    }
8289
8290    /// Where this item is, as a link a reader can open.
8291    ///
8292    /// A board is a hosted place and every issue on it has a web address, so that address
8293    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8294    /// of place it is, so a reader knows to open it rather than to read a file out. It
8295    /// does not replace or derive from `url`: the field goes on reporting exactly what it
8296    /// reported before, and this says what that address *is*.
8297    ///
8298    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8299    /// rather than a third variant, which is the contract's "the source did not say". An
8300    /// issue this run created is not one of those: its address comes back from the
8301    /// creating mutation, so it is somewhere a reader can open from the moment it exists
8302    /// rather than from whenever the board read catches up.
8303    fn location(&self) -> Option<Location> {
8304        self.url.clone().map(Location::Url)
8305    }
8306
8307    /// The short handle this board's backend shows people for a task: the issue's number
8308    /// alone, as a decimal string.
8309    ///
8310    /// The number alone rather than `owner/repo#1043`, because that is the contract's
8311    /// value for this backend. A draft has no number and so no handle, which is the
8312    /// contract's *absent* rather than a handle of some other shape — and the native
8313    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8314    /// derives from.
8315    fn key(&self) -> Option<String> {
8316        self.number.map(|number| number.to_string())
8317    }
8318
8319    /// Whether its `Priority` field holds a value at all, mapped or not.
8320    fn holds_priority(&self) -> bool {
8321        self.priority != HeldPriority::Read(Priority::None)
8322    }
8323
8324    /// The task this item is.
8325    ///
8326    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8327    /// reading that as a level would be a guess, and reading it as `none` would let the next
8328    /// copy clear a priority a person set.
8329    fn task(&self) -> Result<Task, SourceError> {
8330        let priority = match &self.priority {
8331            HeldPriority::Read(priority) => *priority,
8332            HeldPriority::Unmapped(option) => {
8333                return Err(SourceError::Malformed {
8334                    message: format!(
8335                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8336                         this source's priority_mapping does not name, so its priority cannot be \
8337                         read; next: name {option:?} under priority_mapping, or move the item to \
8338                         a mapped option",
8339                        self.id,
8340                        self.number
8341                            .map(|number| format!(" (#{number})"))
8342                            .unwrap_or_default()
8343                    ),
8344                });
8345            }
8346        };
8347        Ok(Task {
8348            id: self.id.clone(),
8349            key: self.key(),
8350            title: self.title.clone(),
8351            content: self.body.clone(),
8352            status: self.status.clone(),
8353            priority,
8354            labels: self.labels.clone(),
8355            project: self.parent.clone(),
8356            url: self.url.clone(),
8357            location: self.location(),
8358            created_at: self.created_at,
8359            updated_at: self.updated_at,
8360            metadata: self.metadata(),
8361            repositories: self.repositories.clone(),
8362            delivers: self.delivers.clone(),
8363            delivered_by: self.delivered_by.clone(),
8364        })
8365    }
8366
8367    fn project(&self) -> Project {
8368        Project {
8369            id: self.id.clone(),
8370            title: self.title.clone(),
8371            content: self.body.clone(),
8372            status: self.status.clone(),
8373            labels: self.labels.clone(),
8374            url: self.url.clone(),
8375            location: self.location(),
8376            created_at: self.created_at,
8377            updated_at: self.updated_at,
8378            metadata: self.metadata(),
8379            repositories: self.repositories.clone(),
8380        }
8381    }
8382
8383    /// The same issue as a document: the project it is filed under, and no status and no
8384    /// dependencies, because a document is not work.
8385    fn document(&self) -> Document {
8386        Document {
8387            id: self.id.clone(),
8388            title: self.title.clone(),
8389            content: self.body.clone(),
8390            project: self.parent.clone(),
8391            labels: self.labels.clone(),
8392            url: self.url.clone(),
8393            location: self.location(),
8394            created_at: self.created_at,
8395            updated_at: self.updated_at,
8396            metadata: self.metadata(),
8397            repositories: self.repositories.clone(),
8398        }
8399    }
8400}
8401
8402/// Where one targeted update moves an item's status, and which of its two halves move.
8403struct StatusMove {
8404    /// The board the item's `Status` field is on.
8405    board: BoardId,
8406    /// The `Status` field's id.
8407    field: String,
8408    /// The option's id.
8409    option: String,
8410    /// The option's name, as the board spells it.
8411    name: String,
8412    /// What the status asks of the issue's state.
8413    target: StatusTarget,
8414    /// The status the item reads as once it is there.
8415    landed: Status,
8416    /// Which of the status's two halves differ from what the item holds.
8417    moves: Moves,
8418}
8419
8420/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8421/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8422/// and is not a value of this type.
8423#[derive(Clone, Copy, PartialEq, Eq)]
8424enum Moves {
8425    /// The option alone.
8426    Option,
8427    /// The issue's state alone: open, closed, or closed with another reason.
8428    State,
8429    /// Both.
8430    Both,
8431}
8432
8433impl Moves {
8434    /// What differs, or `None` when nothing does.
8435    const fn of(option: bool, state: bool) -> Option<Self> {
8436        match (option, state) {
8437            (true, true) => Some(Self::Both),
8438            (true, false) => Some(Self::Option),
8439            (false, true) => Some(Self::State),
8440            (false, false) => None,
8441        }
8442    }
8443
8444    /// Whether the option moves.
8445    const fn option(self) -> bool {
8446        matches!(self, Self::Option | Self::Both)
8447    }
8448
8449    /// Whether the issue's state moves.
8450    const fn state(self) -> bool {
8451        matches!(self, Self::State | Self::Both)
8452    }
8453}
8454
8455/// What one write is, and the status that comes with being it.
8456///
8457/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8458/// status and a task or a project always has one, so "a document carrying a status" and
8459/// "a task carrying none" are states a write cannot be in rather than states every use
8460/// site below has to defend against.
8461enum Written<'a> {
8462    /// A document, which is not work and so has no status at all.
8463    Document,
8464    /// A task or a project, and the status it is being written with.
8465    Work(ItemKind, &'a Status),
8466}
8467
8468impl Written<'_> {
8469    /// Which of the board's three kinds this write is.
8470    const fn kind(&self) -> BoardKind {
8471        match self {
8472            Self::Document => BoardKind::Document,
8473            Self::Work(kind, _) => BoardKind::Work(*kind),
8474        }
8475    }
8476
8477    /// The status this write carries. A document carries none, so a write of one says
8478    /// nothing about the issue's open or closed state and selects no board `Status`
8479    /// option.
8480    const fn status(&self) -> Option<&Status> {
8481        match self {
8482            Self::Document => None,
8483            Self::Work(_, status) => Some(status),
8484        }
8485    }
8486}
8487
8488/// The item being written, in the one shape all three write methods reach.
8489struct Incoming<'a> {
8490    written: Written<'a>,
8491    /// The title a person wrote. A document's goes onto the issue with
8492    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8493    title: &'a str,
8494    content: Option<&'a str>,
8495    labels: &'a [Label],
8496    metadata: &'a BTreeMap<String, Value>,
8497    repositories: &'a [Repository],
8498    parent: Option<&'a NativeId>,
8499    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8500    /// what keeps either key out of their slot.
8501    delivers: &'a [TaskRef],
8502    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8503    delivered_by: &'a [TaskRef],
8504    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8505    /// project, a document, and every write to an instance with no `priority_mapping` —
8506    /// which is what keeps such a write's requests exactly what they were before.
8507    priority: Option<Priority>,
8508}
8509
8510/// What one write does to an item's `Priority` field.
8511enum PriorityWrite {
8512    /// Select this option of this field.
8513    Select {
8514        /// The `Priority` field's id.
8515        field: String,
8516        /// The mapped option's id.
8517        option: String,
8518    },
8519    /// Clear the field's value, which is what `none` is.
8520    Clear {
8521        /// The `Priority` field's id.
8522        field: String,
8523    },
8524}
8525
8526impl Incoming<'_> {
8527    /// The title this write puts on the issue.
8528    fn written_title(&self) -> String {
8529        match self.written {
8530            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8531            Written::Work(..) => self.title.to_owned(),
8532        }
8533    }
8534}
8535
8536#[derive(Clone, Copy, PartialEq, Eq)]
8537enum ContentKind {
8538    DraftIssue,
8539    Issue,
8540}
8541
8542/// What one board issue is: a document, or the work an [`ItemKind`] names.
8543///
8544/// A type of this source's own rather than an `ItemKind` with a third variant, because
8545/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8546/// document — the contract keeps a document out of that enum deliberately. Holding the
8547/// board's three answers in one value is what makes every place that asks "which is this?"
8548/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8549/// two thirds of the board.
8550#[derive(Clone, Copy, PartialEq, Eq)]
8551enum BoardKind {
8552    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8553    Document,
8554    /// Every other issue, and every draft.
8555    Work(ItemKind),
8556}
8557
8558impl BoardKind {
8559    /// How a refusal names this kind to the person reading it.
8560    const fn describes(self) -> &'static str {
8561        match self {
8562            Self::Document => "document",
8563            Self::Work(kind) => kind.marker(),
8564        }
8565    }
8566}
8567
8568/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8569///
8570/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8571/// the shared cross-source journeys assert one answer to one question, so two sources
8572/// that disagree about what "carries the label bug" means fail them.
8573fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8574    let holds = |name: &String| {
8575        labels
8576            .iter()
8577            .any(|label| label.name.eq_ignore_ascii_case(name))
8578    };
8579    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8580        && filter.all_of.iter().all(holds)
8581        && !filter.none_of.iter().any(holds)
8582}
8583
8584/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8585/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8586fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8587    statuses.is_empty() || statuses.contains(&category)
8588}
8589
8590/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8591///
8592/// `content` is the item's own prose — the body with this source's trailing metadata
8593/// comment already taken off — so a search never matches an encoding the author of the
8594/// issue never wrote.
8595fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8596    let terms = query.terms.to_lowercase();
8597    let in_title = title.to_lowercase().contains(&terms);
8598    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8599    match query.fields {
8600        TextFields::Title => in_title,
8601        TextFields::Content => in_content,
8602        TextFields::TitleOrContent => in_title || in_content,
8603    }
8604}
8605
8606/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8607///
8608/// The project predicate is passed separately because a read narrowed to one project has
8609/// already answered it by asking *that project* for its own items — and re-applying it
8610/// there would compare the caller's selector, which may be a project's **name**, against
8611/// the id of the project that name resolved to, and keep nothing. Every other read passes
8612/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8613/// source really does apply.
8614fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8615    labels_match(&task.labels, &query.labels)
8616        && status_matches(task.status.category, &query.statuses)
8617        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8618        && match project {
8619            ProjectFilter::Any => true,
8620            ProjectFilter::Orphans => task.project.is_none(),
8621            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8622        }
8623        && query
8624            .text
8625            .as_ref()
8626            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8627        // Against the parsed metadata slot, and against the origin field, which is where
8628        // `Resolved::metadata` reads each of them from.
8629        && query.metadata_matches(&task.metadata)
8630        && query.origin_matches(&task.metadata)
8631}
8632
8633fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8634    labels_match(&project.labels, &query.labels)
8635        && status_matches(project.status.category, &query.statuses)
8636        && query
8637            .text
8638            .as_ref()
8639            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8640}
8641
8642/// The same three predicates a task query carries, minus the status filter.
8643///
8644/// A document is not work, so it has no status for one to compare against and the query
8645/// type carries none. The project predicate is the same one — a design issue filed under a
8646/// project issue is in that project, and one filed under nothing is in none — so it is
8647/// spelled the same way here rather than answered differently.
8648fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8649    labels_match(&document.labels, &query.labels)
8650        && match project {
8651            ProjectFilter::Any => true,
8652            ProjectFilter::Orphans => document.project.is_none(),
8653            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8654        }
8655        && query
8656            .text
8657            .as_ref()
8658            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8659}
8660
8661#[async_trait::async_trait]
8662impl TaskSource for GitHubProjectsSource {
8663    fn kind(&self) -> &'static str {
8664        KIND
8665    }
8666    fn capabilities(&self) -> Capabilities {
8667        Capabilities {
8668            projects: Support::Native,
8669            documents: Support::Native,
8670            comments: Support::Native,
8671            priority: if self.priorities.is_some() {
8672                Support::Native
8673            } else {
8674                Support::Unsupported
8675            },
8676            filter_by_priority: Support::Native,
8677            filter_by_comment_activity: Support::Native,
8678            filter_by_metadata: Support::Native,
8679            filter_by_origin: Support::Native,
8680            orphan_tasks: Support::Native,
8681            filter_by_label: Support::Native,
8682            filter_by_status: Support::Native,
8683            search_title: Support::Native,
8684            search_content: Support::Native,
8685            task_dependencies: DependencySupport::BothDirections,
8686            project_dependencies: DependencySupport::BothDirections,
8687            max_page_size: MAX_PAGE_SIZE,
8688        }
8689    }
8690    async fn health(&self) -> Result<Health, SourceError> {
8691        let board = self.board_page(None, 1).await?;
8692        Ok(Health {
8693            reachable: true,
8694            detail: Some(format!(
8695                "reading GitHub project {}/{} ({})",
8696                self.owner,
8697                self.project_number,
8698                required_str(&board, "title")?
8699            )),
8700        })
8701    }
8702    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8703        self.item_by_id(id)
8704            .await?
8705            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8706            .map(|item| item.task())
8707            .transpose()
8708    }
8709    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8710        Ok(self
8711            .item_by_id(id)
8712            .await?
8713            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8714            .map(|item| item.project()))
8715    }
8716    async fn query_tasks(
8717        &self,
8718        query: &TaskQuery,
8719        page: &PageRequest,
8720    ) -> Result<Page<Task>, SourceError> {
8721        validate_page(page)?;
8722        refuse_unsearchable(query)?;
8723        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8724            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8725                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8726                (Some(also), None) => Some(also),
8727                (None, Some(since)) => Some(updated_qualifier(since)),
8728                (None, None) => None,
8729            };
8730            if let Some(also) = qualifiers {
8731                return self.search_tasks(query, page, &also).await;
8732            }
8733        }
8734
8735        // A read narrowed to one project asks that project for its own tasks, so nothing
8736        // about it costs what the rest of the board holds. A read carrying a text, metadata
8737        // or origin predicate asks GitHub the narrower question those predicates are, and a
8738        // read narrowed to comment activity alone asks the board's own issue search for the
8739        // issues updated since, which is every issue a comment could have been written or
8740        // edited on since. Every other task read is a question about the whole board and is
8741        // answered by reading it.
8742        let (held, membership) = match (&query.project, query.commented_since) {
8743            (ProjectFilter::Is(project), _) => (
8744                self.project_children(project).await?,
8745                // Answered by where these items came from; see `task_matches`.
8746                &ProjectFilter::Any,
8747            ),
8748            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8749                match (self.narrowed(query).await?, since) {
8750                    (Some(narrowed), _) => (narrowed, &query.project),
8751                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8752                    (None, None) => (self.board().await?.items, &query.project),
8753                }
8754            }
8755        };
8756        // Filtered before paged: a page of a filtered result is a page of the survivors,
8757        // never the survivors of a page.
8758        let mut tasks = Vec::new();
8759        for item in held
8760            .iter()
8761            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8762        {
8763            let task = item.task()?;
8764            if task_matches(&task, query, membership)
8765                && self.commented_since(item, query.commented_since).await?
8766            {
8767                tasks.push(task);
8768            }
8769        }
8770        Ok(offset_page(
8771            tasks,
8772            numeric_cursor(page.cursor.as_ref())?,
8773            page.limit.min(MAX_PAGE_SIZE) as usize,
8774        ))
8775    }
8776    async fn query_projects(
8777        &self,
8778        query: &ProjectQuery,
8779        page: &PageRequest,
8780    ) -> Result<Page<Project>, SourceError> {
8781        validate_page(page)?;
8782        refuse_unsearchable_text(query.text.as_ref())?;
8783        // The projects a board holds are found by an issue search scoped to that board,
8784        // never by walking the board's own item connection: what tells a project from a
8785        // task is the `parent` each issue carries, which costs nothing to read. A query
8786        // carrying a text asks that search for the text too, so it reads the issues that
8787        // hold it rather than every issue of the board.
8788        let held = match self.text_searched(query.text.as_ref()).await? {
8789            Some(searched) => searched,
8790            None => self.board_issues().await?,
8791        };
8792        let projects = held
8793            .iter()
8794            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8795            .map(Resolved::project)
8796            .filter(|project| project_matches(project, query))
8797            .collect();
8798        Ok(offset_page(
8799            projects,
8800            numeric_cursor(page.cursor.as_ref())?,
8801            page.limit.min(MAX_PAGE_SIZE) as usize,
8802        ))
8803    }
8804    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8805        Ok(self
8806            .item_by_id(id)
8807            .await?
8808            .filter(|item| item.kind == BoardKind::Document)
8809            .map(|item| item.document()))
8810    }
8811    async fn query_documents(
8812        &self,
8813        query: &DocumentQuery,
8814        page: &PageRequest,
8815    ) -> Result<Page<Document>, SourceError> {
8816        validate_page(page)?;
8817        // Narrowed to one project, this is the same sub-issue read a task list scoped to
8818        // that project makes — a document filed under a project is a sub-issue of it too,
8819        // and which of them come back is the kind this caller asked for. Unscoped, a query
8820        // carrying a text asks the board-scoped issue search for it, as a task query does,
8821        // and only one carrying none reads the board.
8822        let (held, membership) = match &query.project {
8823            ProjectFilter::Is(project) => (
8824                self.project_children(project).await?,
8825                // Answered by where these items came from; see `task_matches`.
8826                &ProjectFilter::Any,
8827            ),
8828            ProjectFilter::Any | ProjectFilter::Orphans => {
8829                refuse_unsearchable_text(query.text.as_ref())?;
8830                match self.text_searched(query.text.as_ref()).await? {
8831                    Some(searched) => (searched, &query.project),
8832                    None => (self.board().await?.items, &query.project),
8833                }
8834            }
8835        };
8836        // Filtered before paged, exactly as a task read is: a page of a filtered result is
8837        // a page of the survivors, never the survivors of a page.
8838        let documents = held
8839            .iter()
8840            .filter(|item| item.kind == BoardKind::Document)
8841            .map(Resolved::document)
8842            .filter(|document| document_matches(document, query, membership))
8843            .collect();
8844        Ok(offset_page(
8845            documents,
8846            numeric_cursor(page.cursor.as_ref())?,
8847            page.limit.min(MAX_PAGE_SIZE) as usize,
8848        ))
8849    }
8850    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8851        validate_page(page)?;
8852        let offset = numeric_cursor(page.cursor.as_ref())?;
8853        let mut labels = self
8854            .board()
8855            .await?
8856            .items
8857            .into_iter()
8858            .flat_map(|item| item.labels)
8859            .fold(Vec::new(), |mut all, label| {
8860                if !all.iter().any(|x: &Label| x.id == label.id) {
8861                    all.push(label);
8862                }
8863                all
8864            });
8865        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8866        Ok(offset_page(
8867            labels,
8868            offset,
8869            page.limit.min(MAX_PAGE_SIZE) as usize,
8870        ))
8871    }
8872    async fn task_dependencies(
8873        &self,
8874        id: &NativeId,
8875        direction: Direction,
8876        page: &PageRequest,
8877    ) -> Result<Page<DependencyEdge>, SourceError> {
8878        self.dependencies(id, ItemKind::Task, direction, page).await
8879    }
8880    async fn project_dependencies(
8881        &self,
8882        id: &NativeId,
8883        direction: Direction,
8884        page: &PageRequest,
8885    ) -> Result<Page<DependencyEdge>, SourceError> {
8886        self.dependencies(id, ItemKind::Project, direction, page)
8887            .await
8888    }
8889
8890    fn writes(&self) -> WriteSupport {
8891        WriteSupport::Supported
8892    }
8893
8894    /// Create or update one task.
8895    ///
8896    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8897    /// neither may name the task itself or name one task twice — and land in the body's
8898    /// metadata slot under their reserved keys, in place of any caller metadata of those
8899    /// names.
8900    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8901        let near = write.target.as_ref().unwrap_or(&write.item.id);
8902        for (key, entries) in [
8903            (TaskRef::DELIVERS_KEY, &write.item.delivers),
8904            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8905        ] {
8906            TaskRef::listed(key, near, Some(&self.name), entries.clone())
8907                .map_err(|message| SourceError::Refused { message })?;
8908        }
8909        if self.priorities.is_none() && write.item.priority != Priority::None {
8910            return Err(self.holds_no_priority());
8911        }
8912        self.write_item(
8913            &Incoming {
8914                written: Written::Work(ItemKind::Task, &write.item.status),
8915                title: &write.item.title,
8916                content: write.item.content.as_deref(),
8917                labels: &write.item.labels,
8918                metadata: &write.item.metadata,
8919                repositories: &write.item.repositories,
8920                parent: write.item.project.as_ref(),
8921                delivers: &write.item.delivers,
8922                delivered_by: &write.item.delivered_by,
8923                priority: self.priorities.as_ref().map(|_| write.item.priority),
8924            },
8925            write.target.as_ref(),
8926            &write.depends_on,
8927        )
8928        .await
8929    }
8930
8931    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8932        self.write_item(
8933            &Incoming {
8934                written: Written::Work(ItemKind::Project, &write.item.status),
8935                title: &write.item.title,
8936                content: write.item.content.as_deref(),
8937                labels: &write.item.labels,
8938                metadata: &write.item.metadata,
8939                repositories: &write.item.repositories,
8940                parent: None,
8941                delivers: &[],
8942                delivered_by: &[],
8943                priority: None,
8944            },
8945            write.target.as_ref(),
8946            &write.depends_on,
8947        )
8948        .await
8949    }
8950
8951    /// Create or update one document, which is one issue titled the way this board spells
8952    /// a document.
8953    ///
8954    /// Everything else is exactly a task write: caller metadata goes to the same canonical
8955    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8956    /// or a field this board cannot carry is refused by name rather than dropped, a target
8957    /// naming an issue this board does not hold is refused rather than created, and an
8958    /// issue this call created is taken back when the rest of the write fails.
8959    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8960        // A document takes part in no dependency graph, so there is no far end to write
8961        // natively and none to record: a caller naming one is told so rather than having it
8962        // stored under the reserved key, where a later read would report an edge the
8963        // contract says cannot exist.
8964        if !write.depends_on.is_empty() {
8965            return Err(SourceError::Refused {
8966                message: format!(
8967                    "this write names {} dependencies for a document, and a document takes \
8968                     part in no dependency graph; next: put the dependency on the task or \
8969                     project the document is about",
8970                    write.depends_on.len()
8971                ),
8972            });
8973        }
8974        self.write_item(
8975            &Incoming {
8976                written: Written::Document,
8977                title: &write.item.title,
8978                content: write.item.content.as_deref(),
8979                labels: &write.item.labels,
8980                metadata: &write.item.metadata,
8981                repositories: &write.item.repositories,
8982                parent: write.item.project.as_ref(),
8983                delivers: &[],
8984                delivered_by: &[],
8985                priority: None,
8986            },
8987            write.target.as_ref(),
8988            &[],
8989        )
8990        .await
8991    }
8992
8993    /// Set one task's status alone.
8994    ///
8995    /// An open target reopens a closed issue with an `updateIssue` carrying only its
8996    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8997    /// terminal target selects its mapped option, then closes with its fixed reason. No
8998    /// request carries a title, a body or a label. The status
8999    /// answered is what [`StatusMapping::status`] reads off the state just written, which is
9000    /// what a re-read reports.
9001    async fn set_task_status(
9002        &self,
9003        id: &NativeId,
9004        category: StatusCategory,
9005    ) -> Result<Option<Status>, SourceError> {
9006        self.set_status(id, category).await
9007    }
9008
9009    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9010    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9011    /// for `none`. Refused by an instance with no `priority_mapping`.
9012    async fn set_task_priority(
9013        &self,
9014        id: &NativeId,
9015        priority: Priority,
9016    ) -> Result<Option<Priority>, SourceError> {
9017        self.set_priority(id, priority).await
9018    }
9019
9020    /// Replace one task's content with a single body update that keeps the metadata slot
9021    /// byte for byte.
9022    async fn set_task_content(
9023        &self,
9024        id: &NativeId,
9025        content: &str,
9026    ) -> Result<Option<()>, SourceError> {
9027        self.replace_content(id, content).await
9028    }
9029
9030    /// Replace one task issue's content and its provenance slot entry with a single body
9031    /// update. The answers are not kept: see `replace_rendering`.
9032    async fn set_task_rendering(
9033        &self,
9034        id: &NativeId,
9035        content: &str,
9036        provenance: &Value,
9037        _answers: &BTreeMap<String, Value>,
9038    ) -> Result<Option<()>, SourceError> {
9039        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9040            .await
9041    }
9042
9043    /// Replace one design-document issue's content and its provenance slot entry, on exactly
9044    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9045    async fn set_document_rendering(
9046        &self,
9047        id: &NativeId,
9048        content: &str,
9049        provenance: &Value,
9050        _answers: &BTreeMap<String, Value>,
9051    ) -> Result<Option<()>, SourceError> {
9052        self.replace_rendering(id, BoardKind::Document, content, provenance)
9053            .await
9054    }
9055
9056    /// Apply a targeted update with one read of the item and a write only for what differs:
9057    /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9058    /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9059    async fn update_task(
9060        &self,
9061        id: &NativeId,
9062        update: &TaskUpdate,
9063    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9064        self.targeted_update(id, update).await
9065    }
9066
9067    /// Replace one task's `delivered_by` with a single body update that changes the
9068    /// metadata slot and nothing outside it.
9069    async fn set_delivered_by(
9070        &self,
9071        id: &NativeId,
9072        delivered_by: &[TaskRef],
9073    ) -> Result<Option<()>, SourceError> {
9074        self.replace_delivered_by(id, delivered_by).await
9075    }
9076
9077    /// Set one key of one task issue's metadata with a single body update that changes the
9078    /// metadata slot and nothing outside it — no title, label, state or board field request —
9079    /// and sends nothing when the task already holds that value under the key.
9080    async fn set_task_metadata(
9081        &self,
9082        id: &NativeId,
9083        key: &MetadataKey,
9084        value: &Value,
9085    ) -> Result<Option<Task>, SourceError> {
9086        Ok(self
9087            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9088            .await?
9089            .map(|item| item.task())
9090            .transpose()?)
9091    }
9092
9093    /// Set one key of one project issue's metadata, on exactly the terms of
9094    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9095    async fn set_project_metadata(
9096        &self,
9097        id: &NativeId,
9098        key: &MetadataKey,
9099        value: &Value,
9100    ) -> Result<Option<Project>, SourceError> {
9101        Ok(self
9102            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9103            .await?
9104            .map(|item| item.project()))
9105    }
9106
9107    /// Set one key of one design-document issue's metadata, on exactly the terms of
9108    /// [`set_task_metadata`](TaskSource::set_task_metadata).
9109    async fn set_document_metadata(
9110        &self,
9111        id: &NativeId,
9112        key: &MetadataKey,
9113        value: &Value,
9114    ) -> Result<Option<Document>, SourceError> {
9115        Ok(self
9116            .set_slot_key(id, BoardKind::Document, key, value)
9117            .await?
9118            .map(|item| item.document()))
9119    }
9120
9121    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9122        self.delete_item(id).await
9123    }
9124
9125    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9126        self.delete_item(id).await
9127    }
9128
9129    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9130        self.delete_item(id).await
9131    }
9132
9133    /// One page of the task issue's own comments, walked by GitHub's own cursor.
9134    ///
9135    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9136    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9137    ///
9138    /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9139    /// board is the read of its comments. A draft this process already resolved is refused
9140    /// without one.
9141    async fn task_comments(
9142        &self,
9143        task: &NativeId,
9144        page: &PageRequest,
9145    ) -> Result<Option<Page<Comment>>, SourceError> {
9146        validate_page(page)?;
9147        let cached = self.resolved_cache()?.get(task).cloned();
9148        if let Some(item) = cached {
9149            if item.kind != BoardKind::Work(ItemKind::Task) {
9150                return Ok(None);
9151            }
9152            if item.content_kind == ContentKind::DraftIssue {
9153                return Err(self.draft_has_no_comments(task));
9154            }
9155        }
9156        match self.issue_detail(task, page).await? {
9157            Some(TaskDetailRead {
9158                comments: Some(comments),
9159                ..
9160            }) => comments,
9161            _ => Ok(None),
9162        }
9163    }
9164
9165    /// Every id's task, with the first page of its comments when `comments` names it:
9166    /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9167    /// comments in one [`graphql::ISSUE_DETAIL`] request.
9168    async fn get_task_details(
9169        &self,
9170        ids: &[NativeId],
9171        comments: Option<&PageRequest>,
9172    ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9173        if let Some(page) = comments
9174            && let Err(error) = validate_page(page)
9175        {
9176            return ids.iter().map(|_| Err(error.clone())).collect();
9177        }
9178        match (ids, comments) {
9179            ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9180            ([id], None) => vec![self.task_read(id).await],
9181            _ => self.issue_details(ids, comments).await,
9182        }
9183    }
9184
9185    /// Add one comment to the task's issue, as the account the token belongs to.
9186    ///
9187    /// The author is refused before anything is sent — not even the task is read — because
9188    /// no answer GitHub could give would make posting under another name than the one asked
9189    /// for the right outcome.
9190    async fn add_comment(
9191        &self,
9192        task: &NativeId,
9193        comment: &NewComment,
9194    ) -> Result<Option<Comment>, SourceError> {
9195        if let Some(author) = &comment.author {
9196            return Err(SourceError::Refused {
9197                message: format!(
9198                    "source {} cannot post a comment as {author:?}: GitHub records the account \
9199                     the token signs in as the author of every comment; next: leave --author \
9200                     out, and the comment is posted as that account",
9201                    self.name
9202                ),
9203            });
9204        }
9205        let Some(issue) = self.commented_issue(task).await? else {
9206            return Ok(None);
9207        };
9208        let data = self
9209            .graphql(
9210                graphql::ADD_COMMENT,
9211                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9212            )
9213            .await?;
9214        let subject = data
9215            .pointer("/addComment/subject")
9216            .filter(|value| !value.is_null())
9217            .ok_or_else(|| SourceError::Malformed {
9218                message: "GitHub comment addition returned no subject".into(),
9219            })?;
9220        if required_str(subject, "id")? != issue.0 {
9221            return Err(SourceError::Malformed {
9222                message: "GitHub comment addition answered about another issue".into(),
9223            });
9224        }
9225        let added = data
9226            .pointer("/addComment/commentEdge/node")
9227            .filter(|value| !value.is_null())
9228            .ok_or_else(|| SourceError::Malformed {
9229                message: "GitHub comment addition returned no comment".into(),
9230            })?;
9231        comment_from(added).map(Some)
9232    }
9233
9234    async fn edit_comment(
9235        &self,
9236        task: &NativeId,
9237        comment: &NativeId,
9238        body: &CommentBody,
9239    ) -> Result<Option<Comment>, SourceError> {
9240        let Some(issue) = self.commented_issue(task).await? else {
9241            return Ok(None);
9242        };
9243        if !self.comment_is_on(&issue, comment).await? {
9244            return Ok(None);
9245        }
9246        let data = self
9247            .graphql(
9248                graphql::UPDATE_COMMENT,
9249                json!({"input":{"id":comment.0,"body":body.as_str()}}),
9250            )
9251            .await?;
9252        let edited = data
9253            .pointer("/updateIssueComment/issueComment")
9254            .filter(|value| !value.is_null())
9255            .ok_or_else(|| SourceError::Malformed {
9256                message: "GitHub comment update returned no comment".into(),
9257            })?;
9258        let edited = comment_from(edited)?;
9259        if edited.id != *comment {
9260            return Err(SourceError::Malformed {
9261                message: "GitHub comment update returned the wrong comment".into(),
9262            });
9263        }
9264        Ok(Some(edited))
9265    }
9266
9267    async fn delete_comment(
9268        &self,
9269        task: &NativeId,
9270        comment: &NativeId,
9271    ) -> Result<Option<NativeId>, SourceError> {
9272        let Some(issue) = self.commented_issue(task).await? else {
9273            return Ok(None);
9274        };
9275        if !self.comment_is_on(&issue, comment).await? {
9276            return Ok(None);
9277        }
9278        let data = self
9279            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9280            .await?;
9281        // The payload says nothing about the comment it removed, so what is checked is that
9282        // GitHub answered the mutation at all rather than leaving it unanswered.
9283        data.get("deleteIssueComment")
9284            .filter(|value| !value.is_null())
9285            .ok_or_else(|| SourceError::Malformed {
9286                message: "GitHub comment deletion returned no payload".into(),
9287            })?;
9288        Ok(Some(comment.clone()))
9289    }
9290
9291    /// Every request this source has recorded, and what each of GitHub's two budgets was
9292    /// attributed — read off the same accounting the session report is rendered from, so
9293    /// the two cannot count one request two ways.
9294    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9295        Ok(Some(self.ledger.snapshot().metering()))
9296    }
9297}
9298
9299/// One issue comment as the contract carries it.
9300///
9301/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9302/// and when it answers an actor with no login, because either way the source did not say who
9303/// wrote it — which is what an absent author means, rather than an author called nothing.
9304fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9305    Ok(Comment {
9306        id: NativeId(required_str(value, "id")?.to_owned()),
9307        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9308            .map(str::to_owned),
9309        created_at: optional_time(value, "createdAt")?,
9310        updated_at: optional_time(value, "updatedAt")?,
9311        body: required_str(value, "body")?.to_owned(),
9312        url: optional_str(value, "url")?.map(str::to_owned),
9313    })
9314}
9315
9316/// The page of comments one issue node carries, resumed from `after`.
9317fn comment_page(
9318    node: &Value,
9319    issue: &str,
9320    after: Option<&str>,
9321) -> Result<Page<Comment>, SourceError> {
9322    let connection = node
9323        .get("comments")
9324        .filter(|value| !value.is_null())
9325        .ok_or_else(|| SourceError::Malformed {
9326            message: format!("GitHub issue {issue} answered with no comments connection"),
9327        })?;
9328    let items = optional_nodes(Some(connection), "issue comments")?
9329        .into_iter()
9330        .flatten()
9331        .map(comment_from)
9332        .collect::<Result<Vec<_>, _>>()?;
9333    let next = next_cursor(connection)?;
9334    if let Some(next) = &next {
9335        validate_cursor_progress(after, &next.0)?;
9336    }
9337    Ok(Page { items, next })
9338}
9339
9340/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9341/// end — `None` when it carried none, or a page with more past it.
9342fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9343    let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9344        return Ok(None);
9345    };
9346    if next_cursor(connection)?.is_some() {
9347        return Ok(None);
9348    }
9349    Ok(Some(
9350        optional_nodes(Some(connection), "blocked-by issues")?
9351            .into_iter()
9352            .flatten()
9353            .cloned()
9354            .collect(),
9355    ))
9356}
9357
9358/// Where the recorded tail of a dependency walk resumes; see
9359/// [`GitHubProjectsSource::recorded_edges`].
9360const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9361
9362/// The board text field this source keeps a copy's origin in.
9363///
9364/// Named after the key it holds, and held to that name by the guard below rather than by
9365/// a reader noticing.
9366const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9367
9368/// The metadata key that field holds.
9369///
9370/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9371/// constructs or interprets the qualified id it carries. This source names it only to
9372/// route it — a short, typed value belongs in a typed field rather than in the body slot
9373/// a caller's own prose shares.
9374///
9375/// Restated rather than imported, because no plugin crate may depend on the engine. What
9376/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9377/// target in `check`: it reads the engine's own literal and fails naming the file and the
9378/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9379/// that creates a second item every run instead of finding the one it wrote — and that is
9380/// too late to learn it.
9381const ORIGIN_KEY: &str = "onetaskgraph.origin";
9382
9383/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9384///
9385/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9386/// is derived from the far end, never written down on the near item — so only a forward
9387/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9388/// it did not come from, and it is told so rather than answered with an empty page that
9389/// reads as a walk which ended.
9390fn recorded_offset(
9391    cursor: Option<&str>,
9392    direction: Direction,
9393) -> Result<Option<usize>, SourceError> {
9394    cursor
9395        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9396        .map(|offset| {
9397            if direction != Direction::DependsOn {
9398                return Err(SourceError::Config {
9399                    message: format!(
9400                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9401                         reverse dependency read never issues; resume it in the direction \
9402                         that reported it"
9403                    ),
9404                });
9405            }
9406            offset.parse().map_err(|_| SourceError::Config {
9407                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9408            })
9409        })
9410        .transpose()
9411}
9412
9413fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9414    let mut page = offset_page(edges, offset, limit.max(1));
9415    page.next = page
9416        .next
9417        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9418    page
9419}
9420
9421/// The kind of one issue reached through a dependency connection.
9422///
9423/// The same questions the board scan asks, over the fields the dependency document
9424/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9425/// then anything with sub-issues or the marker is a project.
9426///
9427/// # Errors
9428///
9429/// A far end this board holds as a document is refused rather than reported. The two
9430/// answers that are not refusals would both be wrong: reporting it as a task names an id
9431/// no task read of this source can find, and reporting it as a project names one no
9432/// project read can. There is no third value to return — `ItemKind` has no document
9433/// variant, because nothing may point at a document — so the relationship itself is what
9434/// the person is told about.
9435fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9436    let id = required_str(value, "id")?;
9437    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9438        return Err(SourceError::Refused {
9439            message: format!(
9440                "GitHub issue {id} is a document of this board — its title begins \
9441                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9442                 on by one; next: remove that issue's blocking relationship on this board"
9443            ),
9444        });
9445    }
9446    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9447    if parent.is_some() {
9448        return Ok(ItemKind::Task);
9449    }
9450    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9451    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9452        message: format!("GitHub issue {id}: {message}"),
9453    })?;
9454    let sub_issues = sub_issue_total(value)?;
9455    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9456        ItemKind::Project
9457    } else {
9458        ItemKind::Task
9459    })
9460}
9461
9462/// The `IssueStateUpdateInput` one status target asks for.
9463///
9464/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9465/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9466/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9467/// would report a change forever. A document has no status at all, and asks for neither.
9468fn state_input(target: Option<&StatusTarget>) -> Value {
9469    match target {
9470        Some(StatusTarget::Terminal(_, reason)) => {
9471            json!({"value":"CLOSED","stateReason":reason.reason()})
9472        }
9473        Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
9474        // A document has no status, so a write of one says nothing about the issue's open
9475        // or closed state rather than forcing it open: `stateInput` is what carries that
9476        // instruction, and an explicit null asks for no change to it.
9477        None => Value::Null,
9478    }
9479}
9480
9481/// The metadata one write stores in the item's body slot.
9482///
9483/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9484/// rather than carried: the kind marker so an empty project stays readable, the
9485/// repository list only when it is not exactly the issue's own repository, and the far
9486/// ends no relationship here can name.
9487///
9488/// The copy origin is the one typed field that is also mirrored here, and only as a
9489/// mirror: it lands in the board's origin field as well, which stays the one every reader
9490/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9491/// and catches up with a write in seconds rather than minutes — can find the item by it.
9492/// A reader of the release before this one drops the slot's copy and reads the field, so an
9493/// item written here still reads with exactly one origin there.
9494fn slot_metadata(
9495    incoming: &Incoming<'_>,
9496    own_repository: Option<&Repository>,
9497    fallback: &[DependencyEdge],
9498) -> BTreeMap<String, Value> {
9499    let mut metadata = incoming.metadata.clone();
9500    match metadata.remove(ORIGIN_KEY) {
9501        Some(Value::String(origin)) if !origin.is_empty() => {
9502            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9503        }
9504        _ => {}
9505    }
9506    match incoming.written.kind() {
9507        BoardKind::Work(kind) => metadata.insert(
9508            ItemKind::METADATA_KEY.to_owned(),
9509            Value::String(kind.marker().to_owned()),
9510        ),
9511        // A document is told by its title, so it carries no kind marker: that key names
9512        // what a dependency endpoint points at, and nothing may point at a document.
9513        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9514    };
9515    let derivable = own_repository
9516        .map(|own| incoming.repositories == [own.clone()])
9517        .unwrap_or(incoming.repositories.is_empty());
9518    if derivable {
9519        metadata.remove(Repository::METADATA_KEY);
9520    } else {
9521        metadata.insert(
9522            Repository::METADATA_KEY.to_owned(),
9523            Value::Array(
9524                incoming
9525                    .repositories
9526                    .iter()
9527                    .map(|repository| Value::String(repository.as_str().to_owned()))
9528                    .collect(),
9529            ),
9530        );
9531    }
9532    // The typed lists are what land, whatever the caller's own metadata held under their
9533    // keys: a key of either name travelling beside the field would otherwise be a second
9534    // answer to the same question, and the field is the one the contract names.
9535    for (key, entries) in [
9536        (TaskRef::DELIVERS_KEY, incoming.delivers),
9537        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9538    ] {
9539        set_task_list(&mut metadata, key, entries);
9540    }
9541    record_edges(&mut metadata, fallback);
9542    metadata
9543}
9544
9545/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9546/// one slot's metadata, or no such key when there are none.
9547fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9548    if fallback.is_empty() {
9549        metadata.remove(DependencyEdge::RECORDED_KEY);
9550    } else {
9551        metadata.insert(
9552            DependencyEdge::RECORDED_KEY.to_owned(),
9553            Value::Array(
9554                fallback
9555                    .iter()
9556                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9557                    .collect(),
9558            ),
9559        );
9560    }
9561}
9562
9563/// Every label one item carries, from its content's own connection and nowhere else.
9564///
9565/// There is no second place to read one from: no document this source sends selects the
9566/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9567/// cannot carry one at all. The module documentation records the three schema facts that
9568/// settle it.
9569fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9570    optional_nodes(content.get("labels"), "content labels")?
9571        .into_iter()
9572        .flatten()
9573        .map(|v| {
9574            Ok(Label {
9575                id: NativeId(required_str(v, "id")?.to_owned()),
9576                name: required_str(v, "name")?.to_owned(),
9577                color: optional_str(v, "color")?.map(str::to_owned),
9578            })
9579        })
9580        .collect()
9581}
9582
9583/// The definition of each board field one item's values are values of, in the shape a read
9584/// of the board's own `fields` gives one.
9585///
9586/// A value names its field through a fragment on that field's own type, so the type is
9587/// known from which kind of value it is: a single-select value's field is a
9588/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9589/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9590fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9591    field_values
9592        .iter()
9593        .filter_map(|value| {
9594            let field = value.get("field")?.as_object()?;
9595            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9596            let typename = if value.get("text").is_some() {
9597                "ProjectV2Field"
9598            } else if value.get("name").is_some() {
9599                "ProjectV2SingleSelectField"
9600            } else {
9601                return None;
9602            };
9603            let mut defined = field.clone();
9604            defined.insert("__typename".to_owned(), json!(typename));
9605            Some(Value::Object(defined))
9606        })
9607        .collect()
9608}
9609
9610fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9611    let Some(node) = field_values
9612        .iter()
9613        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9614    else {
9615        return Ok(None);
9616    };
9617    Ok(optional_str(node, "text")?.map(str::to_owned))
9618}
9619
9620fn valid_github_owner(owner: &str) -> bool {
9621    !owner.is_empty()
9622        && owner.len() <= 39
9623        && !owner.starts_with('-')
9624        && !owner.ends_with('-')
9625        && !owner.contains("--")
9626        && owner
9627            .bytes()
9628            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9629}
9630
9631/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9632/// neither of the two names a path segment already means.
9633fn valid_github_repository_name(name: &str) -> bool {
9634    !name.is_empty()
9635        && name.len() <= 100
9636        && name != "."
9637        && name != ".."
9638        && name
9639            .bytes()
9640            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9641}
9642
9643fn valid_environment_name(name: &str) -> bool {
9644    let mut bytes = name.bytes();
9645    bytes
9646        .next()
9647        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9648        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9649}
9650
9651/// How many sub-issues one issue has.
9652///
9653/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9654/// absent or non-integer one is a response this source cannot read — and reading it as
9655/// zero would classify a project as a task, which is exactly the mistake the marker
9656/// exists to keep from happening quietly.
9657fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9658    let summary = issue
9659        .get("subIssuesSummary")
9660        .ok_or_else(|| SourceError::Malformed {
9661            message: "GitHub issue is missing subIssuesSummary".into(),
9662        })?;
9663    summary
9664        .get("total")
9665        .and_then(Value::as_u64)
9666        .ok_or_else(|| SourceError::Malformed {
9667            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9668        })
9669}
9670
9671/// One issue's own `number`.
9672///
9673/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9674/// an issue in this module asks for it. So a read of one that comes back without it, or
9675/// with something that is not an unsigned integer, is a response this source cannot read —
9676/// absence here is **not** "this issue has no number". A draft is the content that has
9677/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9678/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9679fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9680    issue
9681        .get("number")
9682        .and_then(Value::as_u64)
9683        .ok_or_else(|| SourceError::Malformed {
9684            message: "GitHub issue number is missing or is not an unsigned integer".into(),
9685        })
9686}
9687
9688/// The `number` a creating mutation answered with, and `None` when it answered without one;
9689/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9690fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9691    match created.get("number") {
9692        None | Some(Value::Null) => Ok(None),
9693        Some(value) => value
9694            .as_u64()
9695            .map(Some)
9696            .ok_or_else(|| SourceError::Malformed {
9697                message: "GitHub created issue number is not an unsigned integer".into(),
9698            }),
9699    }
9700}
9701
9702fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9703    value
9704        .get(field)
9705        .and_then(Value::as_str)
9706        .ok_or_else(|| SourceError::Malformed {
9707            message: format!("GitHub response is missing string field {field}"),
9708        })
9709}
9710
9711fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9712    let found = required_str(value, field)?;
9713    if found.trim().is_empty() {
9714        return Err(SourceError::Malformed {
9715            message: format!("GitHub response has blank string field {field}"),
9716        });
9717    }
9718    Ok(found)
9719}
9720
9721/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9722/// needs one — Linear spells them too, in its own description field.
9723///
9724/// Restated rather than shared, because a plugin crate depends on the contract crate and
9725/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9726/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9727/// source round-trips its own writes perfectly well under its own spelling.
9728const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9729const METADATA_CLOSE: &str = "\n-->";
9730
9731/// What the composer puts between a non-empty visible body and the slot, and the one thing
9732/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9733/// other trailing byte of the body comes back as it was written.
9734// 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.
9735const METADATA_SEPARATOR: &str = "\n\n";
9736
9737/// The visible body and the metadata slot at the end of it.
9738///
9739/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9740/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9741/// own content and is left alone. The visible body is everything before the slot less the
9742/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9743fn metadata_body(
9744    body: Option<String>,
9745) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9746    let Some(body) = body else {
9747        return Ok((None, BTreeMap::new()));
9748    };
9749    let Some(slot) = slot_span(&body)? else {
9750        return Ok((Some(body), BTreeMap::new()));
9751    };
9752    let metadata =
9753        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9754            SourceError::Malformed {
9755                message: format!(
9756                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9757                ),
9758            }
9759        })?;
9760    let before = &body[..slot.start];
9761    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9762    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9763}
9764
9765/// Where the metadata slot sits in one body, as byte offsets into it.
9766struct SlotSpan {
9767    /// Where [`METADATA_OPEN`] begins.
9768    start: usize,
9769    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9770    encoded_start: usize,
9771    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9772    encoded_end: usize,
9773    /// Just past [`METADATA_CLOSE`].
9774    end: usize,
9775}
9776
9777/// The slot at the very end of `body`, or `None` when it has none.
9778///
9779/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9780/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9781/// slot.
9782fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9783    let Some(start) = body.rfind(METADATA_OPEN) else {
9784        return Ok(None);
9785    };
9786    let encoded_start = start + METADATA_OPEN.len();
9787    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9788        return Err(SourceError::Malformed {
9789            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9790        });
9791    };
9792    let encoded_end = encoded_start + relative_end;
9793    let end = encoded_end + METADATA_CLOSE.len();
9794    if !body[end..].trim().is_empty() {
9795        return Ok(None);
9796    }
9797    Ok(Some(SlotSpan {
9798        start,
9799        encoded_start,
9800        encoded_end,
9801        end,
9802    }))
9803}
9804
9805/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9806/// slot as it was.
9807///
9808/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9809/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9810/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9811/// or alone in an empty body — and a body with no slot that is given no metadata is
9812/// returned as it is.
9813fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9814    let encoded = if metadata.is_empty() {
9815        None
9816    } else {
9817        Some(
9818            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9819                message: error.to_string(),
9820            })?,
9821        )
9822    };
9823    Ok(match (slot_span(body)?, encoded) {
9824        (Some(slot), Some(encoded)) => format!(
9825            "{}{encoded}{}",
9826            &body[..slot.encoded_start],
9827            &body[slot.encoded_end..]
9828        ),
9829        (Some(slot), None) => {
9830            let before = &body[..slot.start];
9831            format!(
9832                "{}{}",
9833                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
9834                &body[slot.end..]
9835            )
9836        }
9837        (None, None) => body.to_owned(),
9838        (None, Some(encoded)) if body.is_empty() => {
9839            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9840        }
9841        (None, Some(encoded)) => {
9842            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9843        }
9844    })
9845}
9846
9847/// `body` with everything before its metadata slot replaced by `content`, and the slot
9848/// itself kept byte for byte.
9849///
9850/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
9851/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
9852/// `content` is empty — so a read of the result reports `content` as the visible body and
9853/// the slot's metadata exactly as it was.
9854fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
9855    let Some(slot) = slot_span(body)? else {
9856        return Ok(content.to_owned());
9857    };
9858    let kept = &body[slot.start..];
9859    Ok(if content.is_empty() {
9860        kept.to_owned()
9861    } else {
9862        format!("{content}{METADATA_SEPARATOR}{kept}")
9863    })
9864}
9865
9866/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
9867fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
9868    if entries.is_empty() {
9869        metadata.remove(key);
9870    } else {
9871        metadata.insert(
9872            key.to_owned(),
9873            Value::Array(
9874                entries
9875                    .iter()
9876                    .map(|entry| Value::String(entry.as_str().to_owned()))
9877                    .collect(),
9878            ),
9879        );
9880    }
9881}
9882
9883fn compose_body(
9884    content: Option<&str>,
9885    metadata: &BTreeMap<String, Value>,
9886) -> Result<Option<String>, SourceError> {
9887    let visible = content.unwrap_or_default();
9888    if metadata.is_empty() {
9889        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9890    }
9891    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9892        message: error.to_string(),
9893    })?;
9894    Ok(Some(if visible.is_empty() {
9895        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9896    } else {
9897        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9898    }))
9899}
9900
9901fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9902    value
9903        .get(field)
9904        .and_then(Value::as_bool)
9905        .ok_or_else(|| SourceError::Malformed {
9906            message: format!("GitHub response is missing boolean field {field}"),
9907        })
9908}
9909fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9910    match value.get(field) {
9911        None | Some(Value::Null) => Ok(None),
9912        Some(value) => value
9913            .as_str()
9914            .map(Some)
9915            .ok_or_else(|| SourceError::Malformed {
9916                message: format!("GitHub response field {field} is not a string or null"),
9917            }),
9918    }
9919}
9920fn optional_nodes<'a>(
9921    connection: Option<&'a Value>,
9922    name: &str,
9923) -> Result<Option<&'a Vec<Value>>, SourceError> {
9924    match connection {
9925        None | Some(Value::Null) => Ok(None),
9926        Some(value) => value
9927            .get("nodes")
9928            .and_then(Value::as_array)
9929            .map(Some)
9930            .ok_or_else(|| SourceError::Malformed {
9931                message: format!("GitHub {name}.nodes is not an array"),
9932            }),
9933    }
9934}
9935fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9936    let page_info = connection
9937        .get("pageInfo")
9938        .ok_or_else(|| SourceError::Malformed {
9939            message: format!("GitHub {name} has no pageInfo"),
9940        })?;
9941    if required_bool(page_info, "hasNextPage")? {
9942        return Err(SourceError::Malformed {
9943            message: format!(
9944                "GitHub {name} exceeds the supported nested connection size of {size}"
9945            ),
9946        });
9947    }
9948    Ok(())
9949}
9950fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9951    optional_str(value, field)?
9952        .map(|timestamp| {
9953            timestamp.parse().map_err(|error| SourceError::Malformed {
9954                message: format!("GitHub response field {field} is not a timestamp: {error}"),
9955            })
9956        })
9957        .transpose()
9958}
9959fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9960    if page.limit == 0 {
9961        Err(SourceError::Config {
9962            message: "page limit must be at least 1".into(),
9963        })
9964    } else {
9965        Ok(())
9966    }
9967}
9968fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9969    let page = connection
9970        .get("pageInfo")
9971        .filter(|value| value.is_object())
9972        .ok_or_else(|| SourceError::Malformed {
9973            message: "GitHub connection is missing pageInfo".into(),
9974        })?;
9975    if required_bool(page, "hasNextPage")? {
9976        let cursor = required_str(page, "endCursor")?;
9977        validate_cursor_progress(None, cursor)?;
9978        Ok(Some(Cursor(cursor.into())))
9979    } else {
9980        Ok(None)
9981    }
9982}
9983fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9984    if next.is_empty() || previous == Some(next) {
9985        Err(SourceError::Malformed {
9986            message: "GitHub pagination cursor is empty or did not advance".into(),
9987        })
9988    } else {
9989        Ok(())
9990    }
9991}
9992/// The version of this plugin's opaque narrowing-search cursor.
9993pub const SEARCH_CURSOR_VERSION: u32 = 4;
9994
9995#[derive(Serialize, Deserialize)]
9996#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9997enum SearchConnection {
9998    Initial {},
9999    Continuing { after: Cursor },
10000    Exhausted {},
10001}
10002impl SearchConnection {
10003    fn after(&self) -> Option<&str> {
10004        match self {
10005            Self::Continuing { after } => Some(&after.0),
10006            _ => None,
10007        }
10008    }
10009    fn exhausted(&self) -> bool {
10010        matches!(self, Self::Exhausted { .. })
10011    }
10012    /// Whether a cursor naming this position, `offset` rows into its page, is one this
10013    /// plugin could have handed out: a page is resumed only part of the way through it — an
10014    /// offset of a whole page or more would skip rows nobody was given — an initial page
10015    /// only once some of it was handed out, and an exhausted connection has no page to be
10016    /// part of the way through.
10017    fn valid_resume(&self, offset: usize) -> bool {
10018        let within = offset < SEARCH_PAGE_SIZE as usize;
10019        match self {
10020            Self::Initial { .. } => offset > 0 && within,
10021            Self::Continuing { after } => !after.0.is_empty() && within,
10022            Self::Exhausted { .. } => offset == 0,
10023        }
10024    }
10025}
10026
10027/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10028#[derive(Serialize, Deserialize)]
10029#[serde(deny_unknown_fields)]
10030struct SearchPosition {
10031    version: u32,
10032    connection: SearchConnection,
10033    /// How many rows of the page `connection` starts were already handed out.
10034    #[serde(default, skip_serializing_if = "is_zero")]
10035    offset: usize,
10036    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10037    seen: Vec<NativeId>,
10038    #[serde(default, skip_serializing_if = "Vec::is_empty")]
10039    own: Vec<NativeId>,
10040}
10041impl Default for SearchPosition {
10042    fn default() -> Self {
10043        Self {
10044            version: SEARCH_CURSOR_VERSION,
10045            connection: SearchConnection::Initial {},
10046            offset: 0,
10047            seen: Vec::new(),
10048            own: Vec::new(),
10049        }
10050    }
10051}
10052
10053fn is_zero(offset: &usize) -> bool {
10054    *offset == 0
10055}
10056
10057fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10058    cursor.map_or(Ok(0), |c| {
10059        c.0.parse().map_err(|_| SourceError::Config {
10060            message: "page cursor is invalid".into(),
10061        })
10062    })
10063}
10064fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10065    if offset > items.len() {
10066        return Page::last(vec![]);
10067    }
10068    let tail = items.split_off(offset);
10069    let mut selected = tail;
10070    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10071    selected.truncate(limit);
10072    Page {
10073        items: selected,
10074        next,
10075    }
10076}