Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration from a status category to
85//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
86//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
87//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
88//! A missing mapped option refuses the write before either representation changes. Reads
89//! give a closed issue's reason precedence over its option, while an open issue's option
90//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
91//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
92//! list, so it preserves every existing option id and verifies the field and item
93//! assignments immediately afterwards. It counts a terminal category's mapped option as
94//! configured, because a terminal write refuses without it. No ordinary source read or
95//! write calls that mutation, whose
96//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
97//! and a mistake destroys every item's status. A status this board cannot represent is a
98//! refusal naming the status and the instance instead.
99//!
100//! `unknown` is disabled by default because this source cannot preserve an open-ended
101//! status word: it writes an existing board option and never
102//! creates an option. An operator may map `unknown` to one existing option, in which case
103//! every unknown word lands on that option and reads back as `unknown` under the option's
104//! name. This differs from `local-md`, which writes and reads the original word itself.
105//!
106//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
107//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
108//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
109//! finished tasks were only moved to a "Done" column would read 0% complete forever.
110// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
111//!
112//! # What this source declares, field by field
113//!
114//! One verdict per field of [`Capabilities`], and what `Native` means when this source
115//! says it. *Proven* means a shared journey drives it against the real
116//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
117//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
118//! [`capabilities`](TaskSource::capabilities) from parting.
119//!
120//! | Field | Verdict |
121//! | --- | --- |
122//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
123//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
124//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
125//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
126//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
127//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so neither `ProjectV2.items` nor any issue the search did not name is read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
128//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
129//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
130//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping`. |
131//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
132//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
133//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project or document query's text is applied by that same substring rule over the issues its read already holds, and narrows nothing. |
134//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
135//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
136//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
137//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
138//!
139//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
140//! source has documents and that its tasks have comments, both of which hold — and the three
141//! facts behind the uniform `Native` on the
142//! predicates beside it are recorded below rather than re-derived, because a reader who
143//! takes `Native` to mean *the remote service filters* will read that uniformity as a
144//! lie.
145//!
146//! First, the plugin contract defines `Support::Native` as *the source applies this
147//! predicate itself*, and says nothing about where it applies it. What the declaration
148//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
149//! — so that the engine may push it down and apply nothing of its own.
150//!
151//! Second, this source can keep that promise for every predicate at no additional API
152//! cost, because whichever of the reads below answers a query has already read every
153//! candidate that query will return before it filters anything. Filtering those items is
154//! in-process work over data already in hand.
155//!
156//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
157//! applied in process over what that question returned. A project filter has a relationship — a
158//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
159//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
160//! a search for metadata values, is the board-scoped issue search carrying the text and each
161//! value as quoted phrases; an origin is the board's own field filter over its origin field
162//! beside the same search for the id. **The text search narrows, and that is this source's
163//! declared semantics:** GitHub matches whole words where the substring rule this source and
164//! the local Markdown source confirm with would match inside one, so an item holding the text
165//! only inside a longer word is never a candidate. Every item returned does contain the text.
166//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
167//! so those three are applied in process over the candidates, and a query carrying none of
168//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
169//! engine compensate for work this source has already done, and declaring `projects` native
170//! while ignoring the filter (which this source once did) silently returns another project's
171//! tasks, because the engine trusts the declaration and applies nothing locally.
172//!
173//! # The three ways this source reaches an item, and what each costs
174//!
175//! A board read is charged for what its *nested* connections could return rather than for
176//! what was asked, so one whole-board read costs the same whether the question was about
177//! one project or about all of them. That is why a question about one project is never
178//! answered by reading the board:
179//!
180//! | The question | What is sent | What it costs |
181//! | --- | --- | --- |
182//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)` — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
183//! | 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` | the board's fields |
184//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
185//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
186//! | 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 |
187//! | 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 |
188//! | 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 |
189//! | 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 |
190//! | 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 |
191//!
192//! The following standalone-ticket requests are pinned by the real CLI fixture journey
193//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields`. They include the
194//! origin lookup and field/repository discovery a create needs. A bound re-copy changes
195//! status, priority, content and metadata; comment recount means a subsequent detail read.
196//! Each request here costs one declared point. A membership beyond the embedded page can
197//! additionally require the one-point membership recovery described above.
198//!
199//! | Verb | Requests / points | Documents |
200//! | --- | --- | --- |
201//! | new copy | 6 | ORIGIN_LOOKUP, BOARD_FIELDS, REPOSITORY, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
202//! | bound copy | 5 | ISSUE, BOARD_FIELDS, ISSUE_DEPENDENCIES, UPDATE_ISSUE, UPDATE_FIELDS |
203//! | comment | 2 | ISSUE, ADD_COMMENT |
204//! | recount | 2 | ISSUE, ISSUE_COMMENTS |
205//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
206//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
207//! | content | 2 | ISSUE, UPDATE_ISSUE |
208//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
209//! | record only | 1 | ISSUE |
210//!
211//! <!-- github-search-paging:start -->
212//! Board-scoped text, metadata, project-name and comment-activity searches send every
213//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
214//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
215//! needs rows. A page is never resized to the rows still needed: GitHub orders one
216//! search differently at different page sizes, so one fixed size makes a paged walk
217//! send exactly the requests one whole read sends, and the answer's order is the order
218//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
219//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
220//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
221//! returned and fetched pages: a limit is sliced from the pages it needs, and local
222//! confirmation can require more candidates than matching rows. Walking all pages
223//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
224//! cursor and how far into that page the last answer stopped, and resumes in the same
225//! process or a new one, without duplicates or gaps. It carries no rows: one process
226//! sends each page's search once, and a new process re-reads only the page it resumes
227//! in, then sends a further page once, never as a re-read, only when its limit still
228//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
229//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
230//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
231//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
232//! not required to include the original process's writes still omitted by the index.
233//! <!-- github-search-paging:end -->
234//!
235//! The board half of an issue — its board item's id, its `Status` option and this
236//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
237//! an item reached any of those ways resolves through the same
238//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
239//! same status, the same labels and the same qualified id. That connection comes back a
240//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
241//! on the page in hand and — only if that page reports more of the connection — in the
242//! last row's read of that one issue's memberships, resumed from the page's own cursor and
243//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
244//! report, which is what keeps an id naming another repository's issue from being answered
245//! as an item of this board; and because the page is where the search starts rather than
246//! where it ends, that answer is one about a connection read to exhaustion and never about
247//! an unread page. Nothing costs the extra read but an issue on more boards than a page
248//! holds: an issue this board really does not hold reports no next page, so its
249//! memberships are already exhausted where they arrived.
250//!
251//! **No document here selects the board's own `Labels` field, and nothing is lost by
252//! that.** An item's labels are read from its content alone, wherever that content is
253//! reached: the three documents above select `Issue.labels` on the fragment, and
254//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
255//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
256//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
257//! create one, and `ProjectV2FieldValue` — the whole of what
258//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
259//! it from the content, for every content type it exists on, and there is nothing it can
260//! hold that the content does not already say: for an `Issue` it *is* that issue's own
261//! labels, so selecting it beside them unions a set with itself.
262//!
263//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
264//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
265//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
266//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
267//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
268//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
269//! label set, and that set is the fixture issue's own, by
270//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
271//! item whose content is a draft reports an empty set, by
272//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
273//! selection is held over [`graphql::DOCUMENTS`] by
274//! `no_document_selects_the_boards_own_labels_field`.
275//!
276//! The whole-board row is still the board's own item connection, and deliberately: a
277//! **draft** board item is not an issue, so no search can list one, and the reads that have
278//! to answer for the whole board are the ones whose cost is the board's size anyway.
279//!
280//! **A question about one item this source already names by id never lists the board.**
281//! Whether that item is on this board, and what its board fields are, is answered by reading
282//! that item — its own `Issue.projectItems`, walked to exhaustion by
283//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
284//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
285//! holds. That covers a write's destination, the project a new item is filed under, a
286//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
287//! and the delete that takes back an item a copy made. What such a write needs of the board
288//! and the item does not carry — the board's id, the `Status` and origin field definitions —
289//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
290//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
291//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
292//! minutes. Scanning this host's 842-item board has refused a document copy and an update
293//! even though the items' own reads named that board. A scan there gives the wrong answer
294//! as well as paying for every page. So a `board.items` lookup does not belong on any of
295//! those paths.
296//!
297//! **What a read may return is capped too, and that cap is on the document rather than on
298//! the board.** GitHub limits the number of nodes **one query may return** to
299//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
300//! an error naming the connection the count crossed at, not a slow or a partial result.
301//! Every board this source reads is refused the same way, so no board is too big for these
302//! documents and none is small enough to save one that is over.
303//!
304//! The count is arithmetic over the document's own text: each connection contributes the
305//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
306//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
307//! restate them — `github-graphql-node-count` implements them, and
308//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
309//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
310//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
311//! same text on every run and fails naming any that reaches the limit — so a connection
312//! added to a shared fragment is caught there rather than by GitHub.
313//!
314//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
315//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
316//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
317//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
318//! effectively squared there, which is why it is the one the limit is most sensitive to.
319//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
320//! page of memberships misses is recovered by one further read rather than refused, so it
321//! buys a bound every read pays for at the price of a request only a multi-board issue
322//! pays.
323//!
324//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
325//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
326//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
327//! `cost` is rate-limit points, metered per hour across everything one credential does; it
328//! is what the two limiters [`Limiter`] tells apart meter, and a document under
329//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
330//! that second number, and `tests/point_cost.rs` pins every document in
331//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
332//! one under, the pin itself is the check. The credentialed lane reconciles both figures
333//! against GitHub's own, off a probe it already sends.
334//!
335//! **What is pinned that way is a per-document price and never a session's.** The record in
336//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
337//! **requests** and **worst-case nodes** — and neither is points. What one whole session
338//! consumes of the hourly point allowance is observable only from a credentialed run's own
339//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
340//! and what `tests/live.rs` prints at the end of every run.
341//!
342//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
343//!
344//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
345//! enumerations of a board can supply one alone.** Resolving a node id is strongly
346//! consistent, so a read by id and a project's own sub-issues are already current. The
347//! other two are not, and they are behind by different amounts and in different directions:
348//!
349//! - GitHub's **issue search** is an index and answers a write made moments ago with the
350//!   value from before it — usually for a second or two.
351//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
352//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
353//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
354//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
355//!
356//! That second one is a measurement rather than a caution. This repository's own
357//! credentialed journey writes a project and waits for the board to report it, then writes a
358//! task and waits for the same thing seconds later on the same board: the project wait is
359//! answered through the search and converged in two or three attempts in each of three runs,
360//! and the task wait is answered through `ProjectV2.items` and converged in none of them
361//! inside thirty. Separately, an item added to a second and larger board was read back by
362//! `Issue.projectItems` on that board's own id while every one of that connection's nine
363//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
364//! through the lagging one alone is what had a board read deny an issue that had certainly
365//! landed on it.
366//!
367//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
368//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
369//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
370//! search reports what the projection is behind on. What closes the last
371//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
372//! source answers is completed with what this process itself wrote, so an item created
373//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
374//! nothing is written down, and the record dies with the process. **A wait that has to
375//! observe GitHub's own data cannot be answered from that record** — which is why the
376//! credentialed journey asks through a source built afresh, and why the union above rather
377//! than a longer wait is what makes such a wait converge.
378//!
379//! **A narrowed read is the same bargain, stated for each of the three predicates it
380//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
381//! than walking the board, and every such answer is completed with what this process wrote —
382//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
383//! each filtered by the same predicates as the rest — so an item this command wrote a moment
384//! ago is returned by a query that matches it whether or not the index has caught up. An item
385//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
386//! consistent. What is left is stated rather than papered over:
387//!
388//! | Read | Finds | Behind by |
389//! | --- | --- | --- |
390//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
391//! | 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 |
392//! | 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 |
393//! | origin, third read | this process's own writes | nothing |
394//!
395//! So an origin carrier another process added within the last second or two, before either
396//! index has it, can be missing from an origin query, and one written by the release before
397//! this one — its origin in the field alone — can be missing for as long as the board's own
398//! item connection is behind on it. A copy that must not duplicate its own earlier write
399//! relies on the link it records, not on either index. **A board draft is not an issue**, so
400//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
401//! lists one, the origin lookup drops any the board's own field filter names, and one this
402//! process wrote is not added back either.
403//!
404//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
405//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
406//! body's metadata slot, so the issue search can find it in seconds. The field is
407//! authoritative: this source reads an item's origin from the field alone, so a slot that
408//! disagrees with it, or holds one where the field holds none, is never read as a second
409//! origin — and the release before this one reads the slot, drops that key's copy for the
410//! field's, and sees the same one origin.
411//!
412//! Filtering happens before paging, so a page of a filtered result is a page of the
413//! survivors rather than the survivors of a page. Label matching and the substring rule a
414//! text candidate is confirmed by answer the same question the same way the local Markdown
415//! source's do; which candidates a text search has to confirm is GitHub's word match, which
416//! is the one place the two sources can answer the same text differently.
417//!
418//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
419//! has one source, `capabilities`, and the note above is the reasoning behind it rather
420//! than a second copy of it: without the three facts recorded here a reader takes the
421//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
422//! crate's own capabilities test, which pins every field of it against a fully spelled-out
423//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
424//! fails to compile there rather than going unasserted. -->
425//! The fixture-server tests above run wherever this crate is selected; the credentialed
426//! lane runs in the same required check, beside them, and can fail it — it verifies the
427//! 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,
428//! one filed under neither, a label on one of the three and a closed status on another —
429//! because that shape is what tells an honoured predicate from an ignored one: a board
430//! holding a single project answers a project filter the same way whether or not this
431//! source applies it, which is exactly how the defect above went unseen.
432//!
433//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
434//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
435//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
436//! nominated is what keeps a credentialed write lane off a board and a repository nobody
437//! nominated; it never asks GitHub which project was updated most recently. Before it
438//! starts, the lane also clears any item titled — and any repository label named — the way
439//! it titles and names its own artifacts, which is self-healing after an interrupted run:
440//! a process killed between its writes and its cleanup leaves artifacts the next run
441//! removes.
442//!
443//! # What a session of requests costs, and where the report is
444//!
445//! This source records **every** request it sends into [`accounting::Accounting`], at
446//! `send_once` — the one place a request leaves this crate, which is why a read path added
447//! later is counted without anybody remembering to count it. That is the whole of what this
448//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
449//! session's spend is arrived at, and what it deliberately does not know are set out.
450//!
451//! What one whole session of the live journey costs, counted that way against this crate's
452//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
453//! reduction it came out of, and with what it does and does not say about rate-limit points.
454//!
455//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
456//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
457//! code path — no environment variable, no feature, no build configuration — because an
458//! instrument nobody switches on measures nothing, and
459//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
460//! source's counts the whole session rather than this source's share. The credentialed lane
461//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
462//! passed or failed.
463//!
464//! **A live session refuses to start unless the account can afford it.** Before it does any
465//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
466//! GitHub documents as not counting against the REST rate limit and which answers both of
467//! its budgets at once — and starts only if, for each of them, what remains minus this
468//! session's estimated cost is still at least
469//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
470//! allowance. A session that cannot **declines**: it did not run, so it is
471//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
472//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
473//! waiting for the budget to come back. The estimate is derived offline from
474//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
475//! which is also where the published rule that model rests on is cited; the accounting
476//! above records the gate's own read like any other request, and
477//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
478//!
479//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
480//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
481//! text, which is what lets it run on every platform and on a pull request from a fork with
482//! no credential — and that is what actually stops a regression merging. But an offline
483//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
484//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
485//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
486//! number of nodes this query may return"* and whose `cost` is what that document would
487//! spend, both for a document **without executing it**, and the lane asks it for every query
488//! document this source sends, under the largest bindings this source sends, and fails when
489//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
490//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
491//! one; the offline pins still cover it. It records what those calls reported about the
492//! account's own allowance, because whether asking is free is a thing to observe rather than
493//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
494//! and `cost` is metered against an hourly allowance the accounting above reads off a
495//! credentialed run's own response headers.
496//!
497//! **GitHub has two rate limiters and this source is refused by both, so nothing here
498//! treats them as one thing.** The primary budget is the hourly allowance `gh api
499//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
500//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
501//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
502//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
503//! one part of.
504#![deny(missing_docs)]
505
506use std::collections::BTreeMap;
507use std::sync::{Arc, Mutex};
508use std::time::{Duration, Instant};
509
510use chrono::{DateTime, Utc};
511use onetaskgraph_plugin_api::{
512    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
513    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
514    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
515    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
516    SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskRef,
517    TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
518};
519use reqwest::{Client, StatusCode, Url};
520use schemars::{Schema, schema_for};
521use secrecy::{ExposeSecret, SecretString};
522use serde::{Deserialize, Serialize};
523use serde_json::{Value, json};
524
525pub mod accounting;
526
527use accounting::Accounting;
528
529/// The registry name for this plugin.
530pub const KIND: &str = "github-projects";
531/// GitHub's maximum connection page size.
532pub const MAX_PAGE_SIZE: u32 = 100;
533/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
534/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
535/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
536pub const SEARCH_PAGE_SIZE: u32 = 20;
537
538/// The most nodes any one document this source sends may be asked to return.
539///
540/// GitHub's own published per-query ceiling, taken from
541/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
542/// workspace cannot hold a stale copy of somebody else's number. A query above it is
543/// **refused before it is executed**, whoever is asking and whatever board they are
544/// asking about — so this is a bound on the documents rather than a budget that runs out.
545///
546/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
547/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
548/// everything the credential does — two numbers against two limits, and this constant
549/// bounds only the first. The second is computed offline too, per document:
550/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
551/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
552/// lane. There is no constant like this one to hold a price under, because points are an
553/// hourly allowance rather than a per-call bound.
554///
555/// Neither is a session's price. What `session-cost.md` records of a whole session is its
556/// **requests** and its **worst-case nodes**; what a whole session spends in points is
557/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
558/// [`accounting`]. The module section on the three ways this source reaches an item says how
559/// the count is arrived at, and which of the page sizes below decide it.
560pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
561
562/// Nested connection size for the connections that hang off one item.
563///
564/// It multiplies through every document that reaches an item under a page — the count
565/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
566/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
567/// every document under these constants and fails naming any that reaches the limit, so
568/// raising this is caught there rather than by GitHub.
569const NESTED_PAGE_SIZE: u32 = 50;
570/// How many of one issue's board memberships are read when an issue is reached directly.
571///
572/// An issue reached through a search or through its own node id carries its board half in
573/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
574/// under a page of issues, so every point of it multiplies through the whole document and
575/// is paid for whether or not any issue is on a second board — which is why it is
576/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
577///
578/// **Three, because what a page misses is now recovered rather than refused**, and the
579/// recovery is what the value is chosen against. An issue whose entry for this board sits
580/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
581/// that page's own cursor — so the value trades a bound every read pays for a request only
582/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
583/// boards would pay that request *per issue*, which is order N against the one page per
584/// hundred issues a read costs today. At three it is only reached by an issue on four or
585/// more boards at once, which keeps the recovery path exceptional rather than routine for
586/// a plausible deployment.
587const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
588/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
589/// its two connections for.
590///
591/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
592/// second is a duplicate a copy already takes the first of. Both connections are walked to
593/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
594/// never what the answer is. It is small because every point of it is paid on every lookup,
595/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
596/// worst-case nodes than the one whole-board read they replaced.
597const ORIGIN_PAGE_SIZE: u32 = 3;
598
599pub use github_graphql_node_count::{NodeCountError, Variables};
600
601/// The largest value this source can bind to each page-size variable its documents name.
602///
603/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
604/// constant above it wherever a caller's own limit could reach it — `$first` at
605/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
606/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
607/// not one configuration of it, which is what makes a bound computed under it a bound on
608/// every read.
609pub fn largest_page_sizes() -> Variables {
610    Variables::from([
611        ("first".to_owned(), MAX_PAGE_SIZE),
612        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
613        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
614        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
615    ])
616}
617
618/// The most nodes `document` could be asked to return, by GitHub's published rules.
619///
620/// Computed offline from the document's own text under [`largest_page_sizes`] — no
621/// network, no credential and no schema — by
622/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
623/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
624/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
625///
626/// # Errors
627///
628/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
629/// no single operation, or binds a page size this source does not name — each of which is
630/// a defect in the document rather than a number.
631pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
632    node_count(document, &largest_page_sizes())
633}
634
635/// The most rate-limit points one call of `document` could spend, by GitHub's published
636/// rules.
637///
638/// Computed offline from the document's own text under [`largest_page_sizes`] — no
639/// network, no credential and no schema — by
640/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
641/// This is `cost`, metered **per hour** against the allowance one credential shares across
642/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
643/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
644/// under, so what `tests/point_cost.rs` does with it is pin every document in
645/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
646/// figures against GitHub's own reported `cost`.
647///
648/// # Errors
649///
650/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
651/// no single operation, or binds a page size this source does not name — each of which is
652/// a defect in the document rather than a number.
653pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
654    github_graphql_node_count::point_cost(document, &largest_page_sizes())
655}
656
657/// The most nodes `document` could be asked to return under `variables`.
658///
659/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
660/// [`accounting`] is this under the bindings one request really sent — one spelling of the
661/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
662/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
663///
664/// # Errors
665///
666/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
667/// no single operation, or binds a page size `variables` does not name.
668pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
669    github_graphql_node_count::node_count(document, variables)
670}
671
672/// The issue-title prefix that makes a board issue a document.
673///
674/// A GitHub Projects board has no document type — it holds issues — so the discriminator
675/// is the title, and this is the whole of it: an issue whose title begins with these bytes
676/// is a document and every other issue is the task or project the sub-issue rule makes it.
677///
678/// It is spelled **once**, here, and read rather than restated everywhere else — including
679/// by the shared journeys, which take it from this constant so a board fixture cannot
680/// drift from what this source reads. `docs/metadata.md` records the two consequences that
681/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
682/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
683/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
684pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
685
686/// Exact GraphQL query documents issued by this plugin.
687///
688/// Keeping the production documents here lets the pinned-schema test validate the same
689/// bytes that are sent to GitHub, rather than a test-only copy which could drift
690/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
691/// field, and its guarded caller always supplies the complete existing option set with ids.
692pub mod graphql {
693    /// The board half of one item: the field values every document here reads it from.
694    ///
695    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
696    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
697    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
698    /// *the same value*, because
699    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
700    /// one path. Three spellings of it is what would drift, so there is one.
701    ///
702    /// The `Status` option and this source's own origin text field are the whole of it. It
703    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
704    /// content, so it holds nothing the content's own `labels` do not already say, and it
705    /// would sit a label connection two page sizes deep.
706    macro_rules! board_item_values {
707        () => {
708            r#"fieldValues(first:$nestedFirst){nodes{
709          ... on ProjectV2ItemFieldSingleSelectValue{name field{
710            ... on ProjectV2SingleSelectField{id name options{id name}}
711          }}
712          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
713        }pageInfo{hasNextPage}}"#
714        };
715    }
716
717    /// Everything this source reads about one issue, wherever it reaches that issue.
718    ///
719    /// A macro rather than a constant so the three documents below can `concat!` it: one
720    /// spelling of these fields is what makes an issue read through the board-scoped
721    /// search, through its own node id, and through its project's sub-issue relationship
722    /// resolve to *the same* item, which is the whole of what
723    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
724    ///
725    /// `projectItems` is what carries the board half of an issue: the board item's own id
726    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
727    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
728    /// issue rather than on the board, which is what makes the cost of a read proportional
729    /// to what was asked for instead of to the board's size.
730    ///
731    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
732    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
733    /// not on that page: a page here is where the search for the entry starts rather than
734    /// where it ends.
735    ///
736    /// It does **not** select the board's `Labels` field value, and that is the whole of
737    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
738    /// a label connection there sits under `fieldValues` under `projectItems` under a page
739    /// of issues, spending `$nestedFirst` twice down one path, and took
740    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
741    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
742    /// above, and that connection is where every label this source reports comes from. No
743    /// document in this module selects the board field any longer, [`BOARD`] included; the
744    /// module documentation records why nothing it could have held is lost.
745    macro_rules! board_issue {
746        () => {
747            concat!(
748                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
749      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
750      projectItems(first:$boardItems){nodes{id project{id number}
751        "#,
752                board_item_values!(),
753                r#"}pageInfo{hasNextPage endCursor}}}"#
754            )
755        };
756    }
757
758    /// Every issue of one board, found by a search scoped to that board.
759    ///
760    /// This is how the projects a board holds are listed, and it selects no `items`
761    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
762    /// container walked page by page, so nothing nested inside a board item is paid for.
763    /// Which of the issues it returns is a project is then read off `parent` — GitHub
764    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
765    /// discriminator has to be applied to the field, which is a scalar on the issue and
766    /// costs nothing.
767    pub const SEARCH_ISSUES: &str = concat!(
768        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
769      search(query:$search,type:$type,first:$first,after:$after){
770        pageInfo{hasNextPage endCursor}
771        nodes{__typename ...BoardIssue}
772      }
773    }"#,
774        board_issue!()
775    );
776
777    /// One issue by its own node id, which is what a qualified id names here.
778    ///
779    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
780    /// answers a write made moments ago with the value from before it, and resolving a node
781    /// id does not.
782    pub const ISSUE: &str = concat!(
783        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
784      node(id:$id){__typename ...BoardIssue}
785    }"#,
786        board_issue!()
787    );
788
789    /// One project's tasks: the sub-issues of the issue that project is.
790    ///
791    /// The work this costs is the project's own size. Nothing about it grows as the board
792    /// gains projects, or as those projects gain tasks.
793    pub const SUB_ISSUES: &str = concat!(
794        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
795      node(id:$id){__typename
796        ... on Issue{subIssues(first:$first,after:$after){
797          pageInfo{hasNextPage endCursor}
798          nodes{__typename ...BoardIssue}
799        }}}
800    }"#,
801        board_issue!()
802    );
803
804    /// What a read of the board's own `items` selects of each item's content.
805    ///
806    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
807    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
808    /// content by one spelling.
809    macro_rules! board_item_content {
810        () => {
811            r#" content{
812        ... 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}}}
813        ... on PullRequest{__typename id}
814        ... on DraftIssue{__typename id title body createdAt updatedAt}
815      }"#
816        };
817    }
818
819    /// Reads the board's fields and one page of its items.
820    pub const BOARD: &str = concat!(
821        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
822      owner:repositoryOwner(login:$owner){
823        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
824      }
825    } fragment Board on ProjectV2 { id title
826      fields(first:$nestedFirst){nodes{
827        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
828        ... on ProjectV2Field{__typename id name}
829      }pageInfo{hasNextPage}}
830      items(first:$first,after:$after){nodes{id "#,
831        board_item_values!(),
832        board_item_content!(),
833        r#"} pageInfo{hasNextPage endCursor}}
834    }"#
835    );
836
837    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
838    /// the board.
839    ///
840    /// **`originItems`** is the board's own items narrowed by its own field filter —
841    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
842    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
843    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
844    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
845    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
846    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
847    /// fresh `addProjectV2ItemById` the way that connection does.
848    ///
849    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
850    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
851    /// indexes that comment, and the index catches up with a write in a second or two rather
852    /// than in minutes, so it finds a carrier another process wrote that the first read is
853    /// still behind on.
854    ///
855    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
856    /// — and resumes from its own cursor; a connection already walked to its end is resumed
857    /// from its last cursor, which answers an empty page. Every candidate either read returns
858    /// is confirmed against its own origin field before it is reported, so a token match of
859    /// the search or anything else the filter admits never is.
860    ///
861    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
862    /// own whole reads counts this one among them.
863    pub const ORIGIN_LOOKUP: &str = concat!(
864        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
865      originItems:repositoryOwner(login:$owner){
866        ... on ProjectV2Owner{projectV2(number:$number){
867          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
868        board_item_values!(),
869        board_item_content!(),
870        r#"} pageInfo{hasNextPage endCursor}}
871        }}
872      }
873      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
874        pageInfo{hasNextPage endCursor}
875        nodes{__typename ...BoardIssue}
876      }
877    }"#,
878        board_issue!()
879    );
880
881    /// The board's own id and field definitions, and not one of its items.
882    ///
883    /// What a write needs of the board when the item it writes does not say: the id a field
884    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
885    /// origin fields. It selects no `items`, so what it costs is the board's field list
886    /// however many items the board holds — and it decides nothing about which items those
887    /// are, which is the question a read of one item by its own id answers instead.
888    ///
889    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
890    /// board's item reads by their root counts this one among them.
891    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
892      boardFields:repositoryOwner(login:$owner){
893        ... on ProjectV2Owner{projectV2(number:$number){id
894          fields(first:$nestedFirst){nodes{
895            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
896            ... on ProjectV2Field{__typename id name}
897          }pageInfo{hasNextPage}}
898        }}
899      }
900    }"#;
901
902    /// One board draft by its own node id, with the board item it sits in.
903    ///
904    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
905    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
906    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
907    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
908    /// board listing hands it to, and nothing has to list the board to find one.
909    pub const DRAFT: &str = concat!(
910        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
911      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
912        projectV2Items(first:$boardItems){nodes{id project{id number}
913        "#,
914        board_item_values!(),
915        r#"}pageInfo{hasNextPage endCursor}}}}
916    }"#
917    );
918
919    /// One issue's board memberships alone, walked past the page a read of it carried.
920    ///
921    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
922    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
923    /// boards than that page holds may have this board's entry past its end. This asks that
924    /// one issue for its memberships and nothing else — the caller already holds the issue —
925    /// so an answer of "this board does not hold it" is only ever given about a connection
926    /// read to exhaustion.
927    ///
928    /// It selects the board item's id, its project number and the same
929    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
930    /// very same resolver: an issue recovered this way reports the same title, the same
931    /// status, the same labels and the same qualified id as one whose entry was on the
932    /// page.
933    ///
934    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
935    /// multiplies through it and the membership connection can be walked at
936    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
937    /// further request for any issue a person really keeps.
938    pub const ISSUE_BOARD_ITEMS: &str = concat!(
939        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
940      node(id:$id){
941        ... on Issue{projectItems(first:$first,after:$after){
942          nodes{id project{id number}
943        "#,
944        board_item_values!(),
945        r#"}
946          pageInfo{hasNextPage endCursor}}}
947      }
948    }"#
949    );
950    /// Resolves the configured repository's node id, which creating an issue requires.
951    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
952    /// Reads both dependency directions for one issue, with each far end's own kind — and
953    /// the issue's own body, which is where an edge to another source is recorded, so that
954    /// half of a dependency read needs no second read of the issue or of the board.
955    pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
956      ... on Issue{body
957        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
958        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
959      }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
960    /// Creates one issue in the configured repository.
961    pub const CREATE_ISSUE: &str =
962        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
963    /// Puts an existing issue on the configured board.
964    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
965    /// Updates an issue's visible fields and its open or closed state in one call.
966    pub const UPDATE_ISSUE: &str =
967        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
968    /// Updates an existing draft's user-visible fields.
969    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
970    /// Updates a text or single-select value on one project item.
971    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}}}}}}}}"#;
972    /// Writes up to three board fields and an optional clear in one ordered mutation.
973    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}}}"#;
974    /// Clears one project item's value of one field, which is what a `none` priority is.
975    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}}}}}}}}"#;
976    /// Creates one single-select field with its options. Only the guarded field setup may use
977    /// this document, and only for a field the board lacks.
978    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
979    /// Replaces a single-select field's options. Only the guarded field setup — the
980    /// `status-options` and `fields` operations — may use this document, because GitHub
981    /// treats the input as the complete option list.
982    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
983    /// A fresh snapshot of the Status field and every board item's assignment.
984    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}}}}}}"#;
985    /// Files one issue under another as a sub-issue, which is what project membership is.
986    pub const ADD_SUB_ISSUE: &str =
987        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
988    /// Takes one issue back out of its parent.
989    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
990    /// Adds GitHub's native issue blocked-by relationship.
991    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
992    /// Removes one native issue blocked-by relationship.
993    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
994    /// Deletes one issue, which takes its board item with it.
995    ///
996    /// The engine sends this in one situation only: undoing a copy that could not finish,
997    /// over the items that same copy created. Deleting the issue removes the board item
998    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
999    pub const DELETE_ISSUE: &str =
1000        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1001
1002    /// Everything this source reads about one issue comment, wherever it reaches one.
1003    ///
1004    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1005    /// and a comment just edited are handed to one mapper, so they are selected by one
1006    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1007    /// longer exists, and `login` is the one member every kind of actor carries.
1008    macro_rules! issue_comment {
1009        () => {
1010            "id author{login} createdAt updatedAt body url"
1011        };
1012    }
1013
1014    /// One task's comments: a page of its issue's own `comments` connection.
1015    ///
1016    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1017    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1018    /// list every time somebody edited it; left unordered the connection answers in the order
1019    /// the comments were written, which is the order GitHub documents for the same collection
1020    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1021    /// node count and the caller's own page size is pushed straight down.
1022    pub const ISSUE_COMMENTS: &str = concat!(
1023        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1024        issue_comment!(),
1025        r#"}pageInfo{hasNextPage endCursor}}}}}"#
1026    );
1027    /// Which issue one comment is on, read before that comment is edited or removed.
1028    ///
1029    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1030    /// comment id given against the wrong task would change a comment on another issue.
1031    pub const COMMENT_ISSUE: &str =
1032        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1033    /// Adds one comment to an issue, signed as the account the token belongs to.
1034    pub const ADD_COMMENT: &str = concat!(
1035        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1036        issue_comment!(),
1037        r#"}}}}"#
1038    );
1039    /// Replaces the body of one issue comment.
1040    pub const UPDATE_COMMENT: &str = concat!(
1041        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1042        issue_comment!(),
1043        r#"}}}"#
1044    );
1045    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1046    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1047
1048    /// Every document above, with what this source is doing when it sends one.
1049    ///
1050    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1051    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1052    /// document added later with "talking to GitHub" and never say so.
1053    ///
1054    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1055    /// const` here that this list omits, so the two cannot part — which is the same guard
1056    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1057    pub const DOCUMENTS: [(&str, &str); 30] = [
1058        (SEARCH_ISSUES, "searching this board's issues"),
1059        (ISSUE, "reading one issue"),
1060        (
1061            ISSUE_BOARD_ITEMS,
1062            "reading one issue's board memberships past the page it came with",
1063        ),
1064        (SUB_ISSUES, "reading a project's tasks"),
1065        (BOARD, "reading the board"),
1066        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1067        (BOARD_FIELDS, "reading the board's fields"),
1068        (DRAFT, "reading one draft"),
1069        (REPOSITORY, "reading the destination repository"),
1070        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1071        (CREATE_ISSUE, "creating an issue"),
1072        (ADD_TO_BOARD, "adding an issue to the board"),
1073        (UPDATE_ISSUE, "updating an issue"),
1074        (UPDATE_DRAFT, "updating a draft item"),
1075        (UPDATE_FIELD, "writing a board field"),
1076        (UPDATE_FIELDS, "writing board fields together"),
1077        (CLEAR_FIELD, "clearing a board field"),
1078        (
1079            CREATE_FIELD,
1080            "creating a board single-select field with its options",
1081        ),
1082        (
1083            STATUS_OPTIONS_SNAPSHOT,
1084            "snapshotting board Status options and assignments",
1085        ),
1086        (
1087            STATUS_OPTIONS_UPDATE,
1088            "safely replacing the board Status option list",
1089        ),
1090        (ADD_SUB_ISSUE, "filing an issue under its project"),
1091        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1092        (ADD_BLOCKED_BY, "recording a dependency"),
1093        (REMOVE_BLOCKED_BY, "removing a dependency"),
1094        (DELETE_ISSUE, "deleting an issue"),
1095        (ISSUE_COMMENTS, "reading a task's comments"),
1096        (COMMENT_ISSUE, "reading which issue a comment is on"),
1097        (ADD_COMMENT, "adding a comment"),
1098        (UPDATE_COMMENT, "editing a comment"),
1099        (DELETE_COMMENT, "deleting a comment"),
1100    ];
1101}
1102
1103/// Which of GitHub's two rate limiters refused a request.
1104///
1105/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1106/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1107/// the whole reason this is carried rather than collapsed into "rate limited".
1108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1109enum Limiter {
1110    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1111    Primary,
1112    /// The burst limiter over content-generating requests, which nothing reports.
1113    Secondary,
1114}
1115
1116/// The wordings GitHub answers a secondary rate limit with.
1117///
1118/// It sends them under a forbidden status, under a too-many-requests status, and inside
1119/// the `errors` of a *successful* response, which is why the text is what this matches on
1120/// rather than the status. `abuse detection` is the wording GitHub used before the
1121/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1122/// what a burst of content creation is refused with.
1123///
1124/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1125/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1126/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1127/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1128pub const SECONDARY_WORDINGS: [&str; 5] = [
1129    "secondary rate limit",
1130    "temporarily blocked from content creation",
1131    "abuse detection",
1132    "submitted too quickly",
1133    "exceeded a secondary",
1134];
1135
1136/// The wordings GitHub answers an exhausted primary budget with.
1137///
1138/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1139/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1140/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1141/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1142/// two phrases is a substring of it, so without it that answer read as a refusal that will
1143/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1144/// one reason.
1145pub const PRIMARY_WORDINGS: [&str; 4] = [
1146    "api rate limit exceeded",
1147    "api rate limit already exceeded",
1148    "rate limit exceeded",
1149    "rate_limited",
1150];
1151
1152/// What a response *says about itself*, which is the only place a refusal can be read.
1153///
1154/// Deliberately not the whole response body. A board is a place people write about their
1155/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1156/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1157/// reported. So the item data is never read: what is read is GitHub's own REST-style
1158/// `message` envelope, which is what a forbidden status carries, and the `message` and
1159/// `type` of each GraphQL error, which is where a *successful* response says it.
1160///
1161/// A body that is not JSON at all has nothing structured to read, so only a failing
1162/// response's own text is taken — a successful response that is not JSON is malformed
1163/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1164fn refusal_wording(status: StatusCode, body: &str) -> String {
1165    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1166        return if status.is_success() {
1167            String::new()
1168        } else {
1169            body.to_owned()
1170        };
1171    };
1172    let mut said: Vec<&str> = parsed
1173        .get("message")
1174        .and_then(Value::as_str)
1175        .into_iter()
1176        .collect();
1177    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1178        for error in errors {
1179            said.extend(
1180                ["message", "type"]
1181                    .into_iter()
1182                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1183            );
1184        }
1185    }
1186    said.join("; ")
1187}
1188
1189impl Limiter {
1190    /// Which limiter refused this response, or `None` when none of them did.
1191    ///
1192    /// The wording is read first and the status only decides what carries none of it,
1193    /// because GitHub answers a secondary limit with a forbidden status far more often
1194    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1195    /// really is a credential this token lacks.
1196    ///
1197    /// A response is a refusal because of its status or its own wording. A spent budget
1198    /// only ever explains one; it never turns an answer into a refusal.
1199    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1200        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1201        if SECONDARY_WORDINGS
1202            .iter()
1203            .any(|wording| normalized.contains(wording))
1204        {
1205            return Some(Self::Secondary);
1206        }
1207        if status == StatusCode::TOO_MANY_REQUESTS {
1208            return Some(Self::Primary);
1209        }
1210        // An exhausted budget *explains* a response that failed; it does not make one that
1211        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1212        // request the budget allowed as well as on the ones it then refuses, so reading
1213        // the header alone threw away a good answer — and, once refusals were retried,
1214        // replayed a request that had already taken effect.
1215        if !status.is_success() && budget_exhausted {
1216            return Some(Self::Primary);
1217        }
1218        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1219        // `errors` of an HTTP 200, where nothing about the status says so at all.
1220        if status.is_success()
1221            && PRIMARY_WORDINGS
1222                .iter()
1223                .any(|wording| normalized.contains(wording))
1224        {
1225            return Some(Self::Primary);
1226        }
1227        None
1228    }
1229
1230    /// What this limiter is called where an operator can look it up.
1231    const fn name(self) -> &'static str {
1232        match self {
1233            Self::Primary => "GitHub's primary API rate limit",
1234            Self::Secondary => "GitHub's secondary rate limit",
1235        }
1236    }
1237
1238    /// What the endpoint an operator would go and check says about this limiter.
1239    const fn where_to_look(self) -> &'static str {
1240        match self {
1241            Self::Primary => {
1242                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1243                 comes back."
1244            }
1245            Self::Secondary => {
1246                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1247                 primary budget and does not report this one, so budget showing there says \
1248                 nothing about this refusal, and every further attempt extends it."
1249            }
1250        }
1251    }
1252
1253    /// The next step this limiter actually calls for.
1254    const fn what_to_do(self) -> &'static str {
1255        match self {
1256            Self::Primary => {
1257                "wait for the reset `gh api rate_limit` reports, then run the command again."
1258            }
1259            Self::Secondary => {
1260                "leave this board alone for a few minutes, then run the command again — or \
1261                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1262            }
1263        }
1264    }
1265}
1266
1267/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1268#[derive(Debug, Clone, Copy)]
1269struct Limited {
1270    limiter: Limiter,
1271    hint: Option<u64>,
1272}
1273
1274impl Limited {
1275    /// What the caller is told once this source has waited as long as it may.
1276    ///
1277    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1278    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1279    /// about *which* limiter it was makes it a different kind of failure. What differs is
1280    /// the operator's next step, and that is what the message carries — a secondary
1281    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1282    /// budget looks fine, and then back to retry the very burst that was refused.
1283    fn exhausted(
1284        self,
1285        doing: &str,
1286        waits: u32,
1287        waited: Duration,
1288        needed: Duration,
1289        budget: Duration,
1290    ) -> SourceError {
1291        SourceError::RateLimited {
1292            retry_after_seconds: self.hint,
1293            message: Some(format!(
1294                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1295                 again, and the next wait of {} would take it past the {} one call may spend \
1296                 waiting. {} next: {}",
1297                self.limiter.name(),
1298                plural(waits, "refusal"),
1299                seconds(waited),
1300                seconds(needed),
1301                seconds(budget),
1302                self.limiter.where_to_look(),
1303                self.limiter.what_to_do(),
1304            )),
1305        }
1306    }
1307}
1308
1309/// One HTTP attempt's result, with what its response said about the rate limit.
1310///
1311/// The two travel together so the record and the outcome are written from the same place:
1312/// what a response said about the budget is only readable while that response is in hand,
1313/// and what the attempt *meant* is only decidable once its body has been read.
1314struct Attempted {
1315    result: Result<Value, Attempt>,
1316    limits: accounting::RateLimit,
1317    /// GitHub's own reported cost for this call, for a document that asked for it.
1318    reported_cost: Option<u64>,
1319}
1320
1321/// One attempt's outcome: an error to report, or a rate limit to wait out.
1322enum Attempt {
1323    Failed(SourceError),
1324    Limited(Limited),
1325}
1326
1327fn plural(count: u32, thing: &str) -> String {
1328    if count == 1 {
1329        format!("{count} {thing}")
1330    } else {
1331        format!("{count} {thing}s")
1332    }
1333}
1334
1335fn seconds(duration: Duration) -> String {
1336    format!("{:.1}s", duration.as_secs_f64())
1337}
1338
1339/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1340///
1341/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1342/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1343/// header, and neither is what makes a response a refusal — so the whole cost of one this
1344/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1345/// instead. Refusing the response over the header would turn a readable refusal into an
1346/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1347fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1348    value
1349        .and_then(|value| value.to_str().ok())
1350        .and_then(|value| value.trim().parse::<u64>().ok())
1351}
1352
1353/// Every mutation this source sends creates content — an issue, a board item, a field of
1354/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1355/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1356/// and what the keyword says are the same set. That is what makes the keyword a sound test
1357/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1358/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1359fn is_mutation(query: &str) -> bool {
1360    query.trim_start().starts_with("mutation")
1361}
1362
1363/// What this source was doing, for a diagnostic that has to say so.
1364///
1365/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1366/// a document added without a description is caught by that list's own gate instead of
1367/// falling through to the vague arm below.
1368fn operation_description(query: &str) -> &'static str {
1369    graphql::DOCUMENTS
1370        .iter()
1371        .find(|(document, _)| *document == query)
1372        .map_or("talking to GitHub", |(_, doing)| *doing)
1373}
1374
1375/// GitHub's published ceiling on content-generating requests, per minute.
1376///
1377/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1378/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1379/// from it, so a pacing value checked only against itself cannot go stale here.
1380pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1381/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1382/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1383/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1384pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1385/// Shortest interval between two content-creating mutations, in milliseconds.
1386///
1387/// GitHub documents two secondary limits on content-generating requests:
1388/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1389/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1390/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1391/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1392/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1393/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1394/// deliberately *not* what this paces at. An installation that wants the hourly bound
1395/// honoured for a long sequence of copies says so through
1396/// `pacing.min_mutation_interval_ms`.
1397pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1398/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1399///
1400/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1401/// own advice for a secondary limit — wait, and wait longer each time — without spending
1402/// the first minute of a transient refusal doing nothing.
1403pub const RETRY_BACKOFF_MS: u64 = 1_000;
1404/// Total time one call may spend waiting out rate limits before it reports a failure.
1405///
1406/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1407/// short enough that a command an operator is watching returns. The bound is what makes
1408/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1409/// the limiter, not in a process nobody can tell from a wedged one.
1410pub const RETRY_BUDGET_MS: u64 = 120_000;
1411
1412fn default_token_env() -> String {
1413    "GH_PROJECTS_TOKEN".to_owned()
1414}
1415fn default_endpoint() -> String {
1416    "https://api.github.com/graphql".to_owned()
1417}
1418
1419/// Where one status category lands on this board.
1420///
1421/// `null` — an absent value — disables the category for this instance, and using a
1422/// disabled status is a refusal naming the status and the instance.
1423#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1424#[serde(untagged)]
1425pub enum StatusTargetConfig {
1426    /// The name of a `Status` single-select option already on the board.
1427    Column(ColumnName),
1428}
1429
1430/// The name of a `Status` single-select option on the board.
1431///
1432/// Validated on the way in rather than checked later, so a blank option name — which
1433/// nothing on a board can be — is a state this type cannot hold.
1434#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1435#[serde(try_from = "String")]
1436#[schemars(extend("minLength" = 1))]
1437pub struct ColumnName(String);
1438
1439impl ColumnName {
1440    /// The option name, as the board spells it.
1441    fn as_str(&self) -> &str {
1442        &self.0
1443    }
1444}
1445
1446impl TryFrom<String> for ColumnName {
1447    type Error = String;
1448
1449    fn try_from(name: String) -> Result<Self, Self::Error> {
1450        if name.trim().is_empty() {
1451            return Err("a status_mapping option name cannot be blank".to_owned());
1452        }
1453        Ok(Self(name))
1454    }
1455}
1456
1457/// The two closed states this product can mean.
1458///
1459/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1460/// work nor abandoned work, so nothing here ever writes it.
1461#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1462#[serde(rename_all = "kebab-case")]
1463pub enum ClosedState {
1464    /// `COMPLETED` — precisely done.
1465    Completed,
1466    /// `NOT_PLANNED` — precisely cancelled.
1467    NotPlanned,
1468}
1469
1470impl ClosedState {
1471    const fn reason(self) -> &'static str {
1472        match self {
1473            Self::Completed => "COMPLETED",
1474            Self::NotPlanned => "NOT_PLANNED",
1475        }
1476    }
1477}
1478
1479/// Configuration for one GitHub Projects v2 board.
1480#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1481#[serde(default, deny_unknown_fields)]
1482pub struct GitHubProjectsConfig {
1483    /// Login of the user or organization which owns the board.
1484    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1485    /// The project number shown in the board's GitHub URL.
1486    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1487    // 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.
1488    /// `owner/name` of the repository this source creates an issue in when the item's own
1489    /// `repositories` field does not decide it.
1490    ///
1491    /// An item naming exactly one repository is created there; a task or a document naming
1492    /// none or several is created in its parent project's repository; and a project, or a
1493    /// task or document with no parent, naming none or several is created here. A board
1494    /// has no repository of its own and `createIssue` requires one, so a write without
1495    /// this is refused naming the field. Reads never need it.
1496    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1497    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1498    /// Environment variable containing a fine-grained token with Projects and Issues
1499    /// read/write plus Pull requests read-only access for every repository represented on
1500    /// the board.
1501    #[serde(default = "default_token_env")]
1502    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1503    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1504    #[serde(default = "default_endpoint")]
1505    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1506    /// Per-instance mapping from a status category to where it lands on this board.
1507    ///
1508    /// A category this does not mention keeps its shipped default: `backlog` to
1509    /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1510    /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1511    /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1512    /// board option; every unknown word then lands on that option and reads back as
1513    /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1514    /// word because it never creates board options.
1515    #[serde(default)]
1516    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.
1517    /// Per-instance mapping from a task's priority to an option of this board's
1518    /// single-select field named `Priority`.
1519    ///
1520    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1521    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1522    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1523    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1524    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1525    /// no two levels may name one option. Reads and writes never create the field or an
1526    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1527    /// the board lacks is refused pointing there.
1528    #[serde(default)]
1529    pub priority_mapping: Option<PriorityMappingConfig>,
1530    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1531    ///
1532    /// Every field keeps its shipped default when it is absent, and the defaults are
1533    /// GitHub's own published limits rather than taste. See [`Pacing`].
1534    #[serde(default)]
1535    pub pacing: PacingConfig,
1536}
1537
1538/// Which option of the board's `Priority` field each priority lands on.
1539///
1540/// One member per level rather than a map, so a key that is not a level is refused where
1541/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1542/// value in the field, not an option of it.
1543#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1544#[serde(default, deny_unknown_fields)]
1545pub struct PriorityMappingConfig {
1546    /// The option `urgent` lands on; `Urgent` when absent.
1547    pub urgent: Option<PriorityOptionName>,
1548    /// The option `high` lands on; `High` when absent.
1549    pub high: Option<PriorityOptionName>,
1550    /// The option `medium` lands on; `Medium` when absent.
1551    pub medium: Option<PriorityOptionName>,
1552    /// The option `low` lands on; `Low` when absent.
1553    pub low: Option<PriorityOptionName>,
1554}
1555
1556/// The name of an option of the board's `Priority` single-select field.
1557///
1558/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1559/// blank name.
1560#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1561#[serde(try_from = "String")]
1562#[schemars(extend("minLength" = 1))]
1563pub struct PriorityOptionName(String);
1564
1565impl PriorityOptionName {
1566    /// The option name, as the board spells it.
1567    fn as_str(&self) -> &str {
1568        &self.0
1569    }
1570}
1571
1572impl TryFrom<String> for PriorityOptionName {
1573    type Error = String;
1574
1575    fn try_from(name: String) -> Result<Self, Self::Error> {
1576        if name.trim().is_empty() {
1577            return Err("a priority_mapping option name cannot be blank".to_owned());
1578        }
1579        Ok(Self(name))
1580    }
1581}
1582
1583/// The name of the board field a priority is held in.
1584pub const PRIORITY_FIELD: &str = "Priority";
1585
1586/// The four priorities a board option can hold, in the order a new `Priority` field lists
1587/// them. `none` is not among them: it is the field holding no value.
1588///
1589/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1590/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1591/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1592/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1593pub const PRIORITY_LEVELS: [Priority; 4] = [
1594    Priority::Urgent,
1595    Priority::High,
1596    Priority::Medium,
1597    Priority::Low,
1598];
1599
1600/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1601/// see that list for what this pins.
1602#[must_use]
1603pub const fn level_position(priority: Priority) -> Option<usize> {
1604    match priority {
1605        Priority::None => None,
1606        Priority::Urgent => Some(0),
1607        Priority::High => Some(1),
1608        Priority::Medium => Some(2),
1609        Priority::Low => Some(3),
1610    }
1611}
1612
1613/// This instance's complete priority-to-option mapping, read in both directions.
1614///
1615/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1616/// two levels name one option.
1617#[derive(Debug, Clone)]
1618struct PriorityMapping {
1619    options: [PriorityOptionName; 4],
1620}
1621
1622impl PriorityMapping {
1623    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1624        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1625        let mapping = Self {
1626            options: [
1627                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1628                config.high.unwrap_or_else(|| shipped("High")),
1629                config.medium.unwrap_or_else(|| shipped("Medium")),
1630                config.low.unwrap_or_else(|| shipped("Low")),
1631            ],
1632        };
1633        for (index, option) in mapping.options.iter().enumerate() {
1634            if let Some(earlier) = mapping.options[..index]
1635                .iter()
1636                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1637            {
1638                return Err(SourceError::Config {
1639                    message: format!(
1640                        "priority_mapping of source {instance} sends both {} and {} to the board \
1641                         option {:?}; one option cannot read back as two priorities",
1642                        PRIORITY_LEVELS[earlier],
1643                        PRIORITY_LEVELS[index],
1644                        option.as_str()
1645                    ),
1646                });
1647            }
1648        }
1649        Ok(mapping)
1650    }
1651
1652    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1653    fn option(&self, priority: Priority) -> Option<&str> {
1654        level_position(priority).map(|index| self.options[index].as_str())
1655    }
1656
1657    /// The priority a board option name reports, or `None` when nothing maps to it.
1658    fn priority_of(&self, option: &str) -> Option<Priority> {
1659        self.options
1660            .iter()
1661            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1662            .map(|index| PRIORITY_LEVELS[index])
1663    }
1664
1665    /// Every mapped option name, in the order a new `Priority` field lists them.
1666    fn names(&self) -> impl Iterator<Item = &str> {
1667        self.options.iter().map(PriorityOptionName::as_str)
1668    }
1669}
1670
1671/// What one item's `Priority` field says, read through this instance's mapping.
1672#[derive(Debug, Clone, PartialEq, Eq)]
1673enum HeldPriority {
1674    /// A priority this source reports: an option the mapping names, or no value (`none`).
1675    Read(Priority),
1676    /// An option the mapping does not name, which is never read as a level or as `none`.
1677    Unmapped(String),
1678}
1679
1680/// How fast this source writes, and how long it waits out a rate-limit refusal.
1681///
1682/// Configurable because a GitHub Enterprise installation sets its own limits and an
1683/// operator who has already been refused may want to go slower still — not because the
1684/// defaults are guesses.
1685#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1686#[serde(default, deny_unknown_fields)]
1687pub struct PacingConfig {
1688    /// Shortest interval between two content-creating mutations, in milliseconds.
1689    ///
1690    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1691    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1692    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.
1693    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1694    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1695    /// zero while there is a budget to spend, because a schedule of zero-length waits
1696    /// consumes none of it and so never ends.
1697    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.
1698    /// Total time one call may spend waiting out rate limits, in milliseconds.
1699    ///
1700    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1701    /// the bound is what makes this a wait rather than a hang.
1702    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.
1703}
1704
1705/// The largest any pacing setting may be, in milliseconds.
1706///
1707/// One hour. GitHub's own harshest published bound on content-generating requests works
1708/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1709/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1710/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1711/// and an interval beyond it is a command that never sends its second mutation. It also
1712/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1713/// what an `Instant` can hold on every platform.
1714pub const MAX_PACING_MS: u64 = 3_600_000;
1715
1716/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1717/// source holds.
1718#[derive(Debug, Clone, Copy)]
1719struct Pacing {
1720    min_mutation_interval: Duration,
1721    retry_backoff: Duration,
1722    retry_budget: Duration,
1723}
1724
1725impl Pacing {
1726    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1727    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1728        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1729            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1730                message: format!(
1731                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1732                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1733                     GitHub's own harshest published limit"
1734                ),
1735            }),
1736            Some(value) => Ok(Duration::from_millis(value)),
1737            None => Ok(Duration::from_millis(default)),
1738        };
1739        let retry_backoff = bounded(
1740            config.retry_backoff_ms,
1741            RETRY_BACKOFF_MS,
1742            "retry_backoff_ms",
1743        )?;
1744        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1745        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1746            return Err(SourceError::Config {
1747                message: format!(
1748                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1749                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1750                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1751                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1752                     waiting at all",
1753                    retry_budget.as_millis()
1754                ),
1755            });
1756        }
1757        Ok(Self {
1758            min_mutation_interval: bounded(
1759                config.min_mutation_interval_ms,
1760                MIN_MUTATION_INTERVAL_MS,
1761                "min_mutation_interval_ms",
1762            )?,
1763            retry_backoff,
1764            retry_budget,
1765        })
1766    }
1767}
1768
1769/// Factory for [`GitHubProjectsSource`].
1770#[derive(Debug, Clone, Copy, Default)]
1771pub struct Plugin;
1772
1773impl SourcePlugin for Plugin {
1774    fn kind(&self) -> &'static str {
1775        KIND
1776    }
1777    fn config_schema(&self) -> Schema {
1778        schema_for!(GitHubProjectsConfig)
1779    }
1780    fn build(
1781        &self,
1782        name: &SourceName,
1783        config: &Value,
1784        secrets: &dyn SecretResolver,
1785    ) -> Result<Box<dyn TaskSource>, SourceError> {
1786        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1787    }
1788}
1789
1790impl Plugin {
1791    /// Build a source recording every request it sends into an accounting the caller holds.
1792    ///
1793    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
1794    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
1795    /// session total rather than two — see [`accounting`] and
1796    /// [`GitHubProjectsSource::recording_into`].
1797    ///
1798    /// # Errors
1799    ///
1800    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
1801    /// [`SourceError::Config`] for configuration this plugin cannot use and
1802    /// [`SourceError::Auth`] for a credential it cannot find.
1803    pub fn build_recording_into(
1804        &self,
1805        name: &SourceName,
1806        config: &Value,
1807        secrets: &dyn SecretResolver,
1808        ledger: Arc<Accounting>,
1809    ) -> Result<Box<dyn TaskSource>, SourceError> {
1810        let config: GitHubProjectsConfig =
1811            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1812                message: format!("source {name}: {e}"),
1813            })?;
1814        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
1815            |error| match error {
1816                SourceError::Config { message } => SourceError::Config {
1817                    message: format!("source {name}: {message}"),
1818                },
1819                SourceError::Auth { message } => SourceError::Auth {
1820                    message: format!("source {name}: {message}"),
1821                },
1822                other => other,
1823            },
1824        )?;
1825        Ok(Box::new(source))
1826    }
1827}
1828
1829/// Where a status category lands on this board, once configuration is resolved.
1830#[derive(Debug, Clone, PartialEq, Eq)]
1831enum StatusTarget {
1832    /// Not usable against this instance.
1833    Disabled,
1834    /// The board's `Status` option of this name.
1835    Column(ColumnName),
1836    /// A closed issue, with both its board option and the reason that says which closed it means.
1837    // 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.
1838    Terminal(ColumnName, ClosedState),
1839}
1840
1841/// Every status category, in the order the vocabulary declares them.
1842///
1843/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
1844/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
1845/// added to the shared vocabulary fails to compile until it is named there, and this
1846/// crate's suite reconciles this list against that enum's own derived schema, which is
1847/// generated from the variants rather than written beside them. The schema is what
1848/// catches a list left one short — a list checking only the positions it already holds
1849/// would pass while every mapping indexed by the new position panicked.
1850pub const CATEGORIES: [StatusCategory; 8] = [
1851    StatusCategory::Draft,
1852    StatusCategory::Backlog,
1853    StatusCategory::Todo,
1854    StatusCategory::Queued,
1855    StatusCategory::InProgress,
1856    StatusCategory::Done,
1857    StatusCategory::Cancelled,
1858    StatusCategory::Unknown,
1859];
1860
1861/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
1862#[must_use]
1863pub const fn category_position(category: StatusCategory) -> usize {
1864    match category {
1865        StatusCategory::Draft => 0,
1866        StatusCategory::Backlog => 1,
1867        StatusCategory::Todo => 2,
1868        StatusCategory::Queued => 3,
1869        StatusCategory::InProgress => 4,
1870        StatusCategory::Done => 5,
1871        StatusCategory::Cancelled => 6,
1872        StatusCategory::Unknown => 7,
1873    }
1874}
1875
1876/// The spelling a status category is configured and reported under.
1877fn category_name(category: StatusCategory) -> &'static str {
1878    match category {
1879        StatusCategory::Draft => "draft",
1880        StatusCategory::Backlog => "backlog",
1881        StatusCategory::Todo => "todo",
1882        StatusCategory::Queued => "queued",
1883        StatusCategory::InProgress => "in-progress",
1884        StatusCategory::Done => "done",
1885        StatusCategory::Cancelled => "cancelled",
1886        StatusCategory::Unknown => "unknown",
1887    }
1888}
1889
1890/// A shipped default's option name.
1891///
1892/// The literals below are this file's own and non-blank, and they are validated by the
1893/// one constructor a configured name goes through rather than beside it.
1894fn shipped_column(name: &'static str) -> ColumnName {
1895    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
1896}
1897
1898/// The shipped default for one category, before this instance's configuration.
1899fn shipped_default(category: StatusCategory) -> StatusTarget {
1900    match category {
1901        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
1902        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
1903        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
1904        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
1905        StatusCategory::Done => {
1906            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
1907        }
1908        StatusCategory::Cancelled => {
1909            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
1910        }
1911        StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
1912    }
1913}
1914
1915/// This instance's complete category-to-target mapping, read in both directions.
1916///
1917/// One target per category, held at that category's own [`category_position`], so a
1918/// category missing from the mapping, named twice in it, or filed out of order is a
1919/// state this type cannot hold rather than one [`Self::target`] has to defend against.
1920#[derive(Debug, Clone)]
1921struct StatusMapping {
1922    targets: [StatusTarget; CATEGORIES.len()],
1923}
1924
1925impl StatusMapping {
1926    fn resolve(
1927        configured: BTreeMap<String, Option<StatusTargetConfig>>,
1928        instance: &SourceName,
1929    ) -> Result<Self, SourceError> {
1930        let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
1931        for (key, value) in configured {
1932            let category = CATEGORIES
1933                .iter()
1934                .find(|category| category_name(**category) == key)
1935                .ok_or_else(|| SourceError::Config {
1936                    message: format!(
1937                        "status_mapping names {key:?}, which is not a status category of source \
1938                         {instance}; the categories are {}",
1939                        CATEGORIES
1940                            .iter()
1941                            .map(|category| category_name(*category))
1942                            .collect::<Vec<_>>()
1943                            .join(", ")
1944                    ),
1945                })?;
1946            overrides.insert(category_name(*category), value);
1947        }
1948        // `CATEGORIES[position] == category` for every category — the crate's suite
1949        // asserts it — so mapping the list in order fills each category's own slot.
1950        let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
1951            None => shipped_default(category),
1952            Some(None) => StatusTarget::Disabled,
1953            Some(Some(StatusTargetConfig::Column(option))) => match category {
1954                StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
1955                StatusCategory::Cancelled => {
1956                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
1957                }
1958                _ => StatusTarget::Column(option),
1959            },
1960        });
1961        let mapping = Self { targets };
1962        for (index, category) in CATEGORIES.into_iter().enumerate() {
1963            let option = match mapping.target(category) {
1964                StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
1965                StatusTarget::Disabled => continue,
1966            };
1967            if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
1968                matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1969                    if name.as_str().eq_ignore_ascii_case(option.as_str()))
1970            }) {
1971                return Err(SourceError::Config {
1972                    message: format!(
1973                        "status_mapping of source {instance} sends both {} and {} to the board \
1974                         option {:?}; one option cannot read back as two categories",
1975                        category_name(*other),
1976                        category_name(category),
1977                        option.as_str()
1978                    ),
1979                });
1980            }
1981        }
1982        Ok(mapping)
1983    }
1984
1985    fn target(&self, category: StatusCategory) -> &StatusTarget {
1986        &self.targets[category_position(category)]
1987    }
1988
1989    /// The category a board option name reports, or `None` when nothing maps to it.
1990    fn category_of(&self, option: &str) -> Option<StatusCategory> {
1991        CATEGORIES.into_iter().find(|category| {
1992            matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1993                if name.as_str().eq_ignore_ascii_case(option))
1994        })
1995    }
1996
1997    /// The status an item reports, from the three things a read of it says: its board
1998    /// `Status` option, whether its issue is closed, and the reason it was closed with.
1999    ///
2000    /// The closed state decides the category and the `Status` option decides the name, so
2001    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2002    /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2003    /// duplicate is not finished work, and calling it done is a lie the next copy would
2004    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2005    /// it is read permissively rather than refused — reads are faithful, and refusals
2006    /// belong on writes.
2007    ///
2008    /// One function of those three rather than of a response, so a narrow status write can
2009    /// answer what a re-read would report by applying it to the state it has just written.
2010    fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2011        if closed {
2012            let category = match reason {
2013                None | Some("COMPLETED") => StatusCategory::Done,
2014                Some("NOT_PLANNED") => StatusCategory::Cancelled,
2015                Some(_) => StatusCategory::Unknown,
2016            };
2017            let fallback = match category {
2018                StatusCategory::Done => "Done",
2019                StatusCategory::Cancelled => "Cancelled",
2020                _ => "Closed",
2021            };
2022            return Status {
2023                category,
2024                name: option.unwrap_or(fallback).to_owned(),
2025            };
2026        }
2027        let name = option.unwrap_or("Open").to_owned();
2028        Status {
2029            category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2030            name,
2031        }
2032    }
2033}
2034
2035// 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.
2036/// One repository this source can create an issue in, as `owner/name`.
2037///
2038/// Every `createIssue` this source sends names one of these: the item's own single
2039/// `repositories` entry, else its parent project issue's repository, else the configured
2040/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2041/// that choice and says what it refuses before `createIssue`.
2042// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2043#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2044struct RepositoryTarget {
2045    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2046    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2047}
2048
2049impl RepositoryTarget {
2050    fn parse(value: &str) -> Result<Self, SourceError> {
2051        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2052            message: format!(
2053                "repository must be spelled owner/name; {value:?} names no repository"
2054            ),
2055        })?;
2056        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2057            return Err(SourceError::Config {
2058                message: format!(
2059                    "repository must be spelled owner/name with a GitHub login and one \
2060                     repository name; {value:?} is not"
2061                ),
2062            });
2063        }
2064        Ok(Self {
2065            owner: owner.to_owned(),
2066            name: name.to_owned(),
2067        })
2068    }
2069
2070    /// The one host whose repositories this source creates issues in, spelled once: it is
2071    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2072    const HOST: &str = "github.com";
2073
2074    fn origin(&self) -> String {
2075        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2076    }
2077
2078    /// The repository a normalized origin names, or why it is none this source can create
2079    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2080    fn from_origin(origin: &Repository) -> Result<Self, String> {
2081        let not_here = || {
2082            format!(
2083                "{} is not a {}/owner/name repository",
2084                origin.as_str(),
2085                Self::HOST
2086            )
2087        };
2088        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2089        if host != Self::HOST {
2090            return Err(not_here());
2091        }
2092        Self::parse(rest).map_err(|_| not_here())
2093    }
2094
2095    fn slug(&self) -> String {
2096        format!("{}/{}", self.owner, self.name)
2097    }
2098}
2099
2100/// A source which reads GitHub afresh for every operation.
2101pub struct GitHubProjectsSource {
2102    /// This source's configured name, used both to tell a far end naming this source
2103    /// from one naming a system it knows nothing about, and to name the instance a
2104    /// status refusal is about.
2105    name: SourceName,
2106    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2107    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2108    repository: Option<RepositoryTarget>,
2109    endpoint: Url,
2110    token: SecretString,
2111    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2112    statuses: StatusMapping,
2113    /// Where each priority lands on this board, or `None` when this instance holds none.
2114    priorities: Option<PriorityMapping>,
2115    client: Client,
2116    /// Every item this source has created since it was built, in the order it created
2117    /// them.
2118    ///
2119    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2120    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2121    /// a copy resolving a dependency on an item it had just created refused it as not
2122    /// found. A board read is completed from this — an item remembered here and absent from
2123    /// the read is added back, because the board really does hold it and only the read is
2124    /// behind.
2125    ///
2126    /// It is not a cache of a user's work: nothing is remembered that this process did not
2127    /// itself just write, it lives and dies with the process, and it is never consulted for
2128    /// an item this source did not create.
2129    created: Mutex<Vec<Resolved>>,
2130    /// Every item that already existed and that this source has written since it was built,
2131    /// as it wrote it.
2132    ///
2133    /// The other half of [`Self::created`], held on the same terms and for the reason a
2134    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2135    /// filter is an index behind a write this process made moments ago, so a query matching
2136    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2137    /// is remembered that this process did not itself just write.
2138    updated: Mutex<Vec<Resolved>>,
2139    /// How fast this source writes, and how long it waits out a refusal.
2140    pacing: Pacing,
2141    /// When the last content-creating mutation finished, or the moment the furthest-out
2142    /// reserved slot releases the next one, whichever is later — so the one after it can be
2143    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2144    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2145    /// what it is measured from.
2146    last_mutation: Mutex<Option<Instant>>,
2147    /// The board as this process last read it, for the length of one command.
2148    ///
2149    /// A copy of a project used to re-read the whole board, paged, before writing each of
2150    /// its items, which is by far the largest part of a copy's request count and none of
2151    /// its work. Nothing else changes this board while a command runs — this source's own
2152    /// writes are the only writer — so one read answers them all.
2153    ///
2154    /// It is not a store of a user's work and it is not the cache the no-persistence
2155    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2156    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2157    /// an item this command created and then depends on resolves whether or not GitHub's
2158    /// own eventually-consistent read has caught up. A write to an item already on the
2159    /// board updates the entry here too, so what this holds is the last read plus this
2160    /// process's own writes rather than a snapshot taken before them.
2161    board_cache: Mutex<Option<Board>>,
2162    /// Every issue this board's own search reported, for the length of one command.
2163    ///
2164    /// The second half of a board read, and cached for the same reason and on the same
2165    /// terms as the first: it lives and dies with the process, nothing is written down, and
2166    /// a write this process makes updates the entry here exactly as it updates the one in
2167    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2168    /// that lists this board's projects and its tasks pays for one search rather than two.
2169    search_cache: Mutex<Option<Vec<Resolved>>>,
2170    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2171    /// the length of one command.
2172    ///
2173    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2174    /// and dies with the process, nothing is written down, a write this process makes
2175    /// updates the entry here as it updates the other two, and every answer is completed
2176    /// with this process's own writes each time it is given. A command that asks the same
2177    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2178    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2179    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2180    search_next: Mutex<BTreeMap<String, Option<String>>>,
2181    /// Records already resolved in this source instance, reused by writes and
2182    /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2183    resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2184    /// The board's own id and field definitions as this process last read them on their
2185    /// own, for the length of one command.
2186    ///
2187    /// What a write needs of the board and its item does not say, read once per command
2188    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2189    /// lives and dies with the process and nothing is written down. It holds no item and so
2190    /// can answer no question about one — see [`Self::board_fields`].
2191    fields_cache: Mutex<Option<BoardFields>>,
2192    /// Each destination repository's node id, resolved once per repository
2193    /// rather than per issue created.
2194    ///
2195    /// A repository's node id does not change, and re-reading it for every issue of a copy
2196    /// spent one request per item on an answer this source already had. It is a map rather
2197    /// than one entry because a copy files each item in the repository its own
2198    /// `repositories` field names, so a plan across five repositories asks GitHub five
2199    /// times and not once per item.
2200    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2201    /// What every request this source sends is recorded into.
2202    ///
2203    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2204    /// a request leaves this crate, so nothing has to be switched on for a session to be
2205    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2206    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2207    /// source's reads and writes — adds up one accounting instead of two. See
2208    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2209    ledger: Arc<Accounting>,
2210}
2211
2212/// GitHub's closed single-select color vocabulary.
2213#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2214#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2215pub enum StatusOptionColor {
2216    /// Gray.
2217    Gray,
2218    /// Blue.
2219    Blue,
2220    /// Green.
2221    Green,
2222    /// Yellow.
2223    Yellow,
2224    /// Purple.
2225    Purple,
2226    /// Red.
2227    Red,
2228    /// Orange.
2229    Orange,
2230    /// Pink.
2231    Pink,
2232}
2233
2234/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2235/// applies its additions.
2236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2237pub enum SetupMode {
2238    /// Read without mutation.
2239    Plan,
2240    /// Apply and verify.
2241    Apply,
2242}
2243
2244/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2245/// against it goes on compiling.
2246pub type StatusOptionsMode = SetupMode;
2247
2248/// The explicit result of the requested operation.
2249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2250#[serde(rename_all = "kebab-case")]
2251pub enum StatusOptionsOutcome {
2252    /// A read-only plan.
2253    Planned,
2254    /// Apply found nothing missing.
2255    Unchanged,
2256    /// Additions were applied and verified.
2257    Applied,
2258}
2259
2260/// A GitHub single-select option's opaque GraphQL node identifier.
2261#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2262#[serde(transparent)]
2263pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2264
2265impl TryFrom<String> for StatusOptionId {
2266    type Error = String;
2267
2268    fn try_from(id: String) -> Result<Self, Self::Error> {
2269        if id.trim().is_empty() {
2270            return Err("a GitHub Status option id cannot be blank".to_owned());
2271        }
2272        Ok(Self(id))
2273    }
2274}
2275
2276/// One existing or proposed option in a guarded Status-field update.
2277#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2278pub struct StatusOption {
2279    /// GitHub's stable id.
2280    pub id: StatusOptionId,
2281    /// The visible option name.
2282    pub name: ColumnName,
2283    /// GitHub's single-select color token.
2284    pub color: StatusOptionColor,
2285    /// The option description, including an empty one.
2286    pub description: String,
2287}
2288
2289/// One board item's Status assignment, retained as recovery data.
2290#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2291pub struct StatusAssignment {
2292    /// The project item id whose assignment this is.
2293    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2294    // carried verbatim as operator recovery data; introducing a semantic type would claim
2295    // validation rules GitHub does not publish and no operation here interprets.
2296    pub item_id: String,
2297    /// The selected option, absent when the item has no status.
2298    #[serde(skip_serializing_if = "Option::is_none")]
2299    pub option: Option<AssignedStatusOption>,
2300}
2301
2302/// The inseparable id and name of an assigned option.
2303#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2304pub struct AssignedStatusOption {
2305    /// GitHub's stable id.
2306    pub id: StatusOptionId,
2307    /// The visible name.
2308    pub name: ColumnName,
2309}
2310
2311/// The plan and verified outcome of reconciling configured Status options.
2312#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2313pub struct StatusOptionsReport {
2314    /// The configured source name.
2315    pub source: SourceName,
2316    /// Configured option names absent before the operation.
2317    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2318    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2319    // serialized string here preserves the report's intentionally simple public contract.
2320    pub missing: Vec<String>,
2321    /// What the requested operation did.
2322    pub outcome: StatusOptionsOutcome,
2323    /// The complete option list observed before any mutation.
2324    pub existing: Vec<StatusOption>,
2325}
2326
2327#[derive(Debug, Clone, PartialEq, Eq)]
2328struct StatusSnapshot {
2329    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2330    // passed back as the mutation's project identity; a newtype could enforce no stronger
2331    // invariant because GitHub publishes no grammar for it.
2332    board_id: String,
2333    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2334    // passed back as the mutation's field identity; a newtype could enforce no stronger
2335    // invariant because GitHub publishes no grammar for it.
2336    field_id: String,
2337    options: Vec<StatusOption>,
2338    assignments: Vec<StatusAssignment>,
2339}
2340
2341/// The name of the board field a status is held in.
2342const STATUS_FIELD: &str = "Status";
2343
2344/// Every item's value of each field `report` names, as it stood before the setup wrote
2345/// anything — what a person puts back when the setup is refused part way.
2346fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2347    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2348        .fields
2349        .iter()
2350        .map(|field| (field.field.name(), before.assignments(field.field)))
2351        .collect();
2352    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2353        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2354    })
2355}
2356
2357/// One board field the guarded setup reads and writes — every one it reads, and the only
2358/// ones it writes.
2359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2360pub enum BoardField {
2361    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2362    Status,
2363    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2364    Priority,
2365}
2366
2367impl BoardField {
2368    /// The field's name on the board.
2369    #[must_use]
2370    pub const fn name(self) -> &'static str {
2371        match self {
2372            Self::Status => STATUS_FIELD,
2373            Self::Priority => PRIORITY_FIELD,
2374        }
2375    }
2376
2377    /// The field a board calls `name`, or `None` for one this setup does not own.
2378    fn named(name: &str) -> Option<Self> {
2379        [Self::Status, Self::Priority]
2380            .into_iter()
2381            .find(|field| field.name() == name)
2382    }
2383}
2384
2385/// What the guarded setup did to one field.
2386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2387#[serde(rename_all = "kebab-case")]
2388pub enum FieldOutcome {
2389    /// A read-only plan.
2390    Planned,
2391    /// Apply found the field there with every configured option.
2392    Unchanged,
2393    /// Missing options were added to the field that was there, and verified.
2394    Applied,
2395    /// The field was not there; it was created holding the configured options, and verified.
2396    Created,
2397}
2398
2399/// One field's plan, or its verified outcome.
2400#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2401pub struct FieldReport {
2402    /// Which field.
2403    pub field: BoardField,
2404    /// Whether the board had the field before the operation.
2405    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2406    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2407    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2408    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2409    // one constructor, and it derives `outcome` from `exists` in one match.
2410    pub exists: bool,
2411    /// Configured option names the field lacked before the operation — every one of them,
2412    /// in the order a new field lists them, when the field was not there at all.
2413    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2414    // mapping name and has therefore already passed its nonblank validation; the serialized
2415    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2416    pub missing: Vec<String>,
2417    /// What the requested operation did.
2418    pub outcome: FieldOutcome,
2419    /// The field's complete option list observed before any mutation; empty when the field
2420    /// was not there.
2421    pub existing: Vec<StatusOption>,
2422}
2423
2424/// The plan and verified outcome of setting up every field a source's configuration names.
2425#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2426pub struct FieldsReport {
2427    /// The configured source name.
2428    pub source: SourceName,
2429    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2430    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2431    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2432    // per field would change a published JSON shape. The states the list could hold and the
2433    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2434    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2435    pub fields: Vec<FieldReport>,
2436}
2437
2438/// Which options one field is configured with, in the order a new field would list them.
2439struct FieldPlan {
2440    field: BoardField,
2441    wanted: Vec<String>,
2442}
2443
2444/// One single-select field as the guarded setup snapshots it.
2445#[derive(Debug, Clone, PartialEq, Eq)]
2446struct SnapshotField {
2447    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2448    // passed back as the mutation's field identity; a newtype could enforce no stronger
2449    // invariant because GitHub publishes no grammar for it.
2450    field_id: String,
2451    options: Vec<StatusOption>,
2452}
2453
2454/// Every single-select field of a board and every item's value of each.
2455#[derive(Debug, Clone, PartialEq, Eq)]
2456struct BoardSnapshot {
2457    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2458    // passed back as the mutation's project identity; a newtype could enforce no stronger
2459    // invariant because GitHub publishes no grammar for it.
2460    board_id: String,
2461    fields: BTreeMap<BoardField, SnapshotField>,
2462    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2463    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2464}
2465
2466impl BoardSnapshot {
2467    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2468    /// carries.
2469    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2470        self.items
2471            .iter()
2472            .map(|(item_id, values)| StatusAssignment {
2473                item_id: item_id.clone(),
2474                option: values.get(&field).cloned(),
2475            })
2476            .collect()
2477    }
2478}
2479
2480impl GitHubProjectsSource {
2481    /// Report missing configured Status options and, when `apply` is true, add them with
2482    /// a whole-list mutation that preserves every existing id and verifies the result.
2483    ///
2484    /// # Errors
2485    ///
2486    /// Refuses a board without a single-select `Status` field. A post-write difference in
2487    /// any pre-existing option id or item assignment is refused with the complete pre-write
2488    /// assignment snapshot in the diagnostic for recovery.
2489    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2490    // successful mutation, both drift refusals, source selection, missing Status, casing,
2491    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2492    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2493    // responses from entering the defensive malformed-response branches below.
2494    pub async fn status_options(
2495        &self,
2496        mode: StatusOptionsMode,
2497    ) -> Result<StatusOptionsReport, SourceError> {
2498        let before = self.status_snapshot().await?;
2499        let configured = self
2500            .statuses
2501            .targets
2502            .iter()
2503            // A terminal category's option is as configured as an open one's: a terminal
2504            // write validates it before closing and refuses when the board lacks it.
2505            .filter_map(|target| match target {
2506                StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2507                    Some(name.as_str().to_owned())
2508                }
2509                StatusTarget::Disabled => None,
2510            });
2511        let missing = configured
2512            .filter(|wanted| {
2513                !before
2514                    .options
2515                    .iter()
2516                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2517            })
2518            .collect::<Vec<_>>();
2519        let report = StatusOptionsReport {
2520            source: self.name.clone(),
2521            missing: missing.clone(),
2522            outcome: match (mode, missing.is_empty()) {
2523                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2524                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2525                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2526            },
2527            existing: before.options.clone(),
2528        };
2529        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2530            return Ok(report);
2531        }
2532        let mut options = before
2533            .options
2534            .iter()
2535            .map(|option| {
2536                json!({
2537                    "id": option.id, "name": option.name, "color": option.color,
2538                    "description": option.description,
2539                })
2540            })
2541            .collect::<Vec<_>>();
2542        options.extend(missing.iter().map(|name| {
2543            json!({
2544                "name": name, "color": "GRAY", "description": ""
2545            })
2546        }));
2547        self.graphql(
2548            graphql::STATUS_OPTIONS_UPDATE,
2549            json!({"input": {
2550                "projectId": before.board_id, "fieldId": before.field_id,
2551                "singleSelectOptions": options,
2552            }}),
2553        )
2554        .await?;
2555        let after = self.status_snapshot().await?;
2556        let options_preserved = before
2557            .options
2558            .iter()
2559            .all(|old| after.options.iter().any(|new| new == old));
2560        let additions_present = missing.iter().all(|wanted| {
2561            after
2562                .options
2563                .iter()
2564                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2565        });
2566        if !options_preserved || !additions_present || after.assignments != before.assignments {
2567            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2568                SourceError::Malformed {
2569                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2570                }
2571            })?;
2572            return Err(SourceError::Refused {
2573                message: format!(
2574                    "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}"
2575                ),
2576            });
2577        }
2578        Ok(report)
2579    }
2580
2581    /// A fresh snapshot of the Status field and every board item's assignment of it.
2582    ///
2583    /// # Errors
2584    ///
2585    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2586    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2587        // Status alone, as this operation has always read it: a `Priority` field is another
2588        // operation's, so nothing about it can refuse this one.
2589        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2590        let field = board
2591            .fields
2592            .remove(&BoardField::Status)
2593            .ok_or_else(|| self.no_status_field())?;
2594        Ok(StatusSnapshot {
2595            assignments: board.assignments(BoardField::Status),
2596            board_id: board.board_id,
2597            field_id: field.field_id,
2598            options: field.options,
2599        })
2600    }
2601
2602    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2603    fn no_status_field(&self) -> SourceError {
2604        SourceError::Refused {
2605            message: format!("source {} board has no Status field", self.name),
2606        }
2607    }
2608
2609    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2610    // the real CLI loopback journey, including pagination. The individual malformed guards
2611    // are defensive validation of a schema-pinned third-party response, not separate user
2612    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2613    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2614    /// every board item's value of each, walked to the end of the board's items. A field not
2615    /// in `owned` is read past whatever it holds.
2616    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2617        let mut after: Option<String> = None;
2618        let mut snapshot: Option<BoardSnapshot> = None;
2619        loop {
2620            let data = self
2621                .graphql(
2622                    graphql::STATUS_OPTIONS_SNAPSHOT,
2623                    json!({
2624                        "owner": self.owner, "number": self.project_number,
2625                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2626                    }),
2627                )
2628                .await?;
2629            let board = data
2630                .pointer("/owner/projectV2")
2631                .filter(|board| board.is_object())
2632                .ok_or_else(|| SourceError::Refused {
2633                    message: format!(
2634                        "source {} has no accessible GitHub Projects board",
2635                        self.name
2636                    ),
2637                })?;
2638            if board
2639                .pointer("/fields/pageInfo/hasNextPage")
2640                .and_then(Value::as_bool)
2641                != Some(false)
2642            {
2643                return Err(SourceError::Malformed {
2644                    message:
2645                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2646                            .into(),
2647                });
2648            }
2649            let mut fields = BTreeMap::new();
2650            // Only the fields this setup owns, by name: a node the single-select fragment did not
2651            // match carries no name, and a person's own single-select field — a `Size`, a
2652            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2653            // `Status` or `Priority` field without its options is malformed, not absent.
2654            // 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.
2655            for (owned, field) in board
2656                .pointer("/fields/nodes")
2657                .and_then(Value::as_array)
2658                .ok_or_else(|| SourceError::Malformed {
2659                    message: "GitHub project fields.nodes is not an array".into(),
2660                })?
2661                .iter()
2662                .filter_map(|field| {
2663                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2664                    owned.contains(&named).then_some((named, field))
2665                })
2666            {
2667                let options = field
2668                    .get("options")
2669                    .and_then(Value::as_array)
2670                    .ok_or_else(|| SourceError::Malformed {
2671                        message: "GitHub single-select field options is not an array".into(),
2672                    })?
2673                    .iter()
2674                    .map(|option| {
2675                        Ok(StatusOption {
2676                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2677                                .map_err(|message| SourceError::Malformed { message })?,
2678                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2679                                .map_err(|message| SourceError::Malformed {
2680                                    message: format!(
2681                                        "GitHub single-select option name is invalid: {message}"
2682                                    ),
2683                                })?,
2684                            color: serde_json::from_value(
2685                                option.get("color").cloned().unwrap_or(Value::Null),
2686                            )
2687                            .map_err(|error| {
2688                                SourceError::Malformed {
2689                                    message: format!(
2690                                        "GitHub single-select option color is invalid: {error}"
2691                                    ),
2692                                }
2693                            })?,
2694                            description: optional_str(option, "description")?
2695                                .unwrap_or_default()
2696                                .to_owned(),
2697                        })
2698                    })
2699                    .collect::<Result<Vec<_>, SourceError>>()?;
2700                let snapshot = SnapshotField {
2701                    field_id: required_nonblank_str(field, "id")?.to_owned(),
2702                    options,
2703                };
2704                // A board's field names are unique, so a second one is an answer that cannot
2705                // say which field the setup would act on — refused rather than one chosen.
2706                if fields.insert(owned, snapshot).is_some() {
2707                    return Err(SourceError::Malformed {
2708                        message: format!(
2709                            "GitHub answered two {} fields for this board",
2710                            owned.name()
2711                        ),
2712                    });
2713                }
2714            }
2715            let board_id = required_nonblank_str(board, "id")?.to_owned();
2716            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2717                board_id,
2718                fields,
2719                items: Vec::new(),
2720            });
2721            let items = board
2722                .pointer("/items/nodes")
2723                .and_then(Value::as_array)
2724                .ok_or_else(|| SourceError::Malformed {
2725                    message: "GitHub project items.nodes is not an array".into(),
2726                })?;
2727            for item in items {
2728                let field_values =
2729                    item.get("fieldValues")
2730                        .ok_or_else(|| SourceError::Malformed {
2731                            message: "GitHub project item is missing fieldValues".into(),
2732                        })?;
2733                if field_values
2734                    .pointer("/pageInfo/hasNextPage")
2735                    .and_then(Value::as_bool)
2736                    != Some(false)
2737                {
2738                    return Err(SourceError::Malformed {
2739                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2740                    });
2741                }
2742                let values = item
2743                    .pointer("/fieldValues/nodes")
2744                    .and_then(Value::as_array)
2745                    .ok_or_else(|| SourceError::Malformed {
2746                        message: "GitHub project item fieldValues.nodes is not an array".into(),
2747                    })?;
2748                let item_id = required_nonblank_str(item, "id")?;
2749                let mut assigned = BTreeMap::new();
2750                for value in values {
2751                    let Some(field) = value
2752                        .pointer("/field/name")
2753                        .and_then(Value::as_str)
2754                        .and_then(BoardField::named)
2755                        .filter(|field| owned.contains(field))
2756                    else {
2757                        continue;
2758                    };
2759                    let held = assigned.insert(
2760                        field,
2761                        AssignedStatusOption {
2762                            id: StatusOptionId::try_from(
2763                                required_str(value, "optionId")?.to_owned(),
2764                            )
2765                            .map_err(|message| SourceError::Malformed { message })?,
2766                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2767                                .map_err(|message| SourceError::Malformed {
2768                                    message: format!(
2769                                        "GitHub assigned {} name is invalid: {message}",
2770                                        field.name()
2771                                    ),
2772                                })?,
2773                        },
2774                    );
2775                    // An item holds one value of a field, so a second one leaves no way to
2776                    // tell which it holds — and a verification or recovery built on either
2777                    // could restore the wrong one.
2778                    if held.is_some() {
2779                        return Err(SourceError::Malformed {
2780                            message: format!(
2781                                "GitHub answered two {} values for board item {item_id}",
2782                                field.name()
2783                            ),
2784                        });
2785                    }
2786                }
2787                current.items.push((item_id.to_owned(), assigned));
2788            }
2789            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2790                message: "GitHub project is missing items".into(),
2791            })?;
2792            let has_next = page
2793                .pointer("/pageInfo/hasNextPage")
2794                .and_then(Value::as_bool)
2795                .ok_or_else(|| SourceError::Malformed {
2796                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
2797                })?;
2798            if !has_next {
2799                break;
2800            }
2801            let next =
2802                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
2803            validate_cursor_progress(after.as_deref(), next)?;
2804            after = Some(next.to_owned());
2805        }
2806        snapshot.ok_or_else(|| SourceError::Malformed {
2807            message: "GitHub returned no board field snapshot".into(),
2808        })
2809    }
2810    // llmlint: ignore-end[changed_behavior_has_e2e]
2811
2812    /// Report every board field this source's configuration names and, with
2813    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
2814    /// the `Priority` field when the board has none.
2815    ///
2816    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
2817    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
2818    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
2819    /// color and description: the whole option list goes back with every existing id, because
2820    /// a re-minted id clears every item's value.
2821    ///
2822    /// # Errors
2823    ///
2824    /// Refuses a board without a single-select `Status` field. After an apply the board is
2825    /// read again, and a pre-existing option or any item's value of either field that moved is
2826    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
2827    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
2828    // unchanged apply, a created field, an added option to each field, drift refusal, a board
2829    // with no Status field and a non-github-projects source through the compiled CLI against
2830    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
2831    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
2832        let owned: Vec<BoardField> = if self.priorities.is_some() {
2833            vec![BoardField::Status, BoardField::Priority]
2834        } else {
2835            vec![BoardField::Status]
2836        };
2837        let before = self.board_snapshot(&owned).await?;
2838        let mut plans = vec![FieldPlan {
2839            field: BoardField::Status,
2840            wanted: self
2841                .statuses
2842                .targets
2843                .iter()
2844                .filter_map(|target| match target {
2845                    StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2846                        Some(name.as_str().to_owned())
2847                    }
2848                    StatusTarget::Disabled => None,
2849                })
2850                .collect(),
2851        }];
2852        if !before.fields.contains_key(&BoardField::Status) {
2853            return Err(self.no_status_field());
2854        }
2855        if let Some(mapping) = &self.priorities {
2856            plans.push(FieldPlan {
2857                field: BoardField::Priority,
2858                wanted: mapping.names().map(str::to_owned).collect(),
2859            });
2860        }
2861        // The snapshot reads single-select fields alone, so a field it did not find may still
2862        // be on the board under the name, of another type: creating one beside it would fail
2863        // part way, or leave two fields of one name. Asked of the board's own field list, and
2864        // only when a field is missing.
2865        if plans
2866            .iter()
2867            .any(|plan| !before.fields.contains_key(&plan.field))
2868        {
2869            let board = self.board_fields().await?;
2870            for plan in plans
2871                .iter()
2872                .filter(|plan| !before.fields.contains_key(&plan.field))
2873            {
2874                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
2875                    return Err(SourceError::Refused {
2876                        message: format!(
2877                            "source {}'s board has a {} field that is not a single-select field \
2878                             (it is a {}), so it cannot hold this source's options; next: rename \
2879                             or remove that field, then run this again",
2880                            self.name,
2881                            plan.field.name(),
2882                            optional_str(field, "__typename")?.unwrap_or("field of another type")
2883                        ),
2884                    });
2885                }
2886            }
2887        }
2888        let mut reports = Vec::new();
2889        for plan in &plans {
2890            let held = before.fields.get(&plan.field);
2891            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
2892            let mut missing: Vec<String> = Vec::new();
2893            for wanted in &plan.wanted {
2894                let present = existing
2895                    .iter()
2896                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2897                    || missing
2898                        .iter()
2899                        .any(|named| named.eq_ignore_ascii_case(wanted));
2900                if !present {
2901                    missing.push(wanted.clone());
2902                }
2903            }
2904            reports.push(FieldReport {
2905                field: plan.field,
2906                exists: held.is_some(),
2907                outcome: match (mode, held.is_some(), missing.is_empty()) {
2908                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
2909                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
2910                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
2911                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
2912                },
2913                missing,
2914                existing,
2915            });
2916        }
2917        let report = FieldsReport {
2918            source: self.name.clone(),
2919            fields: reports,
2920        };
2921        let writes: Vec<&FieldReport> = report
2922            .fields
2923            .iter()
2924            .filter(|field| !field.missing.is_empty() || !field.exists)
2925            .collect();
2926        if mode == SetupMode::Plan || writes.is_empty() {
2927            return Ok(report);
2928        }
2929        let mut landed: Vec<&str> = Vec::new();
2930        for field in &writes {
2931            let added = field
2932                .missing
2933                .iter()
2934                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
2935            let sent = match before.fields.get(&field.field) {
2936                Some(held) => {
2937                    let mut options = held
2938                        .options
2939                        .iter()
2940                        .map(|option| {
2941                            json!({
2942                                "id": option.id, "name": option.name, "color": option.color,
2943                                "description": option.description,
2944                            })
2945                        })
2946                        .collect::<Vec<_>>();
2947                    options.extend(added);
2948                    self.graphql(
2949                        graphql::STATUS_OPTIONS_UPDATE,
2950                        json!({"input": {
2951                            "projectId": before.board_id, "fieldId": held.field_id,
2952                            "singleSelectOptions": options,
2953                        }}),
2954                    )
2955                    .await
2956                }
2957                None => {
2958                    self.graphql(
2959                        graphql::CREATE_FIELD,
2960                        json!({"input": {
2961                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
2962                            "name": field.field.name(),
2963                            "singleSelectOptions": added.collect::<Vec<_>>(),
2964                        }}),
2965                    )
2966                    .await
2967                }
2968            };
2969            // A mutation that failed does not establish that GitHub left its field as it was,
2970            // so every failure from here on carries the recovery data a drift refusal does.
2971            match sent {
2972                Ok(_) => landed.push(field.field.name()),
2973                Err(error) => {
2974                    let changed = if landed.is_empty() {
2975                        String::new()
2976                    } else {
2977                        format!("changed the {} field and then ", landed.join(" and "))
2978                    };
2979                    return Err(SourceError::Refused {
2980                        message: format!(
2981                            "the guarded field setup {changed}failed on the {} field, which it may \
2982                             have changed part way: {error}; the pre-write item assignments \
2983                             are:\n{}",
2984                            field.field.name(),
2985                            recovery(&report, &before)?
2986                        ),
2987                    });
2988                }
2989            }
2990        }
2991        // The board has been written, so a verification read that fails leaves it unverified
2992        // rather than unchanged, and says what to put back.
2993        let after = match self.board_snapshot(&owned).await {
2994            Ok(after) => after,
2995            Err(error) => {
2996                return Err(SourceError::Refused {
2997                    message: format!(
2998                        "the guarded field setup changed the {} field and then could not read the \
2999                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
3000                        landed.join(" and "),
3001                        recovery(&report, &before)?
3002                    ),
3003                });
3004            }
3005        };
3006        let mut moved = Vec::new();
3007        for field in &report.fields {
3008            let name = field.field.name();
3009            let now = after
3010                .fields
3011                .get(&field.field)
3012                .map(|held| held.options.as_slice())
3013                .unwrap_or_default();
3014            if !field.existing.iter().all(|old| now.contains(old)) {
3015                moved.push(format!(
3016                    "a pre-existing {name} option id, name, color or description"
3017                ));
3018            }
3019            if !field.missing.iter().all(|wanted| {
3020                now.iter()
3021                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3022            }) {
3023                moved.push(format!("an added {name} option"));
3024            }
3025            if after.assignments(field.field) != before.assignments(field.field) {
3026                moved.push(format!("an item's {name} value"));
3027            }
3028        }
3029        if !moved.is_empty() {
3030            return Err(SourceError::Refused {
3031                message: format!(
3032                    "GitHub changed {} after the guarded field setup; the pre-write item \
3033                     assignments are:\n{}",
3034                    moved.join(", "),
3035                    recovery(&report, &before)?
3036                ),
3037            });
3038        }
3039        Ok(report)
3040    }
3041
3042    /// Validate configuration and capture the named credential without exposing it.
3043    ///
3044    /// # Errors
3045    ///
3046    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3047    /// [`SourceError::Auth`] when the named credential is missing or empty.
3048    pub fn new(
3049        name: &SourceName,
3050        config: GitHubProjectsConfig,
3051        secrets: &dyn SecretResolver,
3052    ) -> Result<Self, SourceError> {
3053        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3054    }
3055
3056    /// The same, recording every request it sends into an accounting the caller holds too.
3057    ///
3058    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3059    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3060    /// up — passes the one it records those into, so the session total accounts for the
3061    /// whole session rather than for this source's share of it.
3062    ///
3063    /// # Errors
3064    ///
3065    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3066    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3067    pub fn recording_into(
3068        name: &SourceName,
3069        config: GitHubProjectsConfig,
3070        secrets: &dyn SecretResolver,
3071        ledger: Arc<Accounting>,
3072    ) -> Result<Self, SourceError> {
3073        if !valid_github_owner(&config.owner) {
3074            return Err(SourceError::Config {
3075                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3076            });
3077        }
3078        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3079            return Err(SourceError::Config {
3080                message: format!("project_number must be between 1 and {}", i32::MAX),
3081            });
3082        }
3083        if !valid_environment_name(&config.token_env) {
3084            return Err(SourceError::Config {
3085                message: "token_env must be a valid environment-variable name".into(),
3086            });
3087        }
3088        let repository = config
3089            .repository
3090            .as_deref()
3091            .map(RepositoryTarget::parse)
3092            .transpose()?;
3093        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3094            message: format!("endpoint is not a valid URL: {e}"),
3095        })?;
3096        if endpoint.scheme() != "https"
3097            && !(endpoint.scheme() == "http"
3098                && endpoint
3099                    .host_str()
3100                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3101        {
3102            return Err(SourceError::Config {
3103                message:
3104                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3105                        .into(),
3106            });
3107        }
3108        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3109            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),
3110        })?;
3111        Ok(Self {
3112            name: name.clone(),
3113            owner: config.owner,
3114            project_number: config.project_number,
3115            repository,
3116            endpoint,
3117            token,
3118            credential_name: config.token_env,
3119            statuses: StatusMapping::resolve(config.status_mapping, name)?,
3120            priorities: config
3121                .priority_mapping
3122                .map(|mapping| PriorityMapping::resolve(mapping, name))
3123                .transpose()?,
3124            client: Client::builder()
3125                .user_agent("onetaskgraph")
3126                .build()
3127                .map_err(|e| SourceError::Config {
3128                    message: format!("cannot build HTTP client: {e}"),
3129                })?,
3130            created: Mutex::new(Vec::new()),
3131            updated: Mutex::new(Vec::new()),
3132            pacing: Pacing::resolve(config.pacing, name)?,
3133            last_mutation: Mutex::new(None),
3134            board_cache: Mutex::new(None),
3135            search_cache: Mutex::new(None),
3136            narrowed_cache: Mutex::new(BTreeMap::new()),
3137            resolved_cache: Mutex::new(BTreeMap::new()),
3138            search_next: Mutex::new(BTreeMap::new()),
3139            fields_cache: Mutex::new(None),
3140            repository_cache: Mutex::new(BTreeMap::new()),
3141            ledger,
3142        })
3143    }
3144
3145    /// A snapshot of every request this source has sent, and what each cost.
3146    ///
3147    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3148    /// of them can sit side by side. When this source was built with
3149    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3150    /// point of building it that way.
3151    #[must_use]
3152    pub fn accounting(&self) -> accounting::Session {
3153        self.ledger.snapshot()
3154    }
3155
3156    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3157    /// rate limit rather than handing it straight back as an error.
3158    ///
3159    /// Retrying is safe for every document here, including the mutations, and the reason
3160    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3161    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3162    /// this replays has already taken effect. An outcome this source cannot know — the
3163    /// send failed, or the body could not be read, so the mutation may well have landed —
3164    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3165    /// attempt. A duplicate write would come from replaying one of those, and none is
3166    /// replayed.
3167    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3168        if is_mutation(query)
3169            && ![
3170                graphql::ADD_COMMENT,
3171                graphql::UPDATE_COMMENT,
3172                graphql::DELETE_COMMENT,
3173            ]
3174            .contains(&query)
3175        {
3176            let mut cache = self.resolved_cache()?;
3177            for argument in ["input", "second", "third", "clear"] {
3178                if let Some(input) = variables.get(argument) {
3179                    cache.retain(|id, item| {
3180                        !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3181                            input
3182                                .get(key)
3183                                .and_then(Value::as_str)
3184                                .is_some_and(|value| value == id.0 || value == item.item_id)
3185                        })
3186                    });
3187                }
3188            }
3189        }
3190        let doing = operation_description(query);
3191        let mut waited = Duration::ZERO;
3192        let mut waits = 0_u32;
3193        let mut backoff = self.pacing.retry_backoff;
3194        loop {
3195            if is_mutation(query) {
3196                let spacing = self.reserve_mutation_slot();
3197                if !spacing.is_zero() {
3198                    tokio::time::sleep(spacing).await;
3199                }
3200            }
3201            let attempt = self.send_once(query, &variables).await;
3202            if is_mutation(query) {
3203                self.finish_mutation();
3204            }
3205            let limited = match attempt {
3206                Ok(data) => return Ok(data),
3207                Err(Attempt::Failed(error)) => return Err(error),
3208                Err(Attempt::Limited(limited)) => limited,
3209            };
3210            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3211            // move that extends a secondary limit, so a hint below the schedule's own next
3212            // wait is raised to it.
3213            let wait = match limited.hint {
3214                Some(hint) => Duration::from_secs(hint).max(backoff),
3215                None => backoff,
3216            };
3217            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3218            // A wait of nothing spends none of the budget, so it is exhaustion rather
3219            // than a retry. `Pacing::resolve` rules out every way of configuring one
3220            // except a budget of zero, where reporting the first refusal is the ask.
3221            if wait.is_zero() || wait > remaining {
3222                return Err(limited.exhausted(
3223                    doing,
3224                    waits,
3225                    waited,
3226                    wait,
3227                    self.pacing.retry_budget,
3228                ));
3229            }
3230            tokio::time::sleep(wait).await;
3231            waited += wait;
3232            waits += 1;
3233            backoff = backoff.saturating_mul(2);
3234        }
3235    }
3236
3237    /// The next moment a content-creating mutation may leave this source, as a wait from
3238    /// now.
3239    ///
3240    /// The slot is reserved under the lock and the waiting happens outside it, so two
3241    /// callers take two slots rather than the same one — and no lock is held across an
3242    /// await.
3243    ///
3244    /// The moment it is spaced from is the previous mutation's *completion*, which
3245    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3246    /// own is the wrong thing to measure from.
3247    fn reserve_mutation_slot(&self) -> Duration {
3248        if self.pacing.min_mutation_interval.is_zero() {
3249            return Duration::ZERO;
3250        }
3251        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3252        // it would turn an earlier failure into a second one for no gain.
3253        let mut last = self
3254            .last_mutation
3255            .lock()
3256            .unwrap_or_else(std::sync::PoisonError::into_inner);
3257        let now = Instant::now();
3258        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3259        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3260        let at = last.map_or(now, |previous| {
3261            previous
3262                .checked_add(self.pacing.min_mutation_interval)
3263                .map_or(now, |earliest| earliest.max(now))
3264        });
3265        *last = Some(at);
3266        at.saturating_duration_since(now)
3267    }
3268
3269    /// Record that a content-creating mutation has finished, so the next one is spaced
3270    /// from here rather than from the moment this one was released.
3271    ///
3272    /// This source can only choose when a request *departs*; the limiter counts when it
3273    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3274    /// departure from the last therefore hands the limiter a gap of the interval less that
3275    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3276    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3277    /// slower machine while passing on a quick one.
3278    ///
3279    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3280    /// previous request had already arrived before its response came back, so its arrival
3281    /// is no later than this moment, and the next mutation is released at least the
3282    /// interval after this moment and arrives no earlier than it is released: the gap the
3283    /// limiter measures is therefore at least the interval, whatever transit costs and on
3284    /// whatever platform. The price is that a mutation's own round trip no longer counts
3285    /// towards its spacing, which makes this source slightly slower than the configured
3286    /// rate rather than slightly faster — the safe side of a limit that punishes being
3287    /// wrong by refusing reads for the next fifty minutes.
3288    ///
3289    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3290    /// and one that never left costs only a wait nobody needed.
3291    fn finish_mutation(&self) {
3292        if self.pacing.min_mutation_interval.is_zero() {
3293            return;
3294        }
3295        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3296        let mut last = self
3297            .last_mutation
3298            .lock()
3299            .unwrap_or_else(std::sync::PoisonError::into_inner);
3300        let now = Instant::now();
3301        // `max` rather than an assignment: a concurrent caller may already have reserved a
3302        // slot further out, and completing this request must never pull that slot back in.
3303        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3304    }
3305
3306    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3307    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3308    ///
3309    /// This is the one place a request leaves this crate, which is why the accounting is
3310    /// here rather than at each of the callers: a read path added later is counted without
3311    /// anybody remembering to count it, and
3312    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3313    /// when one is not.
3314    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3315        let Attempted {
3316            result,
3317            limits,
3318            reported_cost,
3319        } = self.attempt(query, variables).await;
3320        // No `otherwise` name: every document this source sends is one of its own, and the
3321        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3322        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3323        let outcome = match &result {
3324            Ok(_) => accounting::Outcome::Answered,
3325            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3326            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3327        };
3328        self.ledger.record(sending.finished(outcome, limits));
3329        result
3330    }
3331
3332    /// The attempt itself, with what its response said about the rate limit alongside.
3333    ///
3334    /// The two are returned together rather than recorded here because every one of the
3335    /// early exits below is a different outcome, and a record written at each of them is a
3336    /// record one of them can be added without.
3337    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3338        let mut limits = accounting::RateLimit::default();
3339        let mut reported_cost = None;
3340        let result = self
3341            .attempted(query, variables, &mut limits, &mut reported_cost)
3342            .await;
3343        Attempted {
3344            result,
3345            limits,
3346            reported_cost,
3347        }
3348    }
3349
3350    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3351    async fn attempted(
3352        &self,
3353        query: &str,
3354        variables: &Value,
3355        limits: &mut accounting::RateLimit,
3356        reported_cost: &mut Option<u64>,
3357    ) -> Result<Value, Attempt> {
3358        let response = self
3359            .client
3360            .post(self.endpoint.clone())
3361            .bearer_auth(self.token.expose_secret())
3362            .json(&json!({"query": query, "variables": variables}))
3363            .send()
3364            .await
3365            .map_err(|e| {
3366                Attempt::Failed(SourceError::Unavailable {
3367                    message: format!("GitHub GraphQL request failed: {e}"),
3368                })
3369            })?;
3370        let status = response.status();
3371        let header = |name: &str| whole_seconds(response.headers().get(name));
3372        *limits = accounting::RateLimit::read(|name| {
3373            response
3374                .headers()
3375                .get(name)
3376                .and_then(|value| value.to_str().ok())
3377                .map(str::to_owned)
3378        });
3379        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3380        // that are not text at all — is "not known to be exhausted". This never makes a
3381        // response a refusal on its own: it says which limiter a refusal is attributed to
3382        // and where its hint comes from, so a value this cannot read costs a hint rather
3383        // than an answer.
3384        let exhausted = response
3385            .headers()
3386            .get("x-ratelimit-remaining")
3387            .and_then(|value| value.to_str().ok())
3388            == Some("0");
3389        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3390        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3391        // which is the same question answered as an absolute time. Nothing else here is a
3392        // hint, and a schedule is what answers a refusal that carries none.
3393        let hint = header("retry-after").or_else(|| {
3394            exhausted
3395                .then(|| header("x-ratelimit-reset"))
3396                .flatten()
3397                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3398        });
3399        // Read before it is parsed, because the evidence which tells a secondary rate
3400        // limit from a rejected credential is in the body of a response whose status says
3401        // only "forbidden" — and a non-success response was never parsed at all.
3402        let body = response.text().await.map_err(|e| {
3403            Attempt::Failed(SourceError::Unavailable {
3404                message: format!("GitHub GraphQL response could not be read: {e}"),
3405            })
3406        })?;
3407        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3408            return Err(Attempt::Limited(Limited { limiter, hint }));
3409        }
3410        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3411            return Err(Attempt::Failed(SourceError::Auth {
3412                message: format!(
3413                    "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"
3414                ),
3415            }));
3416        }
3417        if !status.is_success() {
3418            return Err(Attempt::Failed(SourceError::Unavailable {
3419                message: format!("GitHub GraphQL returned HTTP {status}"),
3420            }));
3421        }
3422        // GitHub reports what a call cost only when the document asked it to, and no
3423        // document this source sends does — so this is `None` here and carries the figure
3424        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3425        // pick up is a `dryRun` probe's cost, which is some other document's.
3426        *reported_cost = serde_json::from_str::<Value>(&body)
3427            .ok()
3428            .as_ref()
3429            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3430            .and_then(Value::as_u64);
3431        self.answer(&body).map_err(Attempt::Failed)
3432    }
3433
3434    /// What one successful HTTP response says, once its GraphQL errors are read.
3435    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3436        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3437            message: format!("GitHub returned invalid JSON: {e}"),
3438        })?;
3439        let errors = body
3440            .get("errors")
3441            .map(|value| {
3442                value.as_array().ok_or_else(|| SourceError::Malformed {
3443                    message: "GitHub response errors is not an array".into(),
3444                })
3445            })
3446            .transpose()?;
3447        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3448            let messages = errors
3449                .iter()
3450                .filter_map(|e| e.get("message").and_then(Value::as_str))
3451                .collect::<Vec<_>>()
3452                .join("; ");
3453            let message = if messages.is_empty() {
3454                "GitHub returned GraphQL errors".into()
3455            } else {
3456                messages
3457            };
3458            let normalized = message.to_ascii_lowercase();
3459            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3460                return Err(SourceError::Auth {
3461                    message: format!(
3462                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3463                        self.credential_name
3464                    ),
3465                });
3466            }
3467            return Err(SourceError::Refused { message });
3468        }
3469        body.get("data")
3470            .filter(|data| data.is_object())
3471            .cloned()
3472            .ok_or_else(|| SourceError::Malformed {
3473                message: "GitHub response has no data object".into(),
3474            })
3475    }
3476
3477    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3478    // GraphQL cannot independently page them inside the outer item page. This source page is
3479    // deliberately bounded at that published maximum; the live drift journey exercises it.
3480    async fn board_page(
3481        &self,
3482        items_after: Option<&str>,
3483        items_first: u32,
3484    ) -> Result<Value, SourceError> {
3485        let data = self
3486            .graphql(
3487                graphql::BOARD,
3488                json!({"owner":self.owner,"number":self.project_number,
3489                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3490                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3491            )
3492            .await?;
3493        data.pointer("/owner/projectV2")
3494            .filter(|v| !v.is_null())
3495            .cloned()
3496            .ok_or_else(|| SourceError::Refused {
3497                message: format!(
3498                    "GitHub project {}/{} was not found or is not visible to the token",
3499                    self.owner, self.project_number
3500                ),
3501            })
3502    }
3503
3504    /// The search that finds the issues of this board, narrowed by `also` when it is
3505    /// given.
3506    ///
3507    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3508    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3509    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3510    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3511    /// from a task by the `parent` field each issue carries rather than by the search.
3512    fn board_search(&self, also: Option<&str>) -> String {
3513        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3514        match also {
3515            Some(also) => format!("{scope} {also}"),
3516            None => scope,
3517        }
3518    }
3519
3520    /// One issue this source reached directly, as the board item a read of the board would
3521    /// have produced — or `None` when this board does not hold it.
3522    ///
3523    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3524    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3525    /// item's own id, that item's field values, and the issue as its content. One resolver
3526    /// for both routes is what makes an issue read through a search, through its own node
3527    /// id, or through its project's sub-issues report the same title, the same status, the
3528    /// same labels and the same qualified id.
3529    ///
3530    /// An issue with no entry for *this* board is not this source's to report, which is
3531    /// what keeps an id naming some other repository's issue from being answered as an item
3532    /// of this board. That answer is given about an **exhausted** connection and never
3533    /// about an unread page: the entry is looked for on the page in hand, and only if that
3534    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3535    /// rest of it.
3536    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3537        if optional_str(issue, "__typename")? != Some("Issue") {
3538            return Ok(None);
3539        }
3540        let memberships = issue
3541            .get("projectItems")
3542            .ok_or_else(|| SourceError::Malformed {
3543                message: "GitHub issue is missing projectItems".into(),
3544            })?;
3545        let nodes = memberships
3546            .get("nodes")
3547            .and_then(Value::as_array)
3548            .ok_or_else(|| SourceError::Malformed {
3549                message: "GitHub issue projectItems.nodes is not an array".into(),
3550            })?;
3551        let held = match self.board_entry(nodes) {
3552            Some(held) => held.clone(),
3553            None => {
3554                let info = memberships
3555                    .get("pageInfo")
3556                    .ok_or_else(|| SourceError::Malformed {
3557                        message: "GitHub issue projectItems has no pageInfo".into(),
3558                    })?;
3559                // The page held no entry for this board. Whether that means the issue is
3560                // not on it is a question about the rest of the connection, and only a
3561                // connection with no rest answers it here.
3562                if !required_bool(info, "hasNextPage")? {
3563                    return Ok(None);
3564                }
3565                let cursor = required_str(info, "endCursor")?;
3566                validate_cursor_progress(None, cursor)?;
3567                let issue_id = required_str(issue, "id")?;
3568                match self.board_membership(issue_id, cursor).await? {
3569                    Some(held) => held,
3570                    None => return Ok(None),
3571                }
3572            }
3573        };
3574        let item = json!({
3575            "id": required_str(&held, "id")?,
3576            "project": held.get("project"),
3577            "fieldValues": held.get("fieldValues"),
3578            "content": issue,
3579        });
3580        self.resolve(&item)
3581    }
3582
3583    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3584    ///
3585    /// One spelling of *which membership is this board's*, so the page a read carries and
3586    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3587    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3588        nodes.iter().find(|node| {
3589            node.pointer("/project/number").and_then(Value::as_u64)
3590                == Some(u64::from(self.project_number))
3591        })
3592    }
3593
3594    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3595    ///
3596    /// The recovery read: a page of memberships that holds no entry for this board says
3597    /// nothing about the memberships past it, so the connection is walked to exhaustion
3598    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3599    /// positive answer — the whole connection was read and no entry named this board —
3600    /// rather than a failure, and the walk is held to
3601    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3602    /// with a cursor that does not advance is refused instead of spun on.
3603    async fn board_membership(
3604        &self,
3605        issue: &str,
3606        after: &str,
3607    ) -> Result<Option<Value>, SourceError> {
3608        let mut after = after.to_owned();
3609        loop {
3610            let data = self
3611                .graphql(
3612                    graphql::ISSUE_BOARD_ITEMS,
3613                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3614                           "nestedFirst":NESTED_PAGE_SIZE}),
3615                )
3616                .await?;
3617            let Some(connection) = data
3618                .pointer("/node/projectItems")
3619                .filter(|value| !value.is_null())
3620            else {
3621                // The id resolved to nothing, or to something with no memberships to walk —
3622                // which is the same answer as a connection holding no entry for this board.
3623                return Ok(None);
3624            };
3625            let nodes = connection
3626                .get("nodes")
3627                .and_then(Value::as_array)
3628                .ok_or_else(|| SourceError::Malformed {
3629                    message: "GitHub issue projectItems.nodes is not an array".into(),
3630                })?;
3631            if let Some(held) = self.board_entry(nodes) {
3632                return Ok(Some(held.clone()));
3633            }
3634            let info = connection
3635                .get("pageInfo")
3636                .ok_or_else(|| SourceError::Malformed {
3637                    message: "GitHub issue projectItems has no pageInfo".into(),
3638                })?;
3639            let next = required_bool(info, "hasNextPage")?
3640                .then(|| required_str(info, "endCursor"))
3641                .transpose()?;
3642            match next {
3643                Some(next) => {
3644                    validate_cursor_progress(Some(&after), next)?;
3645                    after = next.to_owned();
3646                }
3647                None => return Ok(None),
3648            }
3649        }
3650    }
3651
3652    /// One page of a board-scoped issue search, and where the next page resumes.
3653    async fn search_page(
3654        &self,
3655        search: &str,
3656        first: u32,
3657        after: Option<&str>,
3658    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3659        let data = self
3660            .graphql(
3661                graphql::SEARCH_ISSUES,
3662                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3663                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3664                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3665            )
3666            .await?;
3667        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3668            message: "GitHub search response has no search connection".into(),
3669        })?;
3670        let mut found = Vec::new();
3671        for node in connection
3672            .get("nodes")
3673            .and_then(Value::as_array)
3674            .ok_or_else(|| SourceError::Malformed {
3675                message: "GitHub search nodes is not an array".into(),
3676            })?
3677        {
3678            if let Some(resolved) = self.resolve_issue(node).await? {
3679                found.push(resolved);
3680            }
3681        }
3682        let info = connection
3683            .get("pageInfo")
3684            .ok_or_else(|| SourceError::Malformed {
3685                message: "GitHub search connection has no pageInfo".into(),
3686            })?;
3687        let next = required_bool(info, "hasNextPage")?
3688            .then(|| required_str(info, "endCursor"))
3689            .transpose()?
3690            .map(str::to_owned);
3691        if let Some(next) = &next {
3692            validate_cursor_progress(after, next)?;
3693        }
3694        Ok((found, next))
3695    }
3696
3697    /// Every issue this board holds, completed with what this run wrote.
3698    ///
3699    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3700    /// is an index and is eventually consistent, so an issue this run created seconds ago
3701    /// can be absent from it, and a project listed straight after being written would
3702    /// otherwise be missing from its own board. What is added back is only what this
3703    /// process itself wrote, out of [`Self::created`], which lives and dies with the
3704    /// process.
3705    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3706        let found = self.searched_issues().await?;
3707        self.completed_with_written(found, |_| true)
3708    }
3709
3710    /// Every issue this board's own search reports, walked to exhaustion, read once per
3711    /// source.
3712    ///
3713    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3714    /// needs it too and the two would otherwise walk the same search twice in one command.
3715    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3716    /// is.
3717    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3718        let cached = self.search_cache()?.clone();
3719        if let Some(held) = cached {
3720            return Ok(held);
3721        }
3722        let mut after: Option<String> = None;
3723        let mut found = Vec::new();
3724        let search = self.board_search(None);
3725        loop {
3726            let (page, next) = self
3727                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3728                .await?;
3729            found.extend(page);
3730            match next {
3731                Some(next) => after = Some(next),
3732                None => break,
3733            }
3734        }
3735        *self.search_cache()? = Some(found.clone());
3736        Ok(found)
3737    }
3738
3739    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3740    fn search_cache(
3741        &self,
3742    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3743        self.search_cache
3744            .lock()
3745            .map_err(|_| SourceError::Unavailable {
3746                message: "this source's view of the board's issues was left inconsistent by an \
3747                      earlier failure; next: run the command again"
3748                    .into(),
3749            })
3750    }
3751
3752    /// `found`, with everything this run wrote that `keep` accepts and the read did not
3753    /// report.
3754    ///
3755    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3756    /// at all: the search index is behind, and a node read of an item filed moments ago can
3757    /// be too.
3758    fn completed_with_written(
3759        &self,
3760        mut found: Vec<Resolved>,
3761        keep: impl Fn(&Resolved) -> bool,
3762    ) -> Result<Vec<Resolved>, SourceError> {
3763        for own in self.created()?.iter().filter(|own| keep(own)) {
3764            if !found.iter().any(|item| item.id == own.id) {
3765                found.push(own.clone());
3766            }
3767        }
3768        Ok(found)
3769    }
3770
3771    /// What resolving one node id reached.
3772    ///
3773    /// Three answers rather than an `Option`, because a board *draft* is none of the other
3774    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3775    /// is completed by a read of the draft itself rather than reported as nothing.
3776    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3777        let asked = self
3778            .graphql(
3779                graphql::ISSUE,
3780                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3781                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3782            )
3783            .await;
3784        let data = match asked {
3785            Ok(data) => data,
3786            // A string that is not a node id at all is not a failure to report: it is an id
3787            // this board does not hold, which is what every read of one already answers.
3788            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3789            Err(error) => return Err(error),
3790        };
3791        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3792            return Ok(Reached::Nothing);
3793        };
3794        if optional_str(node, "__typename")? == Some("DraftIssue") {
3795            return Ok(Reached::Draft);
3796        }
3797        Ok(match self.resolve_issue(node).await? {
3798            Some(item) => Reached::Held(Box::new(item)),
3799            None => Reached::Nothing,
3800        })
3801    }
3802
3803    /// One item of this board by its own id, whatever kind it is.
3804    ///
3805    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
3806    /// run wrote is read first, because a node read of an item created moments ago can
3807    /// still be behind the board field values written onto it — see [`Self::created`].
3808    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3809        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
3810            return Ok(Some(own.clone()));
3811        }
3812        match self.reach(id).await? {
3813            Reached::Held(item) => Ok(Some(*item)),
3814            Reached::Nothing => Ok(None),
3815            Reached::Draft => self.draft_by_id(id).await,
3816        }
3817    }
3818
3819    fn resolved_cache(
3820        &self,
3821    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
3822        self.resolved_cache
3823            .lock()
3824            .map_err(|_| SourceError::Unavailable {
3825                message: "resolved item records were left inconsistent; run the command again"
3826                    .into(),
3827            })
3828    }
3829
3830    /// Reuse a record this invocation already resolved. The mutation sender invalidates
3831    /// it before writing, so a partial failure cannot leave a pre-write binding behind.
3832    async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3833        let cached = self.resolved_cache()?.get(id).cloned();
3834        match cached {
3835            Some(item) => Ok(Some(item)),
3836            None => self.item_by_id(id).await,
3837        }
3838    }
3839
3840    /// One board draft by its own id, with the board item it sits in — or `None` when no
3841    /// item of this board is that draft's.
3842    ///
3843    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
3844    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
3845    /// links a draft to one board item, so the page this read carries is the whole of that
3846    /// connection, and a page that reports more than it holds is refused rather than read
3847    /// as an answer about memberships nobody read.
3848    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3849        let data = self
3850            .graphql(
3851                graphql::DRAFT,
3852                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3853                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
3854            )
3855            .await?;
3856        // Gone between the two reads is an answer — the draft is no longer there. Anything
3857        // else than the draft [`Self::reach`] was just told this id is, is not one.
3858        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
3859            return Ok(None);
3860        };
3861        if optional_str(draft, "__typename")? != Some("DraftIssue") {
3862            return Err(SourceError::Malformed {
3863                message: format!(
3864                    "GitHub answered {} as a draft and then as something else",
3865                    id.0
3866                ),
3867            });
3868        }
3869        if required_str(draft, "id")? != id.0 {
3870            return Err(SourceError::Malformed {
3871                message: format!("GitHub answered a different draft for {}", id.0),
3872            });
3873        }
3874        let memberships = draft
3875            .get("projectV2Items")
3876            .ok_or_else(|| SourceError::Malformed {
3877                message: format!("GitHub draft {} is missing projectV2Items", id.0),
3878            })?;
3879        let nodes = memberships
3880            .get("nodes")
3881            .and_then(Value::as_array)
3882            .ok_or_else(|| SourceError::Malformed {
3883                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
3884            })?;
3885        let info = memberships
3886            .get("pageInfo")
3887            .ok_or_else(|| SourceError::Malformed {
3888                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
3889            })?;
3890        // Read whether or not this board's entry is on the page: a page claiming more than
3891        // the one item GitHub links a draft to is a malformed answer either way.
3892        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
3893            return Err(SourceError::Malformed {
3894                message: format!(
3895                    "GitHub draft {} reports more board items than the one GitHub links a draft \
3896                     to",
3897                    id.0
3898                ),
3899            });
3900        }
3901        if let Some(node) = nodes.first()
3902            && node
3903                .pointer("/project/number")
3904                .and_then(Value::as_u64)
3905                .is_none()
3906        {
3907            return Err(SourceError::Malformed {
3908                message: format!(
3909                    "GitHub draft {} board item has no numeric project number",
3910                    id.0
3911                ),
3912            });
3913        }
3914        let Some(held) = self.board_entry(nodes) else {
3915            return Ok(None);
3916        };
3917        if required_str(
3918            held.get("project").ok_or_else(|| SourceError::Malformed {
3919                message: format!("GitHub draft {} board item has no project", id.0),
3920            })?,
3921            "id",
3922        )? != self.board_fields().await?.id.as_str()
3923        {
3924            return Ok(None);
3925        }
3926        let item = json!({
3927            "id": required_str(held, "id")?,
3928            "project": held.get("project"),
3929            "fieldValues": held.get("fieldValues"),
3930            "content": draft,
3931        });
3932        self.resolve(&item)
3933    }
3934
3935    /// The board's own id and field definitions, for a write whose item does not carry
3936    /// them — never its items.
3937    ///
3938    /// A board this command has already listed supplies them, since it read them beside its
3939    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
3940    /// is consulted about which items the board holds: see the module documentation for
3941    /// why a question about one known item is answered by reading that item.
3942    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
3943        if let Some(board) = self.board_cache()?.as_ref() {
3944            return Ok(BoardFields {
3945                id: BoardId::parse(&board.id)?,
3946                fields: board.fields.clone(),
3947            });
3948        }
3949        if let Some(held) = self.fields_cache()?.clone() {
3950            return Ok(held);
3951        }
3952        let data = self
3953            .graphql(
3954                graphql::BOARD_FIELDS,
3955                json!({"owner":self.owner,"number":self.project_number,
3956                       "nestedFirst":NESTED_PAGE_SIZE}),
3957            )
3958            .await?;
3959        let board = data
3960            .pointer("/boardFields/projectV2")
3961            .filter(|value| !value.is_null())
3962            .ok_or_else(|| SourceError::Refused {
3963                message: format!(
3964                    "GitHub project {}/{} was not found or is not visible to the token",
3965                    self.owner, self.project_number
3966                ),
3967            })?;
3968        let read = BoardFields {
3969            id: BoardId::parse(required_str(board, "id")?)?,
3970            fields: board.get("fields").cloned().unwrap_or(Value::Null),
3971        };
3972        *self.fields_cache()? = Some(read.clone());
3973        Ok(read)
3974    }
3975
3976    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
3977    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
3978        self.fields_cache
3979            .lock()
3980            .map_err(|_| SourceError::Unavailable {
3981                message: "this source's view of the board's fields was left inconsistent by an \
3982                      earlier failure; next: run the command again"
3983                    .into(),
3984            })
3985    }
3986
3987    /// What a write to `item` needs of the board, read off that item when it says enough and
3988    /// off [`Self::board_fields`] when it does not.
3989    ///
3990    /// A node read of an item names its board and carries the definition of every field it
3991    /// holds a value of — so an item naming its board, holding a value of the origin field,
3992    /// and, when the write carries a status, holding a `Status` value, needs no read of the
3993    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
3994    /// of may still be on the board, and a view reading it as absent would refuse a write the
3995    /// board can take or skip a field write the board needs, so such an item — and a create,
3996    /// which has no item yet — takes the board's fields from their own read instead.
3997    async fn fields_for(
3998        &self,
3999        item: Option<&Resolved>,
4000        writes_status: bool,
4001        selects_priority: bool,
4002    ) -> Result<BoardFields, SourceError> {
4003        if let Some(item) = item
4004            && let Some(board_id) = item.named_board()
4005            && item.defines(ORIGIN_FIELD)
4006            && (!writes_status || item.defines("Status"))
4007            && (!selects_priority || item.defines(PRIORITY_FIELD))
4008        {
4009            return Ok(BoardFields {
4010                id: board_id,
4011                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4012            });
4013        }
4014        self.board_fields().await
4015    }
4016
4017    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4018    /// when that id names nothing here with a sub-issue relationship to walk.
4019    ///
4020    /// `None` and an empty answer are different: `None` is *this is not an issue of this
4021    /// GitHub*, which is what sends a project selector on to be read as a name, and an
4022    /// empty vector is a project that holds nothing.
4023    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4024        let mut after: Option<String> = None;
4025        let mut children = Vec::new();
4026        loop {
4027            let asked = self
4028                .graphql(
4029                    graphql::SUB_ISSUES,
4030                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4031                           "nestedFirst":NESTED_PAGE_SIZE,
4032                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4033                )
4034                .await;
4035            let data = match asked {
4036                Ok(data) => data,
4037                // A string that is not a node id at all is not a failure to report: it is
4038                // the ordinary answer to a selector naming a project by its name.
4039                Err(error) if unresolvable_node(&error) => return Ok(None),
4040                Err(error) => return Err(error),
4041            };
4042            let Some(connection) = data
4043                .pointer("/node/subIssues")
4044                .filter(|value| !value.is_null())
4045            else {
4046                // No such node, or one with no sub-issue relationship — a board draft is
4047                // the one this board can really hold.
4048                return Ok(None);
4049            };
4050            for node in connection
4051                .get("nodes")
4052                .and_then(Value::as_array)
4053                .ok_or_else(|| SourceError::Malformed {
4054                    message: "GitHub subIssues.nodes is not an array".into(),
4055                })?
4056            {
4057                if let Some(resolved) = self.resolve_issue(node).await? {
4058                    children.push(resolved);
4059                }
4060            }
4061            let info = connection
4062                .get("pageInfo")
4063                .ok_or_else(|| SourceError::Malformed {
4064                    message: "GitHub subIssues connection has no pageInfo".into(),
4065                })?;
4066            let next = required_bool(info, "hasNextPage")?
4067                .then(|| required_str(info, "endCursor"))
4068                .transpose()?;
4069            match next {
4070                Some(next) => {
4071                    validate_cursor_progress(after.as_deref(), next)?;
4072                    after = Some(next.to_owned());
4073                }
4074                None => return Ok(Some(children)),
4075            }
4076        }
4077    }
4078
4079    /// Which issue of this board a project *name* is, or `None` when none is.
4080    ///
4081    /// One bounded query which filters on that name at the server, rather than a walk of
4082    /// every issue the board holds. The name is compared again here: the qualifier narrows
4083    /// what GitHub sends, and this source decides what it names.
4084    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4085        let search = self.board_search(Some(&title_qualifier(name)));
4086        let mut after = None;
4087        loop {
4088            let (candidates, next) = self
4089                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4090                .await?;
4091            if let Some(item) = candidates.into_iter().find(|item| {
4092                item.kind == BoardKind::Work(ItemKind::Project)
4093                    && item.title.eq_ignore_ascii_case(name)
4094            }) {
4095                return Ok(Some(item.id));
4096            }
4097            match next {
4098                Some(next) => after = Some(next),
4099                None => return Ok(None),
4100            }
4101        }
4102    }
4103
4104    /// Everything filed under one project of this board: the sub-issues of the issue that
4105    /// project is.
4106    ///
4107    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4108    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4109    /// gains projects, or as another project gains tasks.
4110    ///
4111    /// A qualified id names the issue and is asked for its sub-issues directly: one
4112    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4113    /// read as a project *name*, which costs the one bounded search
4114    /// [`Self::project_by_name`] makes.
4115    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4116        let (project, children) = match self.sub_issues(selector).await? {
4117            Some(children) => (selector.clone(), children),
4118            None => match self.project_by_name(&selector.0).await? {
4119                Some(project) => {
4120                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4121                    (project, children)
4122                }
4123                None => return Ok(Vec::new()),
4124            },
4125        };
4126        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4127    }
4128
4129    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4130    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4131    ///
4132    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4133    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4134    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4135    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4136    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4137    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4138    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4139    /// rather than silently narrowing a caller's answer.
4140    ///
4141    /// The instant is written to the second, rounded down, which can only widen what the
4142    /// search returns; confirmation against each candidate's own comments is what makes the
4143    /// answer exact. The search is an index that lags a write by a second or two — the module
4144    /// documentation records it — so a caller that asks again from its last instant should
4145    /// overlap the two by more than that.
4146    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4147        let found = self.searched(&updated_qualifier(since)).await?;
4148        self.completed_with_written(found, |_| true)
4149    }
4150
4151    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4152    /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4153    ///
4154    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4155    /// own record is the fresher of the two.
4156    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4157        let search = self.board_search(Some(also));
4158        let mut after: Option<String> = None;
4159        let mut found = Vec::new();
4160        loop {
4161            let (page, next) = self
4162                .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4163                .await?;
4164            found.extend(page);
4165            match next {
4166                Some(next) => after = Some(next),
4167                None => return Ok(found),
4168            }
4169        }
4170    }
4171
4172    /// A bounded task answer; the versioned cursor carries the connection position, how
4173    /// many rows of the page starting there were already handed out, and the own-write ids
4174    /// already observed, including across a new source instance.
4175    ///
4176    /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4177    /// sliced from the pages it needs; why is the module documentation's paging contract.
4178    async fn search_tasks(
4179        &self,
4180        query: &TaskQuery,
4181        page: &PageRequest,
4182        also: &str,
4183    ) -> Result<Page<Task>, SourceError> {
4184        let mut position = match &page.cursor {
4185            None => SearchPosition::default(),
4186            Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4187                .ok()
4188                .filter(|position| {
4189                    position.version == SEARCH_CURSOR_VERSION
4190                        && position.connection.valid_resume(position.offset)
4191                })
4192                .ok_or_else(|| SourceError::Config {
4193                    message: "page cursor is invalid".into(),
4194                })?,
4195        };
4196        let search = self.board_search(Some(also));
4197        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4198        let own = self.with_own_writes(Vec::new())?;
4199        for item in &own {
4200            if !position.own.contains(&item.id) {
4201                position.own.push(item.id.clone());
4202            }
4203        }
4204        let mut tasks = Vec::new();
4205        while !position.connection.exhausted() && tasks.len() < limit {
4206            let first = SEARCH_PAGE_SIZE;
4207            // Page size is part of the key: a short cached answer cannot answer a wider ask.
4208            let key =
4209                serde_json::to_string(&("page", &search, &position.connection.after(), first))
4210                    .expect("search page key is serializable");
4211            let cached = if query.commented_since.is_none() {
4212                self.narrowed_cache()?.get(&key).cloned()
4213            } else {
4214                None
4215            };
4216            let (found, next) = match cached {
4217                Some(found) => {
4218                    let next = self
4219                        .search_next
4220                        .lock()
4221                        .map_err(|_| SourceError::Unavailable {
4222                            message:
4223                                "search pagination was left inconsistent; run the command again"
4224                                    .into(),
4225                        })?
4226                        .get(&key)
4227                        .cloned()
4228                        .flatten();
4229                    (found, next)
4230                }
4231                None => {
4232                    let (found, next) = self
4233                        .search_page(&search, first, position.connection.after())
4234                        .await?;
4235                    if query.commented_since.is_none() {
4236                        self.search_next
4237                            .lock()
4238                            .map_err(|_| SourceError::Unavailable {
4239                                message:
4240                                    "search pagination was left inconsistent; run the command again"
4241                                        .into(),
4242                            })?
4243                            .insert(key.clone(), next.clone());
4244                        self.narrowed_cache()?.insert(key, found.clone());
4245                    }
4246                    (found, next)
4247                }
4248            };
4249            let rows = found.len();
4250            for mut item in found.into_iter().skip(position.offset) {
4251                if tasks.len() == limit {
4252                    break;
4253                }
4254                position.offset += 1;
4255                if position.own.contains(&item.id) {
4256                    if position.seen.contains(&item.id) {
4257                        continue;
4258                    }
4259                    position.seen.push(item.id.clone());
4260                    let updated_at = item.updated_at;
4261                    let Some(written) = self.search_written(&own, &item.id).await? else {
4262                        continue;
4263                    };
4264                    item = written;
4265                    item.updated_at = item.updated_at.max(updated_at);
4266                    self.resolved_cache()?.insert(item.id.clone(), item.clone());
4267                }
4268                if item.kind == BoardKind::Work(ItemKind::Task) {
4269                    let task = item.task()?;
4270                    if task_matches(&task, query, &query.project)
4271                        && self.commented_since(&item, query.commented_since).await?
4272                    {
4273                        tasks.push(task);
4274                    }
4275                }
4276            }
4277            if position.offset < rows {
4278                continue;
4279            }
4280            position.offset = 0;
4281            position.connection = match next {
4282                Some(after) => SearchConnection::Continuing {
4283                    after: Cursor(after),
4284                },
4285                None => SearchConnection::Exhausted {},
4286            };
4287        }
4288        if position.connection.exhausted() {
4289            for id in position.own.clone() {
4290                if position.seen.contains(&id) {
4291                    continue;
4292                }
4293                if tasks.len() == limit {
4294                    break;
4295                }
4296                position.seen.push(id.clone());
4297                let Some(item) = self.search_written(&own, &id).await? else {
4298                    continue;
4299                };
4300                if item.kind == BoardKind::Work(ItemKind::Task) {
4301                    let task = item.task()?;
4302                    if task_matches(&task, query, &query.project)
4303                        && self.commented_since(&item, query.commented_since).await?
4304                    {
4305                        tasks.push(task);
4306                    }
4307                }
4308            }
4309        }
4310        let more = !position.connection.exhausted()
4311            || position.own.iter().any(|id| !position.seen.contains(id));
4312        Ok(Page {
4313            items: tasks,
4314            next: more.then(|| {
4315                Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4316            }),
4317        })
4318    }
4319
4320    /// A resumed process has the ids but no write records; resolve only a record the
4321    /// current page needs, by its uncached node read rather than the lagging search index.
4322    async fn search_written(
4323        &self,
4324        own: &[Resolved],
4325        id: &NativeId,
4326    ) -> Result<Option<Resolved>, SourceError> {
4327        match own.iter().find(|item| item.id == *id) {
4328            Some(item) => Ok(Some(item.clone())),
4329            None => self.item_by_id(id).await,
4330        }
4331    }
4332
4333    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4334    /// without enumerating the board — or `None` for a query carrying none of the three, which
4335    /// keeps the reads it always had.
4336    ///
4337    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4338    /// because it names at most a handful of items. Text and metadata are answered by one
4339    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4340    /// further by `updated:>=` when the query also asks for comment activity, since both
4341    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4342    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4343    ///
4344    /// Completed with what this process wrote, its own record winning over the index's copy
4345    /// of the same item: see [`Self::with_own_writes`].
4346    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4347        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4348            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4349            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4350                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4351                None => qualifiers,
4352            }),
4353            (None, None) => return Ok(None),
4354        };
4355        // A question about comment activity is asked afresh every time, as it always was: it
4356        // is the one a caller polls from one source while waiting for the index, and an
4357        // answer held from the first poll would be the answer to every later one.
4358        let key = query.commented_since.is_none().then(|| asked.key());
4359        let cached = match &key {
4360            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4361            None => None,
4362        };
4363        let found = match cached {
4364            Some(found) => found,
4365            None => {
4366                let found = match &asked {
4367                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4368                    Narrowing::Search(also) => self.searched(also).await?,
4369                };
4370                if let Some(key) = key {
4371                    self.narrowed_cache()?.insert(key, found.clone());
4372                }
4373                found
4374            }
4375        };
4376        self.with_own_writes(found).map(Some)
4377    }
4378
4379    /// Every item of this board that may carry `origin` — a superset of those that do — found
4380    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4381    ///
4382    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4383    /// which reads the field every carrier holds, whichever release wrote it — and the
4384    /// board-scoped issue search for the same id as a phrase in the body, where this source
4385    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4386    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4387    /// query's, exactly.
4388    ///
4389    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4390    /// already ended is sent its last cursor again, which answers an empty page, so the one
4391    /// document serves every page of either. What the two leave is stated in the module
4392    /// documentation: a carrier another process added within the last second or two, before
4393    /// either index has it.
4394    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4395        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4396        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4397        let mut items_after: Option<String> = None;
4398        let mut search_after: Option<String> = None;
4399        let mut found: Vec<Resolved> = Vec::new();
4400        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4401            if !found.iter().any(|held| held.id == resolved.id) {
4402                found.push(resolved);
4403            }
4404        };
4405        loop {
4406            let data = self
4407                .graphql(
4408                    graphql::ORIGIN_LOOKUP,
4409                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4410                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4411                           "itemsAfter":items_after,"searchAfter":search_after,
4412                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4413                           "duplicates":true}),
4414                )
4415                .await?;
4416            let items = data
4417                .pointer("/originItems/projectV2/items")
4418                .filter(|value| !value.is_null())
4419                .ok_or_else(|| SourceError::Refused {
4420                    message: format!(
4421                        "GitHub project {}/{} was not found or is not visible to the token",
4422                        self.owner, self.project_number
4423                    ),
4424                })?;
4425            for item in optional_nodes(Some(items), "project items")?
4426                .into_iter()
4427                .flatten()
4428            {
4429                // The board's own items list its drafts too, and a draft is not an issue: no
4430                // narrowed read answers with one, whatever its origin field holds.
4431                if let Some(resolved) = self.resolve(item)?
4432                    && resolved.content_kind == ContentKind::Issue
4433                {
4434                    keep(resolved, &mut found);
4435                }
4436            }
4437            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4438                message: "GitHub search response has no search connection".into(),
4439            })?;
4440            for node in optional_nodes(Some(searched), "search")?
4441                .into_iter()
4442                .flatten()
4443            {
4444                if let Some(resolved) = self.resolve_issue(node).await? {
4445                    keep(resolved, &mut found);
4446                }
4447            }
4448            let items_next = resumed(items, items_after.as_deref())?;
4449            let search_next = resumed(searched, search_after.as_deref())?;
4450            if !items_next.has_more() && !search_next.has_more() {
4451                return Ok(found);
4452            }
4453            items_after = items_next.cursor();
4454            search_after = search_next.cursor();
4455        }
4456    }
4457
4458    /// `found`, with every item this process created or wrote in its place, and every one of
4459    /// them the read did not report added.
4460    ///
4461    /// This process's own record wins over the read's copy of the same item, because a read
4462    /// of an item written moments ago can still be behind what was written onto it — the
4463    /// origin field included, which is the one a narrowed read is confirmed against — and a
4464    /// read that still names an item under a predicate this process's write moved it out of
4465    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4466    /// last saw the item change, which is what a comment-activity read rules a candidate out
4467    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4468    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4469    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4470        // A board draft is not an issue, so no narrowed read returns one, and this process
4471        // having written one does not make it an answer either.
4472        let own: Vec<Resolved> = self
4473            .created()?
4474            .iter()
4475            .chain(self.updated()?.iter())
4476            .filter(|own| own.content_kind == ContentKind::Issue)
4477            .cloned()
4478            .collect();
4479        for mut own in own {
4480            self.resolved_cache()?.insert(own.id.clone(), own.clone());
4481            match found.iter_mut().find(|read| read.id == own.id) {
4482                Some(read) => {
4483                    own.updated_at = own.updated_at.max(read.updated_at);
4484                    *read = own;
4485                }
4486                None => found.push(own),
4487            }
4488        }
4489        Ok(found)
4490    }
4491
4492    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4493    /// there is no instant to hold it to.
4494    ///
4495    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4496    /// or after the instant moved it there: an issue not updated since holds no such comment,
4497    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4498    /// only as far as the first that matches. A board draft is not an issue and has no
4499    /// comments, so it never matches.
4500    async fn commented_since(
4501        &self,
4502        item: &Resolved,
4503        since: Option<DateTime<Utc>>,
4504    ) -> Result<bool, SourceError> {
4505        let Some(since) = since else {
4506            return Ok(true);
4507        };
4508        if item.content_kind == ContentKind::DraftIssue
4509            || item.updated_at.is_some_and(|updated| updated < since)
4510        {
4511            return Ok(false);
4512        }
4513        let query = TaskQuery {
4514            commented_since: Some(since),
4515            ..TaskQuery::default()
4516        };
4517        let mut after: Option<String> = None;
4518        loop {
4519            let data = self
4520                .graphql(
4521                    graphql::ISSUE_COMMENTS,
4522                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4523                )
4524                .await?;
4525            let Some(connection) = data
4526                .get("node")
4527                .filter(|value| !value.is_null())
4528                .and_then(|node| node.get("comments"))
4529                .filter(|value| !value.is_null())
4530            else {
4531                // Removed since the search reported it: no longer an issue with comments.
4532                return Ok(false);
4533            };
4534            let comments = optional_nodes(Some(connection), "issue comments")?
4535                .into_iter()
4536                .flatten()
4537                .map(comment_from)
4538                .collect::<Result<Vec<_>, _>>()?;
4539            if query.comments_match(&comments) {
4540                return Ok(true);
4541            }
4542            match next_cursor(connection)? {
4543                Some(next) => {
4544                    validate_cursor_progress(after.as_deref(), &next.0)?;
4545                    after = Some(next.0);
4546                }
4547                None => return Ok(false),
4548            }
4549        }
4550    }
4551
4552    /// Every item on the board: the union of both enumerations GitHub offers of one.
4553    ///
4554    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4555    /// board **draft** and reads the board's own fields beside its items, and only the search
4556    /// reports an item that connection is behind on. The module documentation is where the lag and the
4557    /// measurements behind it are written down.
4558    ///
4559    /// A search result is admitted on the same terms as any other issue this source reaches
4560    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4561    /// names *this* board — so an issue the index still believes is here after it was taken
4562    /// off is refused rather than reported.
4563    ///
4564    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4565    /// which is what the cache could otherwise have broken.
4566    async fn board(&self) -> Result<Board, SourceError> {
4567        let cached = self.board_cache()?.clone();
4568        let mut board = match cached {
4569            Some(board) => board,
4570            None => {
4571                let read = self.read_board().await?;
4572                *self.board_cache()? = Some(read.clone());
4573                read
4574            }
4575        };
4576        for held in self.searched_issues().await? {
4577            if !board.items.iter().any(|item| item.id == held.id) {
4578                board.items.push(held);
4579            }
4580        }
4581        for own in self.created()?.iter() {
4582            if !board.items.iter().any(|item| item.id == own.id) {
4583                board.items.push(own.clone());
4584            }
4585        }
4586        Ok(board)
4587    }
4588
4589    /// This process's own view of the board, or the refusal a poisoned lock is.
4590    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4591        self.board_cache
4592            .lock()
4593            .map_err(|_| SourceError::Unavailable {
4594                message: "this source's view of the board was left inconsistent by an earlier \
4595                      failure; next: run the command again"
4596                    .into(),
4597            })
4598    }
4599
4600    /// Bring this process's own view of the board up to an item it has just written.
4601    ///
4602    /// A created item goes to `created`, which is what completes a board read GitHub's own
4603    /// eventual consistency has left behind. An item that was already there is replaced
4604    /// where it sits, so a second write of it in the same command reads its real parent
4605    /// rather than the one it had before the first write.
4606    ///
4607    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4608    /// that wins: an item this same run created is held in `created` and not in the cached
4609    /// board, and `board` completes the cached board *from* `created`, so replacing only
4610    /// the cached copy of such an item replaces nothing and the read still reports the
4611    /// title it was created with. The search is the third, and it is the one an item the
4612    /// board's own projection is behind on sits in *alone* — which is exactly the item this
4613    /// source is least able to re-read, so leaving it out would put the stale title back on
4614    /// the only items the completion in [`Self::board`] exists for.
4615    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4616        self.resolved_cache()?.insert(item.id.clone(), item.clone());
4617        if created {
4618            self.created()?.push(item);
4619            return Ok(());
4620        }
4621        {
4622            let mut own = self.created()?;
4623            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4624                *held = item;
4625                return Ok(());
4626            }
4627        }
4628        {
4629            let mut own = self.updated()?;
4630            match own.iter_mut().find(|held| held.id == item.id) {
4631                Some(held) => *held = item.clone(),
4632                None => own.push(item.clone()),
4633            }
4634        }
4635        if let Some(board) = self.board_cache()?.as_mut()
4636            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4637        {
4638            *held = item.clone();
4639        }
4640        if let Some(found) = self.search_cache()?.as_mut()
4641            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4642        {
4643            *held = item.clone();
4644        }
4645        for found in self.narrowed_cache()?.values_mut() {
4646            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4647                *held = item.clone();
4648            }
4649        }
4650        Ok(())
4651    }
4652
4653    /// Forget one item this process has just deleted, from every half of its own view.
4654    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4655        self.resolved_cache()?.remove(id);
4656        self.created()?.retain(|own| own.id != *id);
4657        self.updated()?.retain(|own| own.id != *id);
4658        if let Some(board) = self.board_cache()?.as_mut() {
4659            board.items.retain(|item| item.id != *id);
4660        }
4661        if let Some(found) = self.search_cache()?.as_mut() {
4662            found.retain(|item| item.id != *id);
4663        }
4664        for found in self.narrowed_cache()?.values_mut() {
4665            found.retain(|item| item.id != *id);
4666        }
4667        Ok(())
4668    }
4669
4670    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4671    fn narrowed_cache(
4672        &self,
4673    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4674        self.narrowed_cache
4675            .lock()
4676            .map_err(|_| SourceError::Unavailable {
4677                message: "this source's view of a narrowed read was left inconsistent by an \
4678                      earlier failure; next: run the command again"
4679                    .into(),
4680            })
4681    }
4682
4683    /// Every page of the board, read from GitHub.
4684    async fn read_board(&self) -> Result<Board, SourceError> {
4685        let mut after: Option<String> = None;
4686        let mut items = Vec::new();
4687        let mut board;
4688        loop {
4689            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
4690            for item in page
4691                .pointer("/items/nodes")
4692                .and_then(Value::as_array)
4693                .ok_or_else(|| SourceError::Malformed {
4694                    message: "GitHub project items.nodes is not an array".into(),
4695                })?
4696            {
4697                if let Some(resolved) = self.resolve(item)? {
4698                    items.push(resolved);
4699                }
4700            }
4701            let info = page
4702                .pointer("/items/pageInfo")
4703                .ok_or_else(|| SourceError::Malformed {
4704                    message: "GitHub project items have no pageInfo".into(),
4705                })?;
4706            let has_next = required_bool(info, "hasNextPage")?;
4707            let next = has_next
4708                .then(|| required_str(info, "endCursor"))
4709                .transpose()?;
4710            board = page.clone();
4711            match next {
4712                Some(next) => {
4713                    validate_cursor_progress(after.as_deref(), next)?;
4714                    after = Some(next.to_owned());
4715                }
4716                None => break,
4717            }
4718        }
4719        Ok(Board {
4720            id: required_str(&board, "id")?.to_owned(),
4721            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4722            items,
4723        })
4724    }
4725
4726    /// The existing items this source has written, for completing a narrowed read that is
4727    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
4728    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4729        self.updated.lock().map_err(|_| SourceError::Unavailable {
4730            message: "this source's record of what it wrote in this run was left inconsistent \
4731                      by an earlier failure; next: run the command again"
4732                .into(),
4733        })
4734    }
4735
4736    /// The items this source has created, for completing a board read that is behind.
4737    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4738        self.created.lock().map_err(|_| SourceError::Unavailable {
4739            message: "this source's record of what it created in this run was left \
4740                      inconsistent by an earlier failure; next: run the command again"
4741                .into(),
4742        })
4743    }
4744
4745    /// One board item as this source reports it, or `None` for content it ignores.
4746    ///
4747    /// A pull request is neither a project nor a task — it is somebody's change, not a
4748    /// unit of plan — and an item whose content the token cannot see has nothing to
4749    /// report at all.
4750    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
4751        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
4752            message: "GitHub project item is missing content".into(),
4753        })?;
4754        if content.is_null() {
4755            return Ok(None);
4756        }
4757        let content_kind = match required_str(content, "__typename")? {
4758            "Issue" => ContentKind::Issue,
4759            "DraftIssue" => ContentKind::DraftIssue,
4760            _ => return Ok(None),
4761        };
4762        let field_values = item
4763            .get("fieldValues")
4764            .ok_or_else(|| SourceError::Malformed {
4765                message: "GitHub project item is missing fieldValues".into(),
4766            })?;
4767        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
4768        let nodes = field_values
4769            .get("nodes")
4770            .and_then(Value::as_array)
4771            .ok_or_else(|| SourceError::Malformed {
4772                message: "GitHub project item fieldValues.nodes is not an array".into(),
4773            })?;
4774        if let Some(labels) = content.get("labels") {
4775            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
4776        }
4777        let raw_body = optional_str(content, "body")?.map(str::to_owned);
4778        let (body, slot) = metadata_body(raw_body.clone())?;
4779        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
4780            .map(|id| NativeId(id.to_owned()));
4781        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
4782        // to read one from; it is a task, and never a project.
4783        let sub_issues = match content_kind {
4784            ContentKind::Issue => sub_issue_total(content)?,
4785            ContentKind::DraftIssue => 0,
4786        };
4787        let content_id = required_str(content, "id")?;
4788        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
4789            message: format!("GitHub issue {content_id}: {message}"),
4790        })?;
4791        let raw_title = required_str(content, "title")?;
4792        // The design prefix is read *first*, before either of the two rules that separate
4793        // a project from a task. A document is not work whatever sub-issues it has and
4794        // whatever marker it carries, and reading the prefix later would make a design
4795        // issue with none of either an empty project.
4796        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
4797            BoardKind::Document
4798        } else if parent.is_some() {
4799            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
4800            // under a project is that project's task even when it has sub-issues of its
4801            // own.
4802            BoardKind::Work(ItemKind::Task)
4803        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
4804            BoardKind::Work(ItemKind::Project)
4805        } else {
4806            BoardKind::Work(ItemKind::Task)
4807        };
4808        // The title a person wrote, which for a document is the one without the prefix —
4809        // the same way `content` above is the body without this source's metadata slot.
4810        let title = match kind {
4811            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
4812            BoardKind::Work(_) => raw_title.to_owned(),
4813        };
4814        let own_repository = content
4815            .pointer("/repository/nameWithOwner")
4816            .and_then(Value::as_str)
4817            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
4818            .transpose()
4819            .map_err(|message| SourceError::Malformed { message })?;
4820        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
4821            Repository::from_metadata(&slot)
4822                .map_err(|message| SourceError::Malformed { message })?
4823        } else {
4824            own_repository.clone().into_iter().collect()
4825        };
4826        let id = NativeId(content_id.to_owned());
4827        // Read only for a task, because only a task has either list: a project or a
4828        // document holding one of these keys holds nothing this source reports, and the
4829        // keys are left out of its caller-visible metadata all the same.
4830        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
4831            let listed = |key: &str| {
4832                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
4833                    .map_err(|message| SourceError::Malformed { message })
4834            };
4835            (
4836                listed(TaskRef::DELIVERS_KEY)?,
4837                listed(TaskRef::DELIVERED_BY_KEY)?,
4838            )
4839        } else {
4840            (Vec::new(), Vec::new())
4841        };
4842        let (option, closed, reason) = Self::status_parts(nodes, content)?;
4843        let priority = self.held_priority(nodes)?;
4844        let resolved = Resolved {
4845            item_id: required_str(item, "id")?.to_owned(),
4846            id,
4847            content_kind,
4848            kind,
4849            title,
4850            body: body.filter(|value| !value.is_empty()),
4851            raw_body,
4852            status: self.statuses.status(option, closed, reason),
4853            option: option.map(str::to_owned),
4854            priority,
4855            closed,
4856            delivers,
4857            delivered_by,
4858            labels: labels(content)?,
4859            parent,
4860            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
4861            number: match content_kind {
4862                ContentKind::Issue => Some(issue_number(content)?),
4863                // A draft is filed in no repository, so nothing ever numbered it:
4864                // `DraftIssue` declares no `number` at all, exactly as it declares no
4865                // `subIssuesSummary` the branch above reads.
4866                ContentKind::DraftIssue => None,
4867            },
4868            url: optional_str(content, "url")?.map(str::to_owned),
4869            created_at: optional_time(content, "createdAt")?,
4870            updated_at: optional_time(content, "updatedAt")?,
4871            own_repository,
4872            repositories,
4873            slot,
4874            // Present when the item was reached through its own issue, whose board entry
4875            // names the board; a read of the board's own items has the board already. An
4876            // empty id names nothing a field write could address, so it is read as absent and
4877            // the write goes back to reading the board.
4878            board_id: item
4879                .pointer("/project/id")
4880                .and_then(Value::as_str)
4881                .filter(|id| !id.is_empty())
4882                .map(str::to_owned),
4883            fields: field_definitions(nodes),
4884        };
4885        self.resolved_cache()?
4886            .insert(resolved.id.clone(), resolved.clone());
4887        Ok(Some(resolved))
4888    }
4889
4890    /// What one board item's `Priority` field says, through this instance's mapping.
4891    ///
4892    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
4893    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
4894    /// option the mapping does not name is kept as itself — never read as a level or as
4895    /// `none` — for a read of the task to report by name.
4896    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
4897        let Some(mapping) = &self.priorities else {
4898            return Ok(HeldPriority::Read(Priority::None));
4899        };
4900        // A value of the field that names no option — a text field someone called `Priority` —
4901        // is malformed rather than `none`: reading it as no priority would let the next copy
4902        // clear one a person set.
4903        let Some(option) = field_values
4904            .iter()
4905            .find(|value| {
4906                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
4907            })
4908            .map(|value| required_str(value, "name"))
4909            .transpose()?
4910        else {
4911            return Ok(HeldPriority::Read(Priority::None));
4912        };
4913        Ok(mapping.priority_of(option).map_or_else(
4914            || HeldPriority::Unmapped(option.to_owned()),
4915            HeldPriority::Read,
4916        ))
4917    }
4918
4919    /// What one board item's status is read from: its `Status` option, whether its issue
4920    /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
4921    /// three into the status it reports.
4922    fn status_parts<'a>(
4923        field_values: &'a [Value],
4924        content: &'a Value,
4925    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
4926        let option = field_values
4927            .iter()
4928            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
4929            .map(|value| required_str(value, "name"))
4930            .transpose()?;
4931        let closed = optional_str(content, "state")? == Some("CLOSED");
4932        Ok((option, closed, optional_str(content, "stateReason")?))
4933    }
4934
4935    /// The board Status option this write selects, or the refusal that says why not.
4936    ///
4937    /// The mapped option is required for both open and terminal targets. A terminal write
4938    /// validates it before changing either representation, so it can never fall back to
4939    /// closing an issue whose board cannot display the matching status.
4940    ///
4941    /// Answers the field's id, the option's id, and the option's name as the board spells
4942    /// it — which is the name a read of the item reports once it sits there.
4943    fn column_for(
4944        &self,
4945        fields: &Value,
4946        status: &Status,
4947        target: &StatusTarget,
4948    ) -> Result<Option<(String, String, String)>, SourceError> {
4949        let wanted = match target {
4950            StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
4951            StatusTarget::Disabled => return Ok(None),
4952        };
4953        let missing = |detail: &str| SourceError::Refused {
4954            message: format!(
4955                "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",
4956                category_name(status.category),
4957                self.name,
4958                category_name(status.category)
4959            ),
4960        };
4961        let Some(field) = Board::field(fields, "Status")? else {
4962            return Err(missing("this board has no Status field"));
4963        };
4964        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4965            return Err(missing(
4966                "this board's Status field is not a single-select field",
4967            ));
4968        }
4969        let option = field
4970            .get("options")
4971            .and_then(Value::as_array)
4972            .and_then(|options| {
4973                options.iter().find(|option| {
4974                    option
4975                        .get("name")
4976                        .and_then(Value::as_str)
4977                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4978                })
4979            });
4980        match option {
4981            None => Err(missing("this board does not have it")),
4982            Some(option) => Ok(Some((
4983                required_str(field, "id")?.to_owned(),
4984                required_str(option, "id")?.to_owned(),
4985                required_str(option, "name")?.to_owned(),
4986            ))),
4987        }
4988    }
4989
4990    /// The refusal a status that closes an issue is answered with over a board draft.
4991    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
4992        SourceError::Refused {
4993            message: format!(
4994                "status {} of source {} closes the item's issue, and GitHub draft items have \
4995                 no open or closed state",
4996                category_name(category),
4997                self.name
4998            ),
4999        }
5000    }
5001
5002    /// What a status write to one item needs of the board: the board's id and the
5003    /// definition of its `Status` field, read off the item when the item says both.
5004    ///
5005    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5006    /// and its `Status` value carries that field's definition, options and all. An item that
5007    /// does not say — no board id, or no `Status` value to read the field off — takes them
5008    /// from [`Self::board_fields`], which reads no item.
5009    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5010        if item.defines("Status")
5011            && let Some(board_id) = item.named_board()
5012        {
5013            return Ok(BoardFields {
5014                id: board_id,
5015                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5016            });
5017        }
5018        self.board_fields().await
5019    }
5020
5021    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5022    async fn set_status(
5023        &self,
5024        id: &NativeId,
5025        category: StatusCategory,
5026    ) -> Result<Option<Status>, SourceError> {
5027        // Refused before anything is read, in the words a write of the same status is.
5028        let target = self.resolved_target(category)?;
5029        let Some(mut item) = self
5030            .bound_item(id)
5031            .await?
5032            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5033        else {
5034            return Ok(None);
5035        };
5036        let board = self.status_board(&item).await?;
5037        let wanted = Status {
5038            category,
5039            name: category_name(category).to_owned(),
5040        };
5041        let (field, option, name) = self
5042            .column_for(&board.fields, &wanted, &target)?
5043            .ok_or_else(|| SourceError::Malformed {
5044                message: format!(
5045                    "status {} of source {} names no board Status option",
5046                    category_name(category),
5047                    self.name
5048                ),
5049            })?;
5050        if item.status.category == category && item.option.as_deref() == Some(&name) {
5051            return Ok(Some(item.status));
5052        }
5053        match &target {
5054            StatusTarget::Terminal(_, reason) => {
5055                if item.content_kind == ContentKind::DraftIssue {
5056                    return Err(self.closes_a_draft(category));
5057                }
5058                self.set_item_field(
5059                    board.id.as_str(),
5060                    &item.item_id,
5061                    &field,
5062                    json!({"singleSelectOptionId": option}),
5063                )
5064                .await?;
5065                self.update_content(
5066                    ContentKind::Issue,
5067                    &item.id,
5068                    json!({"stateInput": state_input(Some(&target))}),
5069                )
5070                .await?;
5071                item.closed = true;
5072                item.status = self
5073                    .statuses
5074                    .status(Some(&name), true, Some(reason.reason()));
5075                item.option = Some(name);
5076            }
5077            StatusTarget::Column(_) => {
5078                // An option is what an open item's status is, so a closed issue is reopened
5079                // first — sitting closed in the column, it would read back as closed. A draft has
5080                // no state to reopen.
5081                if item.content_kind == ContentKind::Issue && item.closed {
5082                    self.update_content(
5083                        ContentKind::Issue,
5084                        &item.id,
5085                        json!({"stateInput": state_input(Some(&target))}),
5086                    )
5087                    .await?;
5088                    item.closed = false;
5089                }
5090                self.set_item_field(
5091                    board.id.as_str(),
5092                    &item.item_id,
5093                    &field,
5094                    json!({"singleSelectOptionId": option}),
5095                )
5096                .await?;
5097                item.status = self.statuses.status(Some(&name), false, None);
5098                item.option = Some(name);
5099            }
5100            StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5101        }
5102        let status = item.status.clone();
5103        self.remember_written(item, false)?;
5104        Ok(Some(status))
5105    }
5106
5107    /// Replace one task's `delivered_by` and nothing else; see
5108    /// [`TaskSource::set_delivered_by`].
5109    ///
5110    /// One update of the body, which differs from the body GitHub holds only inside the
5111    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5112    async fn replace_delivered_by(
5113        &self,
5114        id: &NativeId,
5115        delivered_by: &[TaskRef],
5116    ) -> Result<Option<()>, SourceError> {
5117        let entries = TaskRef::listed(
5118            TaskRef::DELIVERED_BY_KEY,
5119            id,
5120            Some(&self.name),
5121            delivered_by.to_vec(),
5122        )
5123        .map_err(|message| SourceError::Refused { message })?;
5124        let Some(mut item) = self
5125            .bound_item(id)
5126            .await?
5127            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5128        else {
5129            return Ok(None);
5130        };
5131        let mut slot = item.slot.clone();
5132        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5133        self.write_slot(&mut item, &slot).await?;
5134        item.delivered_by = entries;
5135        self.remember_written(item, false)?;
5136        Ok(Some(()))
5137    }
5138
5139    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5140    /// see [`TaskSource::set_task_metadata`].
5141    ///
5142    /// `None` when this board holds no item by that id, or holds one of another kind. The
5143    /// answer is the item as this source now reads it, so what a caller is told the key
5144    /// holds is what the slot holds.
5145    ///
5146    /// A key already holding the value is answered without a write, compared as JSON rather
5147    /// than as the body's bytes: a slot a person spelled with other whitespace would
5148    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5149    async fn set_slot_key(
5150        &self,
5151        id: &NativeId,
5152        kind: BoardKind,
5153        key: &MetadataKey,
5154        value: &Value,
5155    ) -> Result<Option<Resolved>, SourceError> {
5156        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5157            return Ok(None);
5158        };
5159        if item.slot.get(key.as_str()) == Some(value) {
5160            return Ok(Some(item));
5161        }
5162        let mut slot = item.slot.clone();
5163        slot.insert(key.as_str().to_owned(), value.clone());
5164        self.write_slot(&mut item, &slot).await?;
5165        self.remember_written(item.clone(), false)?;
5166        Ok(Some(item))
5167    }
5168
5169    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5170    /// `item` up to what that write left.
5171    ///
5172    /// The body sent differs from the body GitHub holds only inside the slot — see
5173    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5174    /// the mutation the item's content takes, so a board draft's body is written with
5175    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5176    async fn write_slot(
5177        &self,
5178        item: &mut Resolved,
5179        slot: &BTreeMap<String, Value>,
5180    ) -> Result<(), SourceError> {
5181        let held = item.raw_body.clone().unwrap_or_default();
5182        let body = with_slot(&held, slot)?;
5183        if body != held {
5184            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5185                .await?;
5186        }
5187        let (visible, slot) = metadata_body(Some(body.clone()))?;
5188        item.body = visible.filter(|value| !value.is_empty());
5189        item.raw_body = Some(body);
5190        item.slot = slot;
5191        Ok(())
5192    }
5193
5194    /// This instance's target for a category, refusing one it has disabled.
5195    ///
5196    /// Nothing here mutates the board's option set to make room for a status. GitHub
5197    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5198    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5199    /// field and every item's status.
5200    fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5201        let target = self.statuses.target(category).clone();
5202        if target != StatusTarget::Disabled {
5203            return Ok(target);
5204        }
5205        Err(SourceError::Refused {
5206            message: if category == StatusCategory::Draft {
5207                format!(
5208                    "status draft is disabled for source {}: draft is incompatible with this \
5209                     integration because GitHub draft issues cannot have sub-issues, and this \
5210                     source stores a project's tasks as its issue's sub-issues",
5211                    self.name
5212                )
5213            } else if category == StatusCategory::Unknown {
5214                format!(
5215                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5216                     to one board Status option name; every word classified unknown is written \
5217                     to that one option",
5218                    category_name(category),
5219                    self.name,
5220                    category_name(category)
5221                )
5222            } else {
5223                format!(
5224                    "status {} is disabled for source {}; set status_mapping.{} of this source \
5225                     to a board Status option name",
5226                    category_name(category),
5227                    self.name,
5228                    category_name(category)
5229                )
5230            },
5231        })
5232    }
5233
5234    /// What writing `priority` does to one item's `Priority` field on this board, or the
5235    /// refusal naming what the board lacks.
5236    ///
5237    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5238    /// none already, or of an item not created yet. Every other priority selects the option
5239    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5240    /// without that option, is refused rather than given one: reads and writes never create
5241    /// a field or an option.
5242    fn priority_write(
5243        &self,
5244        fields: &Value,
5245        existing: Option<&Resolved>,
5246        priority: Priority,
5247    ) -> Result<Option<PriorityWrite>, SourceError> {
5248        let Some(mapping) = &self.priorities else {
5249            return Err(self.holds_no_priority());
5250        };
5251        let Some(wanted) = mapping.option(priority) else {
5252            if !existing.is_some_and(Resolved::holds_priority) {
5253                return Ok(None);
5254            }
5255            let field =
5256                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5257                    message: format!(
5258                        "an item holding a {PRIORITY_FIELD} value was read without that field"
5259                    ),
5260                })?;
5261            return Ok(Some(PriorityWrite::Clear {
5262                field: required_str(field, "id")?.to_owned(),
5263            }));
5264        };
5265        let missing = |detail: &str| SourceError::Refused {
5266            message: format!(
5267                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5268                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5269                 it, or point priority_mapping.{priority} of this source at an option the board \
5270                 has",
5271                self.name, self.name
5272            ),
5273        };
5274        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5275            return Err(missing(&format!(
5276                "this board has no {PRIORITY_FIELD} field"
5277            )));
5278        };
5279        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5280            return Err(missing(&format!(
5281                "this board's {PRIORITY_FIELD} field is not a single-select field"
5282            )));
5283        }
5284        // An options list that is absent or not a list is an answer this source cannot read,
5285        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5286        let option = field
5287            .get("options")
5288            .and_then(Value::as_array)
5289            .ok_or_else(|| SourceError::Malformed {
5290                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5291            })?
5292            .iter()
5293            .find(|option| {
5294                option
5295                    .get("name")
5296                    .and_then(Value::as_str)
5297                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5298            })
5299            .ok_or_else(|| missing("this board does not have it"))?;
5300        Ok(Some(PriorityWrite::Select {
5301            field: required_str(field, "id")?.to_owned(),
5302            option: required_str(option, "id")?.to_owned(),
5303        }))
5304    }
5305
5306    /// Apply one priority write to one board item.
5307    async fn write_priority(
5308        &self,
5309        board_id: &str,
5310        item_id: &str,
5311        write: &PriorityWrite,
5312    ) -> Result<(), SourceError> {
5313        match write {
5314            PriorityWrite::Select { field, option } => {
5315                self.set_item_field(
5316                    board_id,
5317                    item_id,
5318                    field,
5319                    json!({"singleSelectOptionId": option}),
5320                )
5321                .await
5322            }
5323            PriorityWrite::Clear { field } => {
5324                let data = self
5325                    .graphql(
5326                        graphql::CLEAR_FIELD,
5327                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5328                            "readPriority":false,"priorityName":PRIORITY_FIELD}),
5329                    )
5330                    .await?;
5331                let returned = data
5332                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5333                    .ok_or_else(|| SourceError::Malformed {
5334                        message: "GitHub field clear returned no project item".into(),
5335                    })?;
5336                if required_str(returned, "id")? != item_id {
5337                    return Err(SourceError::Malformed {
5338                        message: "GitHub field clear returned the wrong project item".into(),
5339                    });
5340                }
5341                Ok(())
5342            }
5343        }
5344    }
5345
5346    /// The refusal a priority is answered with by an instance configured with no
5347    /// `priority_mapping`, which holds none.
5348    fn holds_no_priority(&self) -> SourceError {
5349        SourceError::Refused {
5350            message: format!(
5351                "source {} holds no task priority: its configuration sets no priority_mapping; \
5352                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5353                 fields {} --apply` to set its board up",
5354                self.name, self.name
5355            ),
5356        }
5357    }
5358
5359    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5360    ///
5361    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5362    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5363    async fn set_priority(
5364        &self,
5365        id: &NativeId,
5366        priority: Priority,
5367    ) -> Result<Option<Priority>, SourceError> {
5368        if self.priorities.is_none() {
5369            return Err(self.holds_no_priority());
5370        }
5371        let Some(mut item) = self
5372            .bound_item(id)
5373            .await?
5374            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5375        else {
5376            return Ok(None);
5377        };
5378        if priority == Priority::None && !item.holds_priority() {
5379            return Ok(Some(priority));
5380        }
5381        // The item's own read carries the field's definition whenever it holds a value of
5382        // it, which a clear always does; a select onto an item holding none reads the board.
5383        let board = match item.named_board() {
5384            Some(id) if item.defines(PRIORITY_FIELD) => BoardFields {
5385                id,
5386                fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5387            },
5388            _ => self.board_fields().await?,
5389        };
5390        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5391            return Ok(Some(priority));
5392        };
5393        let (document, root, input) = match write {
5394            PriorityWrite::Select { field, option } => (
5395                graphql::UPDATE_FIELD,
5396                "updateProjectV2ItemFieldValue",
5397                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5398            ),
5399            PriorityWrite::Clear { field } => (
5400                graphql::CLEAR_FIELD,
5401                "clearProjectV2ItemFieldValue",
5402                json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5403            ),
5404        };
5405        let data = self
5406            .graphql(
5407                document,
5408                json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5409            )
5410            .await?;
5411        let returned = data
5412            .get(root)
5413            .and_then(|value| value.get("projectV2Item"))
5414            .ok_or_else(|| SourceError::Malformed {
5415                message: "GitHub priority write returned no project item".into(),
5416            })?;
5417        if required_str(returned, "id")? != item.item_id {
5418            return Err(SourceError::Malformed {
5419                message: "GitHub priority write returned the wrong project item".into(),
5420            });
5421        }
5422        let value = returned
5423            .get("fieldValueByName")
5424            .ok_or_else(|| SourceError::Malformed {
5425                message: "GitHub priority write returned no priority read-back".into(),
5426            })?;
5427        if !value.is_null()
5428            && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5429        {
5430            return Err(SourceError::Malformed {
5431                message: "GitHub priority read-back is not a Priority field value".into(),
5432            });
5433        }
5434        let values = if value.is_null() {
5435            Vec::new()
5436        } else {
5437            vec![value.clone()]
5438        };
5439        item.priority = self.held_priority(&values)?;
5440        let answer = item.task()?.priority;
5441        self.remember_written(item, false)?;
5442        Ok(Some(answer))
5443    }
5444
5445    /// Replace one task's visible body and nothing else; see
5446    /// [`TaskSource::set_task_content`].
5447    ///
5448    /// One update of the body, which differs from the body GitHub holds only outside the
5449    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5450    /// this source keeps there reads back as it was. A body that would not change is not
5451    /// sent at all.
5452    async fn replace_content(
5453        &self,
5454        id: &NativeId,
5455        content: &str,
5456    ) -> Result<Option<()>, SourceError> {
5457        let Some(mut item) = self
5458            .bound_item(id)
5459            .await?
5460            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5461        else {
5462            return Ok(None);
5463        };
5464        let held = item.raw_body.clone().unwrap_or_default();
5465        let body = with_content(&held, content)?;
5466        // Checked before anything is sent: content ending in what this source reads as its own
5467        // metadata slot would read back as metadata rather than as the content it was.
5468        let (visible, slot) = metadata_body(Some(body.clone()))?;
5469        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5470            return Err(SourceError::Refused {
5471                message: format!(
5472                    "this content ends in what source {} reads as its own metadata slot \
5473                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5474                     as content; next: remove that trailing block from the content",
5475                    self.name
5476                ),
5477            });
5478        }
5479        if body != held {
5480            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5481                .await?;
5482        }
5483        item.body = visible.filter(|value| !value.is_empty());
5484        item.raw_body = Some(body);
5485        item.slot = slot;
5486        self.remember_written(item, false)?;
5487        Ok(Some(()))
5488    }
5489
5490    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5491    ///
5492    /// One read of the item, and then only what differs from it: at most one `updateIssue`
5493    /// carrying the title, the body — visible content and metadata slot together — and a
5494    /// state change, at most one `Status` option write and one `Priority` field write, and the
5495    /// `blockedBy` additions and removals the named edges differ by. A terminal status selects
5496    /// its option and then closes, as a whole write does; an open one reopens and then selects
5497    /// its option, as [`Self::set_status`] does. The origin field is never written: an update
5498    /// is of an item that already exists, whose origin is what it is.
5499    ///
5500    /// The task answered is the item as those writes left it, built from the read and what was
5501    /// sent rather than read again — the same record a later read in this run answers from.
5502    async fn targeted_update(
5503        &self,
5504        id: &NativeId,
5505        update: &TaskUpdate,
5506    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5507        // Everything this source can refuse without reading the item is refused first, in the
5508        // words a whole write of the same fields is refused with.
5509        update.consistent()?;
5510        if update
5511            .title
5512            .as_deref()
5513            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5514        {
5515            return Err(SourceError::Refused {
5516                message: format!(
5517                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5518                     spells a document, so it would read back as one rather than as a task; \
5519                     retitle it",
5520                    self.name
5521                ),
5522            });
5523        }
5524        if let Some(delivers) = &update.delivers {
5525            TaskRef::listed(
5526                TaskRef::DELIVERS_KEY,
5527                id,
5528                Some(&self.name),
5529                delivers.clone(),
5530            )
5531            .map_err(|message| SourceError::Refused { message })?;
5532        }
5533        if self.priorities.is_none()
5534            && update
5535                .priority
5536                .is_some_and(|priority| priority != Priority::None)
5537        {
5538            return Err(self.holds_no_priority());
5539        }
5540        let target = update
5541            .status
5542            .as_ref()
5543            .map(|status| self.resolved_target(status.category))
5544            .transpose()?;
5545        let Some(mut item) = self
5546            .bound_item(id)
5547            .await?
5548            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5549        else {
5550            return Ok(None);
5551        };
5552        let before = item.task()?;
5553
5554        let mut status_move = None;
5555        if let (Some(status), Some(target)) = (&update.status, target) {
5556            let board = self.status_board(&item).await?;
5557            let (field, option, name) = self
5558                .column_for(&board.fields, status, &target)?
5559                .ok_or_else(|| SourceError::Malformed {
5560                    message: format!(
5561                        "status {} of source {} names no board Status option",
5562                        category_name(status.category),
5563                        self.name
5564                    ),
5565                })?;
5566            let terminal = matches!(target, StatusTarget::Terminal(_, _));
5567            if terminal && item.content_kind == ContentKind::DraftIssue {
5568                return Err(self.closes_a_draft(status.category));
5569            }
5570            let landed = match &target {
5571                StatusTarget::Terminal(_, reason) => {
5572                    self.statuses
5573                        .status(Some(&name), true, Some(reason.reason()))
5574                }
5575                _ => self.statuses.status(Some(&name), false, None),
5576            };
5577            let option_moves = item
5578                .option
5579                .as_deref()
5580                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5581            let state_moves = item.content_kind == ContentKind::Issue
5582                && (item.closed != terminal || (terminal && item.status != landed));
5583            if let Some(moves) = Moves::of(option_moves, state_moves) {
5584                status_move = Some(StatusMove {
5585                    board: board.id,
5586                    field,
5587                    option,
5588                    name,
5589                    target,
5590                    landed,
5591                    moves,
5592                });
5593            }
5594        }
5595
5596        let mut priority_move = None;
5597        if let Some(priority) = update.priority
5598            && self.priorities.is_some()
5599            && item.priority != HeldPriority::Read(priority)
5600        {
5601            let board = match item.named_board() {
5602                Some(board) if item.defines(PRIORITY_FIELD) => BoardFields {
5603                    id: board,
5604                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5605                },
5606                _ => self.board_fields().await?,
5607            };
5608            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5609                priority_move = Some((board.id, write, priority));
5610            }
5611        }
5612
5613        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5614        // recorded in the slot, and the slot travels in the one body update below.
5615        let edges = match &update.depends_on {
5616            Some(edges) => Some(
5617                self.partition_edges(BoardKind::Work(ItemKind::Task), item.content_kind, edges)
5618                    .await?,
5619            ),
5620            None => None,
5621        };
5622
5623        let mut slot = item.slot.clone();
5624        for (key, value) in &update.metadata_set {
5625            slot.insert(key.as_str().to_owned(), value.clone());
5626        }
5627        for key in &update.metadata_remove {
5628            slot.remove(key.as_str());
5629        }
5630        if let Some(delivers) = &update.delivers {
5631            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
5632        }
5633        if let Some((_, recorded)) = &edges {
5634            record_edges(&mut slot, recorded);
5635        }
5636        let held = item.raw_body.clone().unwrap_or_default();
5637        let content = match &update.content {
5638            Some(content) => with_content(&held, content)?,
5639            None => held.clone(),
5640        };
5641        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
5642        // the body's bytes, as a metadata write compares it: a slot a person spelled with
5643        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
5644        let body = if slot == item.slot {
5645            content
5646        } else {
5647            with_slot(&content, &slot)?
5648        };
5649        // Checked before anything is sent, as a content write checks it: content ending in
5650        // what this source reads as its own slot would read back as metadata.
5651        let (visible, read) = metadata_body(Some(body.clone()))?;
5652        let wanted = update.content.as_deref().or(item.body.as_deref());
5653        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
5654            return Err(SourceError::Refused {
5655                message: format!(
5656                    "this content ends in what source {} reads as its own metadata slot \
5657                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5658                     as content; next: remove that trailing block from the content",
5659                    self.name
5660                ),
5661            });
5662        }
5663        let recorded_moves =
5664            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
5665
5666        // One `updateIssue` carries all three, because every mutation spends the secondary
5667        // limiter and the title, body and state are one mutation's inputs.
5668        let mut fields = serde_json::Map::new();
5669        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
5670            fields.insert("title".to_owned(), json!(title));
5671        }
5672        if body != held {
5673            fields.insert("body".to_owned(), json!(body));
5674        }
5675        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
5676            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
5677        }
5678        let terminal = status_move
5679            .as_ref()
5680            .is_some_and(|moving| matches!(moving.target, StatusTarget::Terminal(_, _)));
5681        // A terminal option is selected before the issue closes, so a close never lands on an
5682        // item whose board cannot show it; an open one after the issue reopens.
5683        if terminal {
5684            self.select_option(&item, status_move.as_ref()).await?;
5685        }
5686        if !fields.is_empty() {
5687            self.update_content(item.content_kind, &item.id, Value::Object(fields))
5688                .await?;
5689        }
5690        if !terminal {
5691            self.select_option(&item, status_move.as_ref()).await?;
5692        }
5693        if let Some((board, write, _)) = &priority_move {
5694            self.write_priority(board.as_str(), &item.item_id, write)
5695                .await?;
5696        }
5697        let mut blocked_by_moved = false;
5698        if let Some((native, _)) = &edges
5699            && item.content_kind == ContentKind::Issue
5700        {
5701            blocked_by_moved = self
5702                .reconcile_blocked_by(&item.id, native, Issue::Existing)
5703                .await?;
5704        }
5705
5706        if let Some(title) = &update.title {
5707            item.title.clone_from(title);
5708        }
5709        item.body = visible.filter(|value| !value.is_empty());
5710        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
5711        item.slot = slot;
5712        if let Some(delivers) = &update.delivers {
5713            item.delivers.clone_from(delivers);
5714        }
5715        if let Some(moving) = status_move {
5716            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
5717                && item.content_kind == ContentKind::Issue;
5718            item.status = moving.landed;
5719            item.option = Some(moving.name);
5720        }
5721        if let Some((_, _, priority)) = priority_move {
5722            item.priority = HeldPriority::Read(priority);
5723        }
5724        let task = item.task()?;
5725        let mut written = update.changed(&before, &task);
5726        if blocked_by_moved || recorded_moves {
5727            written.insert(UpdatedField::DependsOn);
5728        }
5729        self.remember_written(item, false)?;
5730        Ok(Some(TaskUpdateOutcome {
5731            task,
5732            written,
5733            delivers_before: before.delivers,
5734        }))
5735    }
5736
5737    /// Select the `Status` option one targeted update moves an item to, when it moves it.
5738    async fn select_option(
5739        &self,
5740        item: &Resolved,
5741        moving: Option<&StatusMove>,
5742    ) -> Result<(), SourceError> {
5743        let Some(moving) = moving.filter(|moving| moving.moves.option()) else {
5744            return Ok(());
5745        };
5746        self.set_item_field(
5747            moving.board.as_str(),
5748            &item.item_id,
5749            &moving.field,
5750            json!({"singleSelectOptionId": moving.option}),
5751        )
5752        .await
5753    }
5754
5755    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
5756    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
5757    ///
5758    /// One update of the body: the content outside the slot, and inside it that one entry,
5759    /// every other entry kept as it was. This source keeps no template answers — an issue has
5760    /// no room beside itself that is not its body, and answers written there would duplicate
5761    /// what the content already says and count against GitHub's body limit — so `answers`
5762    /// reaches nothing here. A body that would not change is not sent at all.
5763    async fn replace_rendering(
5764        &self,
5765        id: &NativeId,
5766        kind: BoardKind,
5767        content: &str,
5768        provenance: &Value,
5769    ) -> Result<Option<()>, SourceError> {
5770        let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5771            return Ok(None);
5772        };
5773        let held = item.raw_body.clone().unwrap_or_default();
5774        let mut slot = item.slot.clone();
5775        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
5776        let body = with_slot(&with_content(&held, content)?, &slot)?;
5777        // Checked before anything is sent, as a content write checks it.
5778        let (visible, read) = metadata_body(Some(body.clone()))?;
5779        if visible.as_deref().unwrap_or_default() != content || read != slot {
5780            return Err(SourceError::Refused {
5781                message: format!(
5782                    "this content ends in what source {} reads as its own metadata slot \
5783                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5784                     as content; next: remove that trailing block from the template",
5785                    self.name
5786                ),
5787            });
5788        }
5789        if body != held {
5790            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5791                .await?;
5792        }
5793        item.body = visible.filter(|value| !value.is_empty());
5794        item.raw_body = Some(body);
5795        item.slot = read;
5796        self.remember_written(item, false)?;
5797        Ok(Some(()))
5798    }
5799
5800    async fn set_item_field(
5801        &self,
5802        board_id: &str,
5803        item_id: &str,
5804        field_id: &str,
5805        value: Value,
5806    ) -> Result<(), SourceError> {
5807        let data = self
5808            .graphql(
5809                graphql::UPDATE_FIELD,
5810                json!({"input":{
5811                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
5812                },"readPriority":false,"priorityName":PRIORITY_FIELD}),
5813            )
5814            .await?;
5815        let returned = data
5816            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
5817            .ok_or_else(|| SourceError::Malformed {
5818                message: "GitHub field update returned no project item".into(),
5819            })?;
5820        if required_str(returned, "id")? != item_id {
5821            return Err(SourceError::Malformed {
5822                message: "GitHub field update returned the wrong project item".into(),
5823            });
5824        }
5825        Ok(())
5826    }
5827
5828    /// GitHub accepts one value per field mutation; aliases combine those mutations in
5829    /// one request. Every returned item id is checked, including optional aliases.
5830    async fn set_item_fields(
5831        &self,
5832        board: &str,
5833        item: &str,
5834        fields: &[(String, Value)],
5835        clear: Option<&str>,
5836    ) -> Result<(), SourceError> {
5837        if fields.len() <= 1 && clear.is_none() {
5838            if let Some((field, value)) = fields.first() {
5839                self.set_item_field(board, item, field, value.clone())
5840                    .await?;
5841            }
5842            return Ok(());
5843        }
5844        if fields.is_empty() {
5845            if let Some(field) = clear {
5846                self.write_priority(
5847                    board,
5848                    item,
5849                    &PriorityWrite::Clear {
5850                        field: field.to_owned(),
5851                    },
5852                )
5853                .await?;
5854            }
5855            return Ok(());
5856        }
5857        let input = |index: usize| {
5858            let (field, value) = fields.get(index).unwrap_or(&fields[0]);
5859            json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
5860        };
5861        let data = self.graphql(graphql::UPDATE_FIELDS, json!({
5862            "input":input(0),"second":input(1),"third":input(2),
5863            "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
5864            "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
5865        })).await?;
5866        for alias in [
5867            Some("updateProjectV2ItemFieldValue"),
5868            (fields.len() > 1).then_some("second"),
5869            (fields.len() > 2).then_some("third"),
5870            clear.map(|_| "cleared"),
5871        ]
5872        .into_iter()
5873        .flatten()
5874        {
5875            let returned = data
5876                .get(alias)
5877                .and_then(|value| value.get("projectV2Item"))
5878                .ok_or_else(|| SourceError::Malformed {
5879                    message: format!("GitHub field update {alias} returned no project item"),
5880                })?;
5881            if required_str(returned, "id")? != item {
5882                return Err(SourceError::Malformed {
5883                    message: format!("GitHub field update {alias} returned the wrong project item"),
5884                });
5885            }
5886        }
5887        Ok(())
5888    }
5889
5890    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
5891        let mut after: Option<String> = None;
5892        let mut ids = Vec::new();
5893        loop {
5894            let data = self
5895                .graphql(
5896                    graphql::ISSUE_DEPENDENCIES,
5897                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
5898                )
5899                .await?;
5900            let connection =
5901                data.pointer("/node/blockedBy")
5902                    .ok_or_else(|| SourceError::Malformed {
5903                        message: "GitHub dependency response has no blockedBy connection".into(),
5904                    })?;
5905            ids.extend(
5906                connection
5907                    .get("nodes")
5908                    .and_then(Value::as_array)
5909                    .ok_or_else(|| SourceError::Malformed {
5910                        message: "GitHub dependency response nodes is not an array".into(),
5911                    })?
5912                    .iter()
5913                    .map(|value| required_str(value, "id").map(str::to_owned))
5914                    .collect::<Result<Vec<_>, _>>()?,
5915            );
5916            let next = next_cursor(connection)?;
5917            if let Some(next) = &next {
5918                validate_cursor_progress(after.as_deref(), &next.0)?;
5919            }
5920            after = next.map(|cursor| cursor.0);
5921            if after.is_none() {
5922                return Ok(ids);
5923            }
5924        }
5925    }
5926
5927    async fn dependencies(
5928        &self,
5929        id: &NativeId,
5930        near_kind: ItemKind,
5931        direction: Direction,
5932        page: &PageRequest,
5933    ) -> Result<Page<DependencyEdge>, SourceError> {
5934        validate_page(page)?;
5935        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5936        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5937        let recorded = recorded_offset(cursor, direction)?;
5938        // Asked for even in the recorded phase, whose page reads nothing from the
5939        // connection: `__typename` is what says whether this item has a native
5940        // relationship at all, and that is what decides which far ends the reserved key is
5941        // allowed to hold.
5942        let data = self
5943            .graphql(
5944                graphql::ISSUE_DEPENDENCIES,
5945                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
5946                       "after":if recorded.is_some() {None} else {cursor}}),
5947            )
5948            .await?;
5949        let node =
5950            data.get("node")
5951                .filter(|v| !v.is_null())
5952                .ok_or_else(|| SourceError::Refused {
5953                    message: format!(
5954                        "GitHub item {} was not found or does not support dependencies",
5955                        id.0
5956                    ),
5957                })?;
5958        let connection_name = match direction {
5959            Direction::DependsOn => "blockedBy",
5960            Direction::DependedOnBy => "blocking",
5961        };
5962        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
5963        // named natively and the reserved key may hold any far end. An issue's connections
5964        // hold issues, and this source reads them at the near item's own level.
5965        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
5966        if let Some(offset) = recorded {
5967            return Ok(recorded_page(
5968                self.recorded_edges(id, near_kind, direction, natively_names, node)
5969                    .await?,
5970                offset,
5971                limit,
5972            ));
5973        }
5974        if natively_names.is_none() {
5975            return Ok(recorded_page(
5976                self.recorded_edges(id, near_kind, direction, natively_names, node)
5977                    .await?,
5978                0,
5979                limit,
5980            ));
5981        }
5982        let connection = node
5983            .get(connection_name)
5984            .ok_or_else(|| SourceError::Malformed {
5985                message: "GitHub dependency response is missing its connection".into(),
5986            })?;
5987        let nodes = connection
5988            .get("nodes")
5989            .and_then(Value::as_array)
5990            .ok_or_else(|| SourceError::Malformed {
5991                message: "GitHub dependency response nodes is not an array".into(),
5992            })?;
5993        // `from` depends on `to`, always. GitHub spells the same relationship from either
5994        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
5995        // it — so the near item is `from` in one direction and `to` in the other.
5996        let items = nodes
5997            .iter()
5998            .map(|value| {
5999                let related = NativeId(required_str(value, "id")?.into());
6000                let related_kind = related_kind(value)?;
6001                let (from, to) = match direction {
6002                    Direction::DependsOn => (
6003                        DependencyEndpoint::from_native(id.clone(), near_kind),
6004                        DependencyEndpoint::from_native(related, related_kind),
6005                    ),
6006                    Direction::DependedOnBy => (
6007                        DependencyEndpoint::from_native(related, related_kind),
6008                        DependencyEndpoint::from_native(id.clone(), near_kind),
6009                    ),
6010                };
6011                Ok(DependencyEdge {
6012                    from,
6013                    to,
6014                    kind: DependencyKind::Blocks,
6015                })
6016            })
6017            .collect::<Result<Vec<_>, SourceError>>()?;
6018        let mut next = next_cursor(connection)?;
6019        if let Some(next) = &next {
6020            validate_cursor_progress(cursor, &next.0)?;
6021        }
6022        if next.is_none()
6023            && !self
6024                .recorded_edges(id, near_kind, direction, natively_names, node)
6025                .await?
6026                .is_empty()
6027        {
6028            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6029        }
6030        Ok(Page { items, next })
6031    }
6032
6033    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6034    /// a far end in another source has to live: no GitHub issue relationship can name one.
6035    ///
6036    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6037    /// source never writes one down.
6038    ///
6039    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6040    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6041    /// request beyond the read already made, and reading the board for them would be a
6042    /// walk of every item for one field of one. A draft has no body in that answer, because
6043    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6044    /// listing of the board, which can be behind on the very item asked about.
6045    async fn recorded_edges(
6046        &self,
6047        id: &NativeId,
6048        near_kind: ItemKind,
6049        direction: Direction,
6050        natively_names: Option<ItemKind>,
6051        node: &Value,
6052    ) -> Result<Vec<DependencyEdge>, SourceError> {
6053        if direction != Direction::DependsOn {
6054            return Ok(Vec::new());
6055        }
6056        let slot = match node.get("body") {
6057            Some(body) if natively_names.is_some() => {
6058                metadata_body(body.as_str().map(str::to_owned))?.1
6059            }
6060            _ => {
6061                let Some(item) = self.bound_item(id).await? else {
6062                    return Ok(Vec::new());
6063                };
6064                item.slot
6065            }
6066        };
6067        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6068            .map_err(|message| SourceError::Malformed { message })
6069    }
6070
6071    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6072        self.repository
6073            .as_ref()
6074            .ok_or_else(|| SourceError::Refused {
6075                message: format!(
6076                    "source {} has no repository configured, and a GitHub Projects board has no \
6077                 repository of its own to create an issue in; set repository: owner/name on \
6078                 this source",
6079                    self.name
6080                ),
6081            })
6082    }
6083
6084    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6085    /// states.
6086    ///
6087    /// The fallback is demanded first, whichever arm answers: a write without a configured
6088    /// repository is refused naming the field exactly as it was before the rule existed,
6089    /// so a source that could not write before cannot write now, rather than writing for
6090    /// the one item whose own field happens to decide it.
6091    ///
6092    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6093    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6094    /// entry owned by someone other than the owner of the parent issue's repository —
6095    /// GitHub accepts a sub-issue from another repository of the same owner and from no
6096    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6097    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6098    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6099    /// and is visible to the token is checked where its node id is resolved, still before
6100    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6101    /// looked up in a listing of the board, which can be minutes behind an issue its own
6102    /// `projectItems` already places on it — and that read answers first from this process's
6103    /// own record, so a project created moments ago in this command answers though GitHub
6104    /// has not caught up.
6105    async fn creation_target(
6106        &self,
6107        incoming: &Incoming<'_>,
6108    ) -> Result<RepositoryTarget, SourceError> {
6109        let fallback = self.configured_repository()?;
6110        let what = |incoming: &Incoming<'_>| {
6111            format!(
6112                "{} {:?}",
6113                incoming.written.kind().describes(),
6114                incoming.title
6115            )
6116        };
6117        let parent = match incoming.parent {
6118            Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6119                SourceError::Refused {
6120                    message: format!(
6121                        "GitHub project issue {} was not found on the board of source {}, so {} \
6122                         cannot be filed under it",
6123                        parent.0,
6124                        self.name,
6125                        what(incoming)
6126                    ),
6127                }
6128            })?),
6129            None => None,
6130        };
6131        let parents_repository = parent
6132            .as_ref()
6133            .map(|parent| {
6134                // A draft is on the board and so is found, but it has no repository to
6135                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6136                // would refuse the task only once `createIssue` had made it.
6137                if parent.content_kind == ContentKind::DraftIssue {
6138                    return Err(SourceError::Refused {
6139                        message: format!(
6140                            "GitHub project item {} on the board of source {} is a draft, \
6141                             which cannot have sub-issues, so {} cannot be filed under it",
6142                            parent.id.0,
6143                            self.name,
6144                            what(incoming)
6145                        ),
6146                    });
6147                }
6148                // An issue's repository is where a sub-issue is placed and whose owner it
6149                // is compared against, so a parent whose repository this source cannot
6150                // spell as `owner/name` — GitHub's login grammar is wider than this
6151                // source's floor — is one nothing can be filed under.
6152                parent
6153                    .own_repository
6154                    .as_ref()
6155                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6156                    .ok_or_else(|| SourceError::Malformed {
6157                        message: format!(
6158                            "GitHub project issue {} on the board of source {} is in {}, which \
6159                             is not a {}/owner/name repository this source can place {} in",
6160                            parent.id.0,
6161                            self.name,
6162                            parent
6163                                .own_repository
6164                                .as_ref()
6165                                .map_or("no repository", Repository::as_str),
6166                            RepositoryTarget::HOST,
6167                            what(incoming)
6168                        ),
6169                    })
6170            })
6171            .transpose()?;
6172        match incoming.repositories {
6173            [named] => {
6174                let target =
6175                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6176                        message: format!(
6177                            "{} names repository {}, which is not a {}/owner/name repository \
6178                             source {} can create an issue in; name one that is, or name none",
6179                            what(incoming),
6180                            named.as_str(),
6181                            RepositoryTarget::HOST,
6182                            self.name
6183                        ),
6184                    })?;
6185                if let Some(parents) = &parents_repository
6186                    && parents.owner != target.owner
6187                {
6188                    return Err(SourceError::Refused {
6189                        message: format!(
6190                            "{} names repository {}, owned by {}, but its project's issue is in \
6191                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
6192                             of the same owner as its parent issue; name a repository of {}, or \
6193                             name none",
6194                            what(incoming),
6195                            target.slug(),
6196                            target.owner,
6197                            parents.slug(),
6198                            parents.owner,
6199                            parents.owner
6200                        ),
6201                    });
6202                }
6203                Ok(target)
6204            }
6205            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6206        }
6207    }
6208
6209    /// The node id of the repository `incoming` is being created in, or the refusal naming
6210    /// the item and the repository the token cannot see.
6211    ///
6212    /// Resolved once per command per repository; see [`Self::repository_cache`].
6213    async fn repository_id(
6214        &self,
6215        repository: &RepositoryTarget,
6216        incoming: &Incoming<'_>,
6217    ) -> Result<String, SourceError> {
6218        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6219            return Ok(id);
6220        }
6221        let data = self
6222            .graphql(
6223                graphql::REPOSITORY,
6224                json!({"owner":repository.owner,"name":repository.name}),
6225            )
6226            .await?;
6227        let node = data
6228            .get("repository")
6229            .filter(|value| !value.is_null())
6230            .ok_or_else(|| SourceError::Refused {
6231                message: format!(
6232                    "GitHub repository {} was not found or is not visible to the token, so {} \
6233                     {:?} cannot be created in it",
6234                    repository.slug(),
6235                    incoming.written.kind().describes(),
6236                    incoming.title
6237                ),
6238            })?;
6239        let id = required_str(node, "id")?.to_owned();
6240        self.repository_cache()?
6241            .insert(repository.clone(), id.clone());
6242        Ok(id)
6243    }
6244
6245    fn repository_cache(
6246        &self,
6247    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6248        self.repository_cache
6249            .lock()
6250            .map_err(|_| SourceError::Unavailable {
6251                message: "this source's record of the destination repository was left \
6252                          inconsistent by an earlier failure; next: run the command again"
6253                    .into(),
6254            })
6255    }
6256
6257    /// Create or update one board item, whichever kind it is.
6258    async fn write_item(
6259        &self,
6260        incoming: &Incoming<'_>,
6261        target: Option<&NativeId>,
6262        depends_on: &[DependencyEdge],
6263    ) -> Result<NativeId, SourceError> {
6264        // Refused before anything is read or written: a task or a project titled the way
6265        // this board spells a document would land as an issue this same source reads back
6266        // as a document, so the field this destination cannot carry is named rather than
6267        // written and silently reclassified.
6268        if let Written::Work(kind, _) = incoming.written
6269            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6270        {
6271            return Err(SourceError::Refused {
6272                message: format!(
6273                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6274                     spells a document, so it would read back as one rather than as a {}; \
6275                     retitle it, or copy it as a document",
6276                    kind.marker(),
6277                    self.name,
6278                    kind.marker()
6279                ),
6280            });
6281        }
6282        // The destination is read by its own id, and whether this board holds it is decided
6283        // by that read — its own `projectItems` — rather than by whether a listing of the
6284        // board happens to include it yet. See the module documentation.
6285        let existing = match target {
6286            Some(target) => {
6287                Some(
6288                    self.bound_item(target)
6289                        .await?
6290                        .ok_or_else(|| SourceError::Refused {
6291                            message: format!("GitHub destination item {} was not found", target.0),
6292                        })?,
6293                )
6294            }
6295            None => None,
6296        };
6297        let existing = existing.as_ref();
6298        let board = self
6299            .fields_for(
6300                existing,
6301                incoming.written.status().is_some(),
6302                incoming
6303                    .priority
6304                    .is_some_and(|priority| priority != Priority::None),
6305            )
6306            .await?;
6307        let status_target = incoming
6308            .written
6309            .status()
6310            .map(|status| self.resolved_target(status.category))
6311            .transpose()?;
6312        let column = match (incoming.written.status(), status_target.as_ref()) {
6313            (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6314            _ => None,
6315        };
6316        // Resolved before anything is created, for the reason the column above is: a
6317        // priority this board has no option for is refused while nothing has been written.
6318        let priority_write = match incoming.priority {
6319            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6320            None => None,
6321        };
6322        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6323        if content_kind == ContentKind::DraftIssue {
6324            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6325                (status_target.as_ref(), incoming.written.status())
6326            {
6327                return Err(self.closes_a_draft(status.category));
6328            }
6329            if incoming.parent.is_some() {
6330                return Err(SourceError::Refused {
6331                    message: "GitHub draft items cannot be a project's sub-issue".into(),
6332                });
6333            }
6334        }
6335        match existing {
6336            Some(item) if content_kind == ContentKind::Issue => {
6337                if item.labels != incoming.labels {
6338                    return Err(SourceError::Refused {
6339                        message: "GitHub issue labels differ from the labels being written".into(),
6340                    });
6341                }
6342            }
6343            _ => {
6344                if !incoming.labels.is_empty() {
6345                    return Err(SourceError::Refused {
6346                        message: "GitHub items created by this destination carry no labels".into(),
6347                    });
6348                }
6349            }
6350        }
6351
6352        // An existing issue is never moved; a new one is created where the rule says. The
6353        // repository the issue really lives in is what the slot below is written against,
6354        // so a single entry that is where the issue is created travels as no key at all,
6355        // and the read side derives it back from the issue.
6356        let (own_repository, creation_target) = match existing {
6357            Some(item) => (item.own_repository.clone(), None),
6358            None => {
6359                let target = self.creation_target(incoming).await?;
6360                let origin = Repository::try_from(target.origin())
6361                    .map_err(|message| SourceError::Config { message })?;
6362                (Some(origin), Some(target))
6363            }
6364        };
6365        let (native, fallback) = self
6366            .partition_edges(incoming.written.kind(), content_kind, depends_on)
6367            .await?;
6368        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6369        let body = compose_body(incoming.content, &slot)?;
6370        // Read before anything is created, for the reason the field below is: a value
6371        // this destination cannot store has to refuse, and refusing after `createIssue`
6372        // would leave an issue behind that nothing asked for. The engine writes a
6373        // qualified id here; a caller handing this key anything else is told so rather
6374        // than having it silently stored as no origin at all.
6375        // 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.
6376        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6377            None => "",
6378            Some(Value::String(origin)) => origin.as_str(),
6379            Some(other) => {
6380                return Err(SourceError::Refused {
6381                    message: format!(
6382                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6383                         is {other}"
6384                    ),
6385                });
6386            }
6387        };
6388        // Resolved before anything is created: a board that cannot carry the copy origin
6389        // has to refuse the write, and refusing it after `createIssue` would leave an
6390        // issue behind that nothing asked for.
6391        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6392            Some(field) => {
6393                if required_str(field, "__typename")? != "ProjectV2Field" {
6394                    return Err(SourceError::Refused {
6395                        message: format!(
6396                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6397                        ),
6398                    });
6399                }
6400                Some(required_str(field, "id")?.to_owned())
6401            }
6402            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6403                return Err(SourceError::Refused {
6404                    message: format!(
6405                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6406                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6407                         the board"
6408                    ),
6409                });
6410            }
6411            None => None,
6412        };
6413
6414        let Landed {
6415            content_id,
6416            item_id,
6417            url,
6418            number,
6419        } = match existing {
6420            Some(item) => {
6421                self.update_existing(item, incoming, &body, status_target.as_ref())
6422                    .await?;
6423                Landed {
6424                    content_id: item.id.clone(),
6425                    item_id: item.item_id.clone(),
6426                    url: item.url.clone(),
6427                    number: item.number,
6428                }
6429            }
6430            None => {
6431                let target = creation_target
6432                    .as_ref()
6433                    .ok_or_else(|| SourceError::Malformed {
6434                        message: "a new item was decided without a repository to create it in"
6435                            .into(),
6436                    })?;
6437                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6438                    .await?
6439            }
6440        };
6441
6442        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6443        let column = column
6444            .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6445            .map(|(field, option, _)| (field, option));
6446        // Creating an item here is several calls — `createIssue`, `addProjectV2ItemById`,
6447        // then each board field, the parent and the dependencies — and GitHub can fail at
6448        // any of them. Everything this source can refuse *before* the first of those is
6449        // already checked above, so what is left is GitHub itself failing part way. When it
6450        // does over an item this call created, the issue is taken back: a write that
6451        // refused must not leave an item behind that nobody asked for, and one that does
6452        // makes the retry create a second.
6453        let landed = self
6454            .finish_write(
6455                board.id.as_str(),
6456                incoming,
6457                &content_id,
6458                &item_id,
6459                content_kind,
6460                existing,
6461                origin_field.as_deref(),
6462                origin,
6463                column,
6464                status_target.as_ref(),
6465                priority_write.as_ref(),
6466                &native,
6467            )
6468            .await;
6469        if let Err(error) = landed {
6470            if existing.is_none() {
6471                // Best effort, and the write's own failure is what the caller is told: a
6472                // refusal naming the tidy-up would hide why the write failed at all.
6473                let _ = self.delete_issue(&content_id).await;
6474            }
6475            return Err(error);
6476        }
6477
6478        let written_status = match (incoming.written.status(), status_target.as_ref()) {
6479            (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6480                self.statuses
6481                    .status(written_option.as_deref(), true, Some(reason.reason()))
6482            }
6483            (Some(_), Some(StatusTarget::Column(_))) => {
6484                self.statuses.status(written_option.as_deref(), false, None)
6485            }
6486            (Some(status), _) => status.clone(),
6487            (None, _) => Status {
6488                category: StatusCategory::Unknown,
6489                name: "Open".to_owned(),
6490            },
6491        };
6492
6493        // So the rest of this command reads what it just did rather than what the board
6494        // said before it. See `remember_written` for which half takes it.
6495        let remembered = Resolved {
6496            item_id,
6497            id: content_id.clone(),
6498            content_kind,
6499            kind: incoming.written.kind(),
6500            title: incoming.title.to_owned(),
6501            // The visible half of the body this write composed, split back off it the
6502            // way a read splits it — so what this record reports is what a read of the
6503            // same issue reports, rather than the person's text with the metadata slot
6504            // still on the end of it.
6505            body: metadata_body(body.clone())?.0,
6506            raw_body: body.clone(),
6507            // A document has no status of its own; what it reads back as is whatever
6508            // the issue's own state says, which is what a re-read reports.
6509            status: written_status,
6510            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6511            priority: match incoming.priority {
6512                Some(priority) => HeldPriority::Read(priority),
6513                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6514                    item.priority.clone()
6515                }),
6516            },
6517            // What `state_input` asked for: closed for a terminal target, open for any other
6518            // status, and the issue's own state left as it was by a document write.
6519            closed: content_kind == ContentKind::Issue
6520                && match status_target.as_ref() {
6521                    Some(StatusTarget::Terminal(_, _)) => true,
6522                    Some(_) => false,
6523                    None => existing.is_some_and(|item| item.closed),
6524                },
6525            delivers: incoming.delivers.to_vec(),
6526            delivered_by: incoming.delivered_by.to_vec(),
6527            labels: incoming.labels.to_vec(),
6528            parent: incoming.parent.cloned(),
6529            origin: (!origin.is_empty()).then(|| origin.to_owned()),
6530            number,
6531            // In the update path this is the item's own url, read off `existing` where the
6532            // record above was bound, so one expression serves both halves.
6533            url,
6534            created_at: existing.and_then(|item| item.created_at),
6535            updated_at: existing.and_then(|item| item.updated_at),
6536            own_repository,
6537            repositories: incoming.repositories.to_vec(),
6538            slot,
6539            board_id: Some(board.id.as_str().to_owned()),
6540            fields: board
6541                .fields
6542                .get("nodes")
6543                .and_then(Value::as_array)
6544                .cloned()
6545                .unwrap_or_default(),
6546        };
6547        self.remember_written(remembered, existing.is_none())?;
6548        Ok(content_id)
6549    }
6550
6551    /// Everything a write does after the item exists: its board fields, its parent, and
6552    /// its dependencies.
6553    ///
6554    /// Split out of `write_item` so there is one place a failure past the point of no
6555    /// return is caught, rather than a tidy-up repeated at each `?` above.
6556    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
6557    // so there is one place a failure past the point of no return is caught, and its
6558    // arguments are exactly the values that tail already had in scope. Bundling them into a
6559    // struct would describe no concept — it would be "the arguments of this function" — and
6560    // would put the whole of `write_item`'s locals behind one more indirection.
6561    #[allow(clippy::too_many_arguments)]
6562    async fn finish_write(
6563        &self,
6564        board_id: &str,
6565        incoming: &Incoming<'_>,
6566        content_id: &NativeId,
6567        item_id: &str,
6568        content_kind: ContentKind,
6569        existing: Option<&Resolved>,
6570        origin_field: Option<&str>,
6571        origin: &str,
6572        column: Option<(String, String)>,
6573        status_target: Option<&StatusTarget>,
6574        priority: Option<&PriorityWrite>,
6575        native: &[String],
6576    ) -> Result<(), SourceError> {
6577        let mut fields = Vec::new();
6578        if let Some(field_id) = origin_field
6579            && existing.map_or(!origin.is_empty(), |item| {
6580                item.origin.as_deref().unwrap_or("") != origin
6581            })
6582        {
6583            fields.push((field_id.to_owned(), json!({"text":origin})));
6584        }
6585        if let Some((field_id, option_id)) = column {
6586            fields.push((field_id, json!({"singleSelectOptionId":option_id})));
6587        }
6588        let clear = match priority {
6589            Some(PriorityWrite::Select { field, option }) => {
6590                fields.push((field.clone(), json!({"singleSelectOptionId":option})));
6591                None
6592            }
6593            Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
6594            None => None,
6595        };
6596        self.set_item_fields(board_id, item_id, &fields, clear)
6597            .await?;
6598
6599        if content_kind == ContentKind::Issue
6600            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
6601        {
6602            self.update_content(
6603                ContentKind::Issue,
6604                content_id,
6605                json!({"stateInput":state_input(status_target)}),
6606            )
6607            .await?;
6608        }
6609
6610        if content_kind == ContentKind::Issue {
6611            self.reparent(
6612                existing.and_then(|item| item.parent.clone()),
6613                content_id,
6614                incoming.parent,
6615            )
6616            .await?;
6617            // A document takes part in no dependency graph, so writing one neither reads
6618            // nor changes the issue's own `blockedBy` relationships. Reconciling them
6619            // against the empty list a document write carries would *delete* whatever
6620            // relationships a person had made on that issue, which is a write nobody
6621            // asked for.
6622            if incoming.written.kind() != BoardKind::Document {
6623                let issue = match existing {
6624                    Some(_) => Issue::Existing,
6625                    None => Issue::Created,
6626                };
6627                self.reconcile_blocked_by(content_id, native, issue).await?;
6628            }
6629        }
6630        Ok(())
6631    }
6632
6633    /// Delete one issue, which takes its board item with it.
6634    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
6635        let data = self
6636            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6637            .await?;
6638        data.pointer("/deleteIssue/repository")
6639            .filter(|value| !value.is_null())
6640            .ok_or_else(|| SourceError::Malformed {
6641                message: "GitHub issue deletion returned no repository".into(),
6642            })?;
6643        self.forget(id)?;
6644        Ok(())
6645    }
6646
6647    /// Remove one item this copy created, so a copy that could not finish leaves the board
6648    /// as it found it.
6649    ///
6650    /// Deleting the issue takes its board item with it, so there is no second mutation to
6651    /// keep in step. An id the board does not hold is not an error: the item is already
6652    /// gone, which is the state this asks for. Which that is, is decided by reading the item
6653    /// by its own id — a listing of the board can still be missing an item it holds, and
6654    /// reading that as *already gone* would leave behind the very item this was asked to
6655    /// take back.
6656    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
6657        let Some(item) = self.bound_item(id).await? else {
6658            return Ok(());
6659        };
6660        if item.content_kind == ContentKind::DraftIssue {
6661            return Err(SourceError::Refused {
6662                message: format!(
6663                    "GitHub item {} is a draft, and this source removes an item by deleting \
6664                     its issue; next: remove it from the board by hand",
6665                    id.0
6666                ),
6667            });
6668        }
6669        let data = self
6670            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6671            .await?;
6672        data.pointer("/deleteIssue/repository")
6673            .filter(|value| !value.is_null())
6674            .ok_or_else(|| SourceError::Malformed {
6675                message: "GitHub issue deletion returned no repository".into(),
6676            })?;
6677        self.forget(id)?;
6678        Ok(())
6679    }
6680
6681    /// The issue a comment call on `task` is about, or `None` when this board holds no such
6682    /// task.
6683    ///
6684    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
6685    /// read of the task cannot disagree about which ids name one: a project or a document of
6686    /// this board is not a task here either.
6687    ///
6688    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
6689    /// issues and a draft is not one. It is refused rather than answered with an empty page,
6690    /// which would read as a task nobody has commented on yet.
6691    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
6692        let cached = self.resolved_cache()?.get(task).cloned();
6693        let Some(item) = (match cached {
6694            Some(item) => Some(item),
6695            None => self.item_by_id(task).await?,
6696        })
6697        .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
6698            return Ok(None);
6699        };
6700        if item.content_kind == ContentKind::DraftIssue {
6701            return Err(SourceError::Refused {
6702                message: format!(
6703                    "task {} of source {} is a draft item on the board, and GitHub keeps \
6704                     comments on issues alone, so a draft has none to read or write; next: \
6705                     convert the draft to an issue on the board, then comment on the issue it \
6706                     becomes",
6707                    task.0, self.name
6708                ),
6709            });
6710        }
6711        Ok(Some(item.id))
6712    }
6713
6714    /// Whether the comment `comment` is one of `issue`'s own.
6715    ///
6716    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
6717    /// comment's id and nothing else: a comment id given against the wrong task would
6718    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
6719    /// names something that is not an issue comment, is a comment this task does not have —
6720    /// which is what GitHub refusing to resolve it means too.
6721    async fn comment_is_on(
6722        &self,
6723        issue: &NativeId,
6724        comment: &NativeId,
6725    ) -> Result<bool, SourceError> {
6726        let asked = self
6727            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
6728            .await;
6729        let data = match asked {
6730            Ok(data) => data,
6731            Err(error) if unresolvable_node(&error) => return Ok(false),
6732            Err(error) => return Err(error),
6733        };
6734        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6735            return Ok(false);
6736        };
6737        if optional_str(node, "__typename")? != Some("IssueComment") {
6738            return Ok(false);
6739        }
6740        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
6741            message: format!("GitHub issue comment {} names no issue", comment.0),
6742        })?;
6743        Ok(required_str(on, "id")? == issue.0)
6744    }
6745
6746    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
6747    async fn partition_edges(
6748        &self,
6749        near_kind: BoardKind,
6750        near_content: ContentKind,
6751        depends_on: &[DependencyEdge],
6752    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
6753        let mut native = Vec::new();
6754        let mut fallback = Vec::new();
6755        for edge in depends_on {
6756            let same_source = edge
6757                .to
6758                .source()
6759                .is_none_or(|source| source == self.name.as_str());
6760            // A qualified id's source segment runs to its *first* colon — `GlobalId` and
6761            // `DependencyEndpoint::source` both read it that way — and a native id may hold
6762            // colons of its own, so the far end is everything after that one separator.
6763            // Splitting at the last would truncate `work:urn:task:7` to `7`.
6764            let far_id = if edge.to.is_qualified() {
6765                edge.to
6766                    .id()
6767                    .split_once(':')
6768                    .map_or(edge.to.id(), |(_, native)| native)
6769            } else {
6770                edge.to.id()
6771            };
6772            // A same-source far end is read by its own id, exactly as the item it is a far end
6773            // of is: whether this board holds it is that read's answer, never a listing's.
6774            let far = if same_source {
6775                Some(
6776                    self.item_by_id(&NativeId(far_id.to_owned()))
6777                        .await?
6778                        .ok_or_else(|| SourceError::Refused {
6779                            message: format!("GitHub dependency item {far_id} was not found"),
6780                        })?,
6781                )
6782            } else {
6783                None
6784            };
6785            let far = far.as_ref();
6786            // The caller says which kind the far end is, and this board holds the far end
6787            // itself, so a disagreement is settled here rather than stored: recorded, the
6788            // wrong kind would read back as a cross-level edge that never existed; written
6789            // natively, it would name a relationship of a different level than the caller
6790            // asked for.
6791            //
6792            // A far end this board holds as a *document* fails the same comparison and is
6793            // refused by the same sentence: `ItemKind` has no document variant because
6794            // nothing may point at one, so no caller can name it correctly and the refusal
6795            // is the only honest answer.
6796            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
6797                return Err(SourceError::Refused {
6798                    message: format!(
6799                        "GitHub dependency item {far_id} is a {} of this board, and this item \
6800                         names it as a {}; record the kind it is",
6801                        disagreeing.kind.describes(),
6802                        edge.to.kind.marker()
6803                    ),
6804                });
6805            }
6806            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
6807            // however the far end is spelled — and one classified native here would be
6808            // written nowhere at all, because a draft's native reconciliation never runs.
6809            let native_here = near_content == ContentKind::Issue
6810                && far.is_some_and(|far| {
6811                    far.content_kind == ContentKind::Issue
6812                        && BoardKind::Work(edge.to.kind) == near_kind
6813                });
6814            if native_here {
6815                native.push(far_id.to_owned());
6816            } else {
6817                fallback.push(edge.clone());
6818            }
6819        }
6820        Ok((native, fallback))
6821    }
6822
6823    async fn update_existing(
6824        &self,
6825        item: &Resolved,
6826        incoming: &Incoming<'_>,
6827        body: &Option<String>,
6828        status_target: Option<&StatusTarget>,
6829    ) -> Result<(), SourceError> {
6830        let title = incoming.written_title();
6831        let mut fields = match item.content_kind {
6832            ContentKind::DraftIssue => json!({"title":title,"body":body}),
6833            ContentKind::Issue => json!({"title":title,"body":body,
6834                                         "stateInput":state_input(status_target)}),
6835        };
6836        if matches!(status_target, Some(StatusTarget::Terminal(_, _))) {
6837            fields
6838                .as_object_mut()
6839                .expect("update fields are an object")
6840                .remove("stateInput");
6841        }
6842        self.update_content(item.content_kind, &item.id, fields)
6843            .await
6844    }
6845
6846    /// Update one board item's content with exactly `fields` beside its id, through the
6847    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
6848    /// a draft.
6849    ///
6850    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
6851    /// is what lets a narrow write carry the one thing it changes and nothing else.
6852    async fn update_content(
6853        &self,
6854        kind: ContentKind,
6855        id: &NativeId,
6856        fields: Value,
6857    ) -> Result<(), SourceError> {
6858        let (operation, id_key, pointer) = match kind {
6859            ContentKind::DraftIssue => (
6860                graphql::UPDATE_DRAFT,
6861                "draftIssueId",
6862                "/updateProjectV2DraftIssue/draftIssue",
6863            ),
6864            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
6865        };
6866        let mut input = fields;
6867        input[id_key] = json!(id.0);
6868        let data = self.graphql(operation, json!({"input":input})).await?;
6869        let returned = data
6870            .pointer(pointer)
6871            .ok_or_else(|| SourceError::Malformed {
6872                message: "GitHub item update returned no item".into(),
6873            })?;
6874        if required_str(returned, "id")? != id.0 {
6875            return Err(SourceError::Malformed {
6876                message: "GitHub item update returned the wrong item".into(),
6877            });
6878        }
6879        Ok(())
6880    }
6881
6882    /// Creates one issue, files it on the board, and reports what a read of it would say:
6883    /// its content id, its board item id, and the web address GitHub gave it.
6884    ///
6885    /// Two calls rather than one: `createIssue` needs a repository and answers with an
6886    /// issue that is on no board, and `addProjectV2ItemById` is what puts it there. A
6887    /// terminal status is not written here: `finish_write` selects its option first and
6888    /// closes the issue after, so a close never lands on an item whose board cannot show it.
6889    ///
6890    /// The address and the number come back here because this is the only place either is
6891    /// known before GitHub's own board read catches up — an item this run created answers
6892    /// the reads that follow it out of the record below, and one remembered without them
6893    /// would report no location and no key for the rest of the run.
6894    async fn create_and_file_issue(
6895        &self,
6896        board_id: &str,
6897        repository: &RepositoryTarget,
6898        incoming: &Incoming<'_>,
6899        body: &Option<String>,
6900    ) -> Result<Landed, SourceError> {
6901        let repository_id = self.repository_id(repository, incoming).await?;
6902        let data = self
6903            .graphql(
6904                graphql::CREATE_ISSUE,
6905                json!({"input":{
6906                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
6907                }}),
6908            )
6909            .await?;
6910        let created = data
6911            .pointer("/createIssue/issue")
6912            .filter(|value| !value.is_null())
6913            .ok_or_else(|| SourceError::Malformed {
6914                message: "GitHub issue creation returned no issue".into(),
6915            })?;
6916        let content_id = NativeId(required_str(created, "id")?.to_owned());
6917        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
6918        // a response without it is not worth failing a landed write over — the item simply
6919        // reports no location until the board read catches up, which is what it did before.
6920        let url = optional_str(created, "url")?.map(str::to_owned);
6921        // The issue exists from here on, so an unreadable number and a refused board
6922        // filing below each try, best effort, to take it back: an issue in the repository
6923        // that is on no board is an item nobody asked for and nothing here would find again.
6924        //
6925        // Its number is optional on the same terms its address is — a landed write is not
6926        // worth failing over a member that came back missing, and such an item reports no
6927        // handle until a board read catches up. A number that is *present* and is not an
6928        // unsigned integer is still a response this source cannot read.
6929        let number = match created_issue_number(created) {
6930            Ok(number) => number,
6931            Err(error) => {
6932                let _ = self.delete_issue(&content_id).await;
6933                return Err(error);
6934            }
6935        };
6936        let added = match self
6937            .graphql(
6938                graphql::ADD_TO_BOARD,
6939                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
6940            )
6941            .await
6942        {
6943            Ok(added) => added,
6944            Err(error) => {
6945                let _ = self.delete_issue(&content_id).await;
6946                return Err(error);
6947            }
6948        };
6949        let item = added
6950            .pointer("/addProjectV2ItemById/item")
6951            .filter(|value| !value.is_null())
6952            .ok_or_else(|| SourceError::Malformed {
6953                message: "GitHub board addition returned no project item".into(),
6954            })?;
6955        Ok(Landed {
6956            content_id,
6957            item_id: required_str(item, "id")?.to_owned(),
6958            url,
6959            number,
6960        })
6961    }
6962
6963    /// Move one issue under the project it now belongs to, or out of the one it left.
6964    async fn reparent(
6965        &self,
6966        held: Option<NativeId>,
6967        child: &NativeId,
6968        wanted: Option<&NativeId>,
6969    ) -> Result<(), SourceError> {
6970        if held.as_ref() == wanted {
6971            return Ok(());
6972        }
6973        if let Some(held) = &held {
6974            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
6975                .await?;
6976        }
6977        if let Some(wanted) = wanted {
6978            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
6979                .await?;
6980        }
6981        Ok(())
6982    }
6983
6984    async fn sub_issue(
6985        &self,
6986        operation: &str,
6987        parent: &NativeId,
6988        child: &NativeId,
6989        root: &str,
6990    ) -> Result<(), SourceError> {
6991        let data = self
6992            .graphql(
6993                operation,
6994                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
6995            )
6996            .await?;
6997        let issue =
6998            data.pointer(&format!("/{root}/issue"))
6999                .ok_or_else(|| SourceError::Malformed {
7000                    message: "GitHub sub-issue update returned no issue".into(),
7001                })?;
7002        let sub =
7003            data.pointer(&format!("/{root}/subIssue"))
7004                .ok_or_else(|| SourceError::Malformed {
7005                    message: "GitHub sub-issue update returned no sub-issue".into(),
7006                })?;
7007        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7008            return Err(SourceError::Malformed {
7009                message: "GitHub sub-issue update returned the wrong issues".into(),
7010            });
7011        }
7012        Ok(())
7013    }
7014
7015    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7016    /// whether there was one.
7017    ///
7018    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7019    /// relationships are not read: there is nothing a read of them could find.
7020    async fn reconcile_blocked_by(
7021        &self,
7022        content_id: &NativeId,
7023        native: &[String],
7024        issue: Issue,
7025    ) -> Result<bool, SourceError> {
7026        let current = match issue {
7027            Issue::Created => Vec::new(),
7028            Issue::Existing => self.native_dependency_ids(content_id).await?,
7029        };
7030        let mut changed = false;
7031        for (operation, far_id) in current
7032            .iter()
7033            .filter(|id| !native.contains(id))
7034            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7035            .chain(
7036                native
7037                    .iter()
7038                    .filter(|id| !current.contains(id))
7039                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7040            )
7041        {
7042            let data = self
7043                .graphql(
7044                    operation,
7045                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7046                )
7047                .await?;
7048            let root = if operation == graphql::ADD_BLOCKED_BY {
7049                "addBlockedBy"
7050            } else {
7051                "removeBlockedBy"
7052            };
7053            let issue =
7054                data.pointer(&format!("/{root}/issue"))
7055                    .ok_or_else(|| SourceError::Malformed {
7056                        message: "GitHub dependency update returned no issue".into(),
7057                    })?;
7058            let blocker = data
7059                .pointer(&format!("/{root}/blockingIssue"))
7060                .ok_or_else(|| SourceError::Malformed {
7061                    message: "GitHub dependency update returned no blocking issue".into(),
7062                })?;
7063            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7064            {
7065                return Err(SourceError::Malformed {
7066                    message: "GitHub dependency update returned the wrong issues".into(),
7067                });
7068            }
7069            changed = true;
7070        }
7071        Ok(changed)
7072    }
7073}
7074
7075/// Whether the issue one write reconciles was created by that write or was already there.
7076#[derive(Clone, Copy, PartialEq, Eq)]
7077enum Issue {
7078    /// Created by this write, so it holds no relationships yet.
7079    Created,
7080    /// On the board before this write, holding whatever relationships it holds.
7081    Existing,
7082}
7083
7084/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7085enum Reached {
7086    /// An issue this board holds, resolved into everything this source reports about it.
7087    Held(Box<Resolved>),
7088    /// Nothing this board holds: no such node, or a node on some other board.
7089    Nothing,
7090    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7091    /// again by [`GitHubProjectsSource::draft_by_id`].
7092    Draft,
7093}
7094
7095/// What GitHub says when a string is not a node id it can resolve.
7096///
7097/// Matched because it is the ordinary answer to a project selector naming a project by its
7098/// *name*, and reporting that as a failure would make naming one impossible. It is read
7099/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7100/// does not define the syntax of a GitHub node id and would be wrong about it.
7101const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7102
7103/// Whether this refusal is GitHub saying the id names no node at all.
7104fn unresolvable_node(error: &SourceError) -> bool {
7105    matches!(error, SourceError::Refused { message }
7106        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7107}
7108
7109/// One project name, as a search qualifier which filters on it at the server.
7110///
7111/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7112/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7113/// the way it documents. A title matched here is still compared for equality afterwards:
7114/// the qualifier narrows what the server sends, and this source decides what it names.
7115fn title_qualifier(name: &str) -> String {
7116    format!("in:title {}", quoted(name))
7117}
7118
7119/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7120/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7121/// it documents — so a value holding a qualifier's spelling is searched for rather than
7122/// obeyed.
7123fn quoted(value: &str) -> String {
7124    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7125    format!("\"{escaped}\"")
7126}
7127
7128/// The search qualifier for the issues updated at or after `since`.
7129///
7130/// Written to the second, rounded down, which can only widen what the search returns.
7131fn updated_qualifier(since: DateTime<Utc>) -> String {
7132    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7133}
7134
7135/// The search terms that narrow a board-scoped issue search to a task query's text and
7136/// metadata predicates, or `None` when it carries neither.
7137///
7138/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7139/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7140/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7141/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7142/// a metadata value searches both fields for both — wider than asked, never narrower, and
7143/// every candidate is confirmed in process afterwards.
7144///
7145/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7146/// matches whole tokens where a substring rule would match inside a word, so an item holding
7147/// the text only inside a longer word is not returned. A text of nothing but whitespace
7148/// matches every item, so it narrows nothing and is not sent.
7149fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7150    let text = query
7151        .text
7152        .as_ref()
7153        .filter(|text| !text.terms.trim().is_empty());
7154    if text.is_none() && query.metadata.is_empty() {
7155        return None;
7156    }
7157    let (title, body) = match text.map(|text| text.fields) {
7158        None => (false, true),
7159        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7160        Some(TextFields::Content) => (false, true),
7161        Some(TextFields::TitleOrContent) => (true, true),
7162    };
7163    let fields = match (title, body) {
7164        (true, true) => "in:title,body",
7165        (true, false) => "in:title",
7166        _ => "in:body",
7167    };
7168    let phrases = text
7169        .map(|text| text.terms.clone())
7170        .into_iter()
7171        .chain(
7172            query
7173                .metadata
7174                .iter()
7175                .map(|wanted| as_stored(wanted.value())),
7176        )
7177        .map(|phrase| quoted(&phrase))
7178        .collect::<Vec<_>>();
7179    Some(format!("{fields} {}", phrases.join(" ")))
7180}
7181
7182/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7183/// before anything is asked of GitHub.
7184///
7185/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7186/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7187/// left out, the search is every issue of the board. So this source says it cannot answer
7188/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7189/// nothing GitHub could search for, and keeps the board read it always had.
7190fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7191    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7192                       letter or digit with a bounded query";
7193    if let Some(text) = &query.text
7194        && !text.terms.trim().is_empty()
7195        && !has_words(&text.terms)
7196    {
7197        return Err(SourceError::Refused {
7198            message: format!(
7199                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7200                text.terms
7201            ),
7202        });
7203    }
7204    if let Some(wanted) = query
7205        .metadata
7206        .iter()
7207        .find(|wanted| !has_words(wanted.value()))
7208    {
7209        return Err(SourceError::Refused {
7210            message: format!(
7211                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
7212                wanted.value(),
7213                std::iter::once(wanted.key())
7214                    .chain(wanted.path().iter().map(String::as_str))
7215                    .collect::<Vec<_>>()
7216                    .join("/"),
7217            ),
7218        });
7219    }
7220    Ok(())
7221}
7222
7223/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
7224fn has_words(phrase: &str) -> bool {
7225    phrase.chars().any(char::is_alphanumeric)
7226}
7227
7228/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
7229///
7230/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
7231/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
7232/// which GitHub's word match would read as different words.
7233fn as_stored(value: &str) -> String {
7234    let encoded = Value::String(value.to_owned()).to_string();
7235    encoded[1..encoded.len() - 1].to_owned()
7236}
7237
7238/// The one narrower question a task query carrying a text, metadata or origin predicate is
7239/// sent as.
7240enum Narrowing {
7241    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
7242    Origin(String),
7243    /// The board-scoped issue search narrowed by these qualifiers.
7244    Search(String),
7245}
7246
7247impl Narrowing {
7248    /// What this question is remembered under for the length of one command.
7249    fn key(&self) -> String {
7250        match self {
7251            Self::Origin(origin) => format!("origin {origin}"),
7252            Self::Search(also) => format!("search {also}"),
7253        }
7254    }
7255}
7256
7257/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
7258enum Resumed {
7259    /// It reported another page, which starts after this cursor.
7260    More(String),
7261    /// It has ended. Sending this cursor again — the page's own end when it had one, and
7262    /// otherwise the cursor it was reached from — answers an empty page, so the one document
7263    /// can go on walking the other connection.
7264    Ended(Option<String>),
7265}
7266
7267impl Resumed {
7268    /// Whether the connection has another page.
7269    const fn has_more(&self) -> bool {
7270        matches!(self, Self::More(_))
7271    }
7272
7273    /// The cursor to send this connection next.
7274    fn cursor(self) -> Option<String> {
7275        match self {
7276            Self::More(next) => Some(next),
7277            Self::Ended(last) => last,
7278        }
7279    }
7280}
7281
7282/// Where `connection`, reached from `after`, resumes — refused when it reports another page
7283/// with no cursor to it, or from a cursor that does not advance.
7284fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
7285    let info = connection
7286        .get("pageInfo")
7287        .ok_or_else(|| SourceError::Malformed {
7288            message: "GitHub connection has no pageInfo".into(),
7289        })?;
7290    let end = optional_str(info, "endCursor")?;
7291    if required_bool(info, "hasNextPage")? {
7292        let next = end.ok_or_else(|| SourceError::Malformed {
7293            message: "GitHub connection reports another page and no endCursor".into(),
7294        })?;
7295        validate_cursor_progress(after, next)?;
7296        return Ok(Resumed::More(next.to_owned()));
7297    }
7298    Ok(Resumed::Ended(
7299        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
7300    ))
7301}
7302
7303/// The board, and every item on it this source reports.
7304#[derive(Clone)]
7305struct Board {
7306    id: String,
7307    fields: Value,
7308    items: Vec<Resolved>,
7309}
7310
7311/// What a write needs of the board and nothing more: its node id and its field
7312/// definitions, in the shape a read of the board's own `fields` gives them.
7313///
7314/// Deliberately no items. A write decides which item it writes, which parent it files
7315/// under and which far ends it names by reading each of them by its own id; this is the
7316/// half of the board those reads cannot carry, and holding no item is what keeps it from
7317/// ever being asked whether an item is there.
7318#[derive(Clone)]
7319struct BoardFields {
7320    id: BoardId,
7321    fields: Value,
7322}
7323
7324/// A board's node id: what a field write and `addProjectV2ItemById` address.
7325///
7326/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
7327/// refused where it is read, and one an item names blank is read as not named at all.
7328#[derive(Clone)]
7329struct BoardId(String);
7330
7331/// Where one write left its item, for the record the rest of the command reads it out of.
7332///
7333/// A named record rather than a tuple because the update arm and the create arm each fill
7334/// all four, and two `Option`s of different meaning side by side in a tuple are two
7335/// positions a reader has to count.
7336struct Landed {
7337    /// The issue's own node id, which is the [`NativeId`] this source reports.
7338    content_id: NativeId,
7339    /// The board item's id, which is what a field write addresses.
7340    // 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.
7341    item_id: String,
7342    /// The web address GitHub gave the issue, when it gave one.
7343    // 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.
7344    url: Option<String>,
7345    /// The issue's number on its repository, when GitHub reported one.
7346    number: Option<u64>,
7347}
7348
7349impl BoardId {
7350    fn parse(id: &str) -> Result<Self, SourceError> {
7351        if id.trim().is_empty() {
7352            return Err(SourceError::Malformed {
7353                message: "GitHub named a board with a blank node id".into(),
7354            });
7355        }
7356        Ok(Self(id.to_owned()))
7357    }
7358
7359    fn as_str(&self) -> &str {
7360        &self.0
7361    }
7362}
7363
7364impl Board {
7365    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
7366        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
7367        let nodes = fields
7368            .get("nodes")
7369            .and_then(Value::as_array)
7370            .ok_or_else(|| SourceError::Malformed {
7371                message: "GitHub project fields.nodes is not an array".into(),
7372            })?;
7373        Ok(nodes
7374            .iter()
7375            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
7376    }
7377}
7378
7379/// One board item, resolved into everything this source reports about it.
7380#[derive(Clone)]
7381struct Resolved {
7382    item_id: String,
7383    id: NativeId,
7384    content_kind: ContentKind,
7385    kind: BoardKind,
7386    title: String,
7387    body: Option<String>,
7388    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
7389    /// that changes the slot alone has to keep byte for byte outside it.
7390    raw_body: Option<String>,
7391    status: Status,
7392    /// The name of the board `Status` option this item sits in, as the board spells it.
7393    option: Option<String>,
7394    /// What its `Priority` field says, read through this instance's mapping.
7395    priority: HeldPriority,
7396    /// Whether this item's issue is closed. A draft has no such state and is never closed.
7397    closed: bool,
7398    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
7399    delivers: Vec<TaskRef>,
7400    /// Every task that delivers this one, read out of its slot. Empty for anything not a
7401    /// task.
7402    delivered_by: Vec<TaskRef>,
7403    labels: Vec<Label>,
7404    parent: Option<NativeId>,
7405    // 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.
7406    origin: Option<String>,
7407    /// The issue's own number on its repository, as GitHub reports it.
7408    ///
7409    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
7410    /// declares none, and a draft is not filed in a repository to be numbered by one — and
7411    /// an issue this run created whose creating mutation answered without one, which is a
7412    /// response GitHub's own schema says cannot happen and which a landed write is not
7413    /// worth failing over. An `Issue` read off the board always has one.
7414    number: Option<u64>,
7415    url: Option<String>,
7416    created_at: Option<DateTime<Utc>>,
7417    updated_at: Option<DateTime<Utc>>,
7418    own_repository: Option<Repository>,
7419    repositories: Vec<Repository>,
7420    slot: BTreeMap<String, Value>,
7421    /// The node id of the board this item sits on, when the read that reached it said.
7422    board_id: Option<String>,
7423    /// The definition of every board field this item holds a value of, in the shape a read
7424    /// of the board's own `fields` gives one.
7425    ///
7426    /// Only the fields this item has a value in: a field it holds nothing of is not here,
7427    /// which says nothing about whether the board has it.
7428    fields: Vec<Value>,
7429}
7430
7431impl Resolved {
7432    /// The board this item's own read names it on, when that read named one this source can
7433    /// address.
7434    fn named_board(&self) -> Option<BoardId> {
7435        self.board_id
7436            .as_deref()
7437            .and_then(|id| BoardId::parse(id).ok())
7438    }
7439
7440    /// Whether this item holds a value of the board field called `name`, and so carries
7441    /// that field's definition. `false` says nothing about whether the board has the field.
7442    fn defines(&self, name: &str) -> bool {
7443        self.fields
7444            .iter()
7445            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
7446    }
7447
7448    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
7449    /// in a field of its own, and none of the five keys that are only an encoding.
7450    ///
7451    /// The two delivery keys are left out for every kind, not only for a task: they are
7452    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
7453    /// document carrying one holds nothing a caller's own metadata could mean by it.
7454    fn metadata(&self) -> BTreeMap<String, Value> {
7455        let mut metadata = self.slot.clone();
7456        metadata.remove(Repository::METADATA_KEY);
7457        metadata.remove(DependencyEdge::RECORDED_KEY);
7458        metadata.remove(ItemKind::METADATA_KEY);
7459        metadata.remove(TaskRef::DELIVERS_KEY);
7460        metadata.remove(TaskRef::DELIVERED_BY_KEY);
7461        // The board field is the origin, and the body's copy of it is only a mirror for the
7462        // issue search to find: an item whose field holds none has none, whatever its body
7463        // says, so no reader ever sees two answers.
7464        metadata.remove(ORIGIN_KEY);
7465        if let Some(origin) = &self.origin {
7466            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
7467        }
7468        metadata
7469    }
7470
7471    /// Where this item is, as a link a reader can open.
7472    ///
7473    /// A board is a hosted place and every issue on it has a web address, so that address
7474    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
7475    /// of place it is, so a reader knows to open it rather than to read a file out. It
7476    /// does not replace or derive from `url`: the field goes on reporting exactly what it
7477    /// reported before, and this says what that address *is*.
7478    ///
7479    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
7480    /// rather than a third variant, which is the contract's "the source did not say". An
7481    /// issue this run created is not one of those: its address comes back from the
7482    /// creating mutation, so it is somewhere a reader can open from the moment it exists
7483    /// rather than from whenever the board read catches up.
7484    fn location(&self) -> Option<Location> {
7485        self.url.clone().map(Location::Url)
7486    }
7487
7488    /// The short handle this board's backend shows people for a task: the issue's number
7489    /// alone, as a decimal string.
7490    ///
7491    /// The number alone rather than `owner/repo#1043`, because that is the contract's
7492    /// value for this backend. A draft has no number and so no handle, which is the
7493    /// contract's *absent* rather than a handle of some other shape — and the native
7494    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
7495    /// derives from.
7496    fn key(&self) -> Option<String> {
7497        self.number.map(|number| number.to_string())
7498    }
7499
7500    /// Whether its `Priority` field holds a value at all, mapped or not.
7501    fn holds_priority(&self) -> bool {
7502        self.priority != HeldPriority::Read(Priority::None)
7503    }
7504
7505    /// The task this item is.
7506    ///
7507    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
7508    /// reading that as a level would be a guess, and reading it as `none` would let the next
7509    /// copy clear a priority a person set.
7510    fn task(&self) -> Result<Task, SourceError> {
7511        let priority = match &self.priority {
7512            HeldPriority::Read(priority) => *priority,
7513            HeldPriority::Unmapped(option) => {
7514                return Err(SourceError::Malformed {
7515                    message: format!(
7516                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
7517                         this source's priority_mapping does not name, so its priority cannot be \
7518                         read; next: name {option:?} under priority_mapping, or move the item to \
7519                         a mapped option",
7520                        self.id,
7521                        self.number
7522                            .map(|number| format!(" (#{number})"))
7523                            .unwrap_or_default()
7524                    ),
7525                });
7526            }
7527        };
7528        Ok(Task {
7529            id: self.id.clone(),
7530            key: self.key(),
7531            title: self.title.clone(),
7532            content: self.body.clone(),
7533            status: self.status.clone(),
7534            priority,
7535            labels: self.labels.clone(),
7536            project: self.parent.clone(),
7537            url: self.url.clone(),
7538            location: self.location(),
7539            created_at: self.created_at,
7540            updated_at: self.updated_at,
7541            metadata: self.metadata(),
7542            repositories: self.repositories.clone(),
7543            delivers: self.delivers.clone(),
7544            delivered_by: self.delivered_by.clone(),
7545        })
7546    }
7547
7548    fn project(&self) -> Project {
7549        Project {
7550            id: self.id.clone(),
7551            title: self.title.clone(),
7552            content: self.body.clone(),
7553            status: self.status.clone(),
7554            labels: self.labels.clone(),
7555            url: self.url.clone(),
7556            location: self.location(),
7557            created_at: self.created_at,
7558            updated_at: self.updated_at,
7559            metadata: self.metadata(),
7560            repositories: self.repositories.clone(),
7561        }
7562    }
7563
7564    /// The same issue as a document: the project it is filed under, and no status and no
7565    /// dependencies, because a document is not work.
7566    fn document(&self) -> Document {
7567        Document {
7568            id: self.id.clone(),
7569            title: self.title.clone(),
7570            content: self.body.clone(),
7571            project: self.parent.clone(),
7572            labels: self.labels.clone(),
7573            url: self.url.clone(),
7574            location: self.location(),
7575            created_at: self.created_at,
7576            updated_at: self.updated_at,
7577            metadata: self.metadata(),
7578            repositories: self.repositories.clone(),
7579        }
7580    }
7581}
7582
7583/// Where one targeted update moves an item's status, and which of its two halves move.
7584struct StatusMove {
7585    /// The board the item's `Status` field is on.
7586    board: BoardId,
7587    /// The `Status` field's id.
7588    field: String,
7589    /// The option's id.
7590    option: String,
7591    /// The option's name, as the board spells it.
7592    name: String,
7593    /// What the status asks of the issue's state.
7594    target: StatusTarget,
7595    /// The status the item reads as once it is there.
7596    landed: Status,
7597    /// Which of the status's two halves differ from what the item holds.
7598    moves: Moves,
7599}
7600
7601/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
7602/// closed state of its issue, or both. A status neither half of which differs is no move at all,
7603/// and is not a value of this type.
7604#[derive(Clone, Copy, PartialEq, Eq)]
7605enum Moves {
7606    /// The option alone.
7607    Option,
7608    /// The issue's state alone: open, closed, or closed with another reason.
7609    State,
7610    /// Both.
7611    Both,
7612}
7613
7614impl Moves {
7615    /// What differs, or `None` when nothing does.
7616    const fn of(option: bool, state: bool) -> Option<Self> {
7617        match (option, state) {
7618            (true, true) => Some(Self::Both),
7619            (true, false) => Some(Self::Option),
7620            (false, true) => Some(Self::State),
7621            (false, false) => None,
7622        }
7623    }
7624
7625    /// Whether the option moves.
7626    const fn option(self) -> bool {
7627        matches!(self, Self::Option | Self::Both)
7628    }
7629
7630    /// Whether the issue's state moves.
7631    const fn state(self) -> bool {
7632        matches!(self, Self::State | Self::Both)
7633    }
7634}
7635
7636/// What one write is, and the status that comes with being it.
7637///
7638/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
7639/// status and a task or a project always has one, so "a document carrying a status" and
7640/// "a task carrying none" are states a write cannot be in rather than states every use
7641/// site below has to defend against.
7642enum Written<'a> {
7643    /// A document, which is not work and so has no status at all.
7644    Document,
7645    /// A task or a project, and the status it is being written with.
7646    Work(ItemKind, &'a Status),
7647}
7648
7649impl Written<'_> {
7650    /// Which of the board's three kinds this write is.
7651    const fn kind(&self) -> BoardKind {
7652        match self {
7653            Self::Document => BoardKind::Document,
7654            Self::Work(kind, _) => BoardKind::Work(*kind),
7655        }
7656    }
7657
7658    /// The status this write carries. A document carries none, so a write of one says
7659    /// nothing about the issue's open or closed state and selects no board `Status`
7660    /// option.
7661    const fn status(&self) -> Option<&Status> {
7662        match self {
7663            Self::Document => None,
7664            Self::Work(_, status) => Some(status),
7665        }
7666    }
7667}
7668
7669/// The item being written, in the one shape all three write methods reach.
7670struct Incoming<'a> {
7671    written: Written<'a>,
7672    /// The title a person wrote. A document's goes onto the issue with
7673    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
7674    title: &'a str,
7675    content: Option<&'a str>,
7676    labels: &'a [Label],
7677    metadata: &'a BTreeMap<String, Value>,
7678    repositories: &'a [Repository],
7679    parent: Option<&'a NativeId>,
7680    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
7681    /// what keeps either key out of their slot.
7682    delivers: &'a [TaskRef],
7683    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
7684    delivered_by: &'a [TaskRef],
7685    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
7686    /// project, a document, and every write to an instance with no `priority_mapping` —
7687    /// which is what keeps such a write's requests exactly what they were before.
7688    priority: Option<Priority>,
7689}
7690
7691/// What one write does to an item's `Priority` field.
7692enum PriorityWrite {
7693    /// Select this option of this field.
7694    Select {
7695        /// The `Priority` field's id.
7696        field: String,
7697        /// The mapped option's id.
7698        option: String,
7699    },
7700    /// Clear the field's value, which is what `none` is.
7701    Clear {
7702        /// The `Priority` field's id.
7703        field: String,
7704    },
7705}
7706
7707impl Incoming<'_> {
7708    /// The title this write puts on the issue.
7709    fn written_title(&self) -> String {
7710        match self.written {
7711            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
7712            Written::Work(..) => self.title.to_owned(),
7713        }
7714    }
7715}
7716
7717#[derive(Clone, Copy, PartialEq, Eq)]
7718enum ContentKind {
7719    DraftIssue,
7720    Issue,
7721}
7722
7723/// What one board issue is: a document, or the work an [`ItemKind`] names.
7724///
7725/// A type of this source's own rather than an `ItemKind` with a third variant, because
7726/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
7727/// document — the contract keeps a document out of that enum deliberately. Holding the
7728/// board's three answers in one value is what makes every place that asks "which is this?"
7729/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
7730/// two thirds of the board.
7731#[derive(Clone, Copy, PartialEq, Eq)]
7732enum BoardKind {
7733    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
7734    Document,
7735    /// Every other issue, and every draft.
7736    Work(ItemKind),
7737}
7738
7739impl BoardKind {
7740    /// How a refusal names this kind to the person reading it.
7741    const fn describes(self) -> &'static str {
7742        match self {
7743            Self::Document => "document",
7744            Self::Work(kind) => kind.marker(),
7745        }
7746    }
7747}
7748
7749/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
7750///
7751/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
7752/// the shared cross-source journeys assert one answer to one question, so two sources
7753/// that disagree about what "carries the label bug" means fail them.
7754fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
7755    let holds = |name: &String| {
7756        labels
7757            .iter()
7758            .any(|label| label.name.eq_ignore_ascii_case(name))
7759    };
7760    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
7761        && filter.all_of.iter().all(holds)
7762        && !filter.none_of.iter().any(holds)
7763}
7764
7765/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
7766/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
7767fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
7768    statuses.is_empty() || statuses.contains(&category)
7769}
7770
7771/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
7772///
7773/// `content` is the item's own prose — the body with this source's trailing metadata
7774/// comment already taken off — so a search never matches an encoding the author of the
7775/// issue never wrote.
7776fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
7777    let terms = query.terms.to_lowercase();
7778    let in_title = title.to_lowercase().contains(&terms);
7779    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
7780    match query.fields {
7781        TextFields::Title => in_title,
7782        TextFields::Content => in_content,
7783        TextFields::TitleOrContent => in_title || in_content,
7784    }
7785}
7786
7787/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
7788///
7789/// The project predicate is passed separately because a read narrowed to one project has
7790/// already answered it by asking *that project* for its own items — and re-applying it
7791/// there would compare the caller's selector, which may be a project's **name**, against
7792/// the id of the project that name resolved to, and keep nothing. Every other read passes
7793/// `query.project` and applies it here, which is what keeps `projects` a predicate this
7794/// source really does apply.
7795fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
7796    labels_match(&task.labels, &query.labels)
7797        && status_matches(task.status.category, &query.statuses)
7798        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
7799        && match project {
7800            ProjectFilter::Any => true,
7801            ProjectFilter::Orphans => task.project.is_none(),
7802            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
7803        }
7804        && query
7805            .text
7806            .as_ref()
7807            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
7808        // Against the parsed metadata slot, and against the origin field, which is where
7809        // `Resolved::metadata` reads each of them from.
7810        && query.metadata_matches(&task.metadata)
7811        && query.origin_matches(&task.metadata)
7812}
7813
7814fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
7815    labels_match(&project.labels, &query.labels)
7816        && status_matches(project.status.category, &query.statuses)
7817        && query
7818            .text
7819            .as_ref()
7820            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
7821}
7822
7823/// The same three predicates a task query carries, minus the status filter.
7824///
7825/// A document is not work, so it has no status for one to compare against and the query
7826/// type carries none. The project predicate is the same one — a design issue filed under a
7827/// project issue is in that project, and one filed under nothing is in none — so it is
7828/// spelled the same way here rather than answered differently.
7829fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
7830    labels_match(&document.labels, &query.labels)
7831        && match project {
7832            ProjectFilter::Any => true,
7833            ProjectFilter::Orphans => document.project.is_none(),
7834            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
7835        }
7836        && query
7837            .text
7838            .as_ref()
7839            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
7840}
7841
7842#[async_trait::async_trait]
7843impl TaskSource for GitHubProjectsSource {
7844    fn kind(&self) -> &'static str {
7845        KIND
7846    }
7847    fn capabilities(&self) -> Capabilities {
7848        Capabilities {
7849            projects: Support::Native,
7850            documents: Support::Native,
7851            comments: Support::Native,
7852            priority: if self.priorities.is_some() {
7853                Support::Native
7854            } else {
7855                Support::Unsupported
7856            },
7857            filter_by_priority: Support::Native,
7858            filter_by_comment_activity: Support::Native,
7859            filter_by_metadata: Support::Native,
7860            filter_by_origin: Support::Native,
7861            orphan_tasks: Support::Native,
7862            filter_by_label: Support::Native,
7863            filter_by_status: Support::Native,
7864            search_title: Support::Native,
7865            search_content: Support::Native,
7866            task_dependencies: DependencySupport::BothDirections,
7867            project_dependencies: DependencySupport::BothDirections,
7868            max_page_size: MAX_PAGE_SIZE,
7869        }
7870    }
7871    async fn health(&self) -> Result<Health, SourceError> {
7872        let board = self.board_page(None, 1).await?;
7873        Ok(Health {
7874            reachable: true,
7875            detail: Some(format!(
7876                "reading GitHub project {}/{} ({})",
7877                self.owner,
7878                self.project_number,
7879                required_str(&board, "title")?
7880            )),
7881        })
7882    }
7883    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
7884        self.item_by_id(id)
7885            .await?
7886            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7887            .map(|item| item.task())
7888            .transpose()
7889    }
7890    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
7891        Ok(self
7892            .item_by_id(id)
7893            .await?
7894            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7895            .map(|item| item.project()))
7896    }
7897    async fn query_tasks(
7898        &self,
7899        query: &TaskQuery,
7900        page: &PageRequest,
7901    ) -> Result<Page<Task>, SourceError> {
7902        validate_page(page)?;
7903        refuse_unsearchable(query)?;
7904        if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
7905            let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
7906                (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
7907                (Some(also), None) => Some(also),
7908                (None, Some(since)) => Some(updated_qualifier(since)),
7909                (None, None) => None,
7910            };
7911            if let Some(also) = qualifiers {
7912                return self.search_tasks(query, page, &also).await;
7913            }
7914        }
7915
7916        // A read narrowed to one project asks that project for its own tasks, so nothing
7917        // about it costs what the rest of the board holds. A read carrying a text, metadata
7918        // or origin predicate asks GitHub the narrower question those predicates are, and a
7919        // read narrowed to comment activity alone asks the board's own issue search for the
7920        // issues updated since, which is every issue a comment could have been written or
7921        // edited on since. Every other task read is a question about the whole board and is
7922        // answered by reading it.
7923        let (held, membership) = match (&query.project, query.commented_since) {
7924            (ProjectFilter::Is(project), _) => (
7925                self.project_children(project).await?,
7926                // Answered by where these items came from; see `task_matches`.
7927                &ProjectFilter::Any,
7928            ),
7929            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
7930                match (self.narrowed(query).await?, since) {
7931                    (Some(narrowed), _) => (narrowed, &query.project),
7932                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
7933                    (None, None) => (self.board().await?.items, &query.project),
7934                }
7935            }
7936        };
7937        // Filtered before paged: a page of a filtered result is a page of the survivors,
7938        // never the survivors of a page.
7939        let mut tasks = Vec::new();
7940        for item in held
7941            .iter()
7942            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7943        {
7944            let task = item.task()?;
7945            if task_matches(&task, query, membership)
7946                && self.commented_since(item, query.commented_since).await?
7947            {
7948                tasks.push(task);
7949            }
7950        }
7951        Ok(offset_page(
7952            tasks,
7953            numeric_cursor(page.cursor.as_ref())?,
7954            page.limit.min(MAX_PAGE_SIZE) as usize,
7955        ))
7956    }
7957    async fn query_projects(
7958        &self,
7959        query: &ProjectQuery,
7960        page: &PageRequest,
7961    ) -> Result<Page<Project>, SourceError> {
7962        validate_page(page)?;
7963        // The projects a board holds are found by an issue search scoped to that board,
7964        // never by walking the board's own item connection: what tells a project from a
7965        // task is the `parent` each issue carries, which costs nothing to read.
7966        let projects = self
7967            .board_issues()
7968            .await?
7969            .iter()
7970            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7971            .map(Resolved::project)
7972            .filter(|project| project_matches(project, query))
7973            .collect();
7974        Ok(offset_page(
7975            projects,
7976            numeric_cursor(page.cursor.as_ref())?,
7977            page.limit.min(MAX_PAGE_SIZE) as usize,
7978        ))
7979    }
7980    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
7981        Ok(self
7982            .item_by_id(id)
7983            .await?
7984            .filter(|item| item.kind == BoardKind::Document)
7985            .map(|item| item.document()))
7986    }
7987    async fn query_documents(
7988        &self,
7989        query: &DocumentQuery,
7990        page: &PageRequest,
7991    ) -> Result<Page<Document>, SourceError> {
7992        validate_page(page)?;
7993        // Narrowed to one project, this is the same sub-issue read a task list scoped to
7994        // that project makes — a document filed under a project is a sub-issue of it too,
7995        // and which of them come back is the kind this caller asked for.
7996        let (held, membership) = match &query.project {
7997            ProjectFilter::Is(project) => (
7998                self.project_children(project).await?,
7999                // Answered by where these items came from; see `task_matches`.
8000                &ProjectFilter::Any,
8001            ),
8002            ProjectFilter::Any | ProjectFilter::Orphans => {
8003                (self.board().await?.items, &query.project)
8004            }
8005        };
8006        // Filtered before paged, exactly as a task read is: a page of a filtered result is
8007        // a page of the survivors, never the survivors of a page.
8008        let documents = held
8009            .iter()
8010            .filter(|item| item.kind == BoardKind::Document)
8011            .map(Resolved::document)
8012            .filter(|document| document_matches(document, query, membership))
8013            .collect();
8014        Ok(offset_page(
8015            documents,
8016            numeric_cursor(page.cursor.as_ref())?,
8017            page.limit.min(MAX_PAGE_SIZE) as usize,
8018        ))
8019    }
8020    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8021        validate_page(page)?;
8022        let offset = numeric_cursor(page.cursor.as_ref())?;
8023        let mut labels = self
8024            .board()
8025            .await?
8026            .items
8027            .into_iter()
8028            .flat_map(|item| item.labels)
8029            .fold(Vec::new(), |mut all, label| {
8030                if !all.iter().any(|x: &Label| x.id == label.id) {
8031                    all.push(label);
8032                }
8033                all
8034            });
8035        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8036        Ok(offset_page(
8037            labels,
8038            offset,
8039            page.limit.min(MAX_PAGE_SIZE) as usize,
8040        ))
8041    }
8042    async fn task_dependencies(
8043        &self,
8044        id: &NativeId,
8045        direction: Direction,
8046        page: &PageRequest,
8047    ) -> Result<Page<DependencyEdge>, SourceError> {
8048        self.dependencies(id, ItemKind::Task, direction, page).await
8049    }
8050    async fn project_dependencies(
8051        &self,
8052        id: &NativeId,
8053        direction: Direction,
8054        page: &PageRequest,
8055    ) -> Result<Page<DependencyEdge>, SourceError> {
8056        self.dependencies(id, ItemKind::Project, direction, page)
8057            .await
8058    }
8059
8060    fn writes(&self) -> WriteSupport {
8061        WriteSupport::Supported
8062    }
8063
8064    /// Create or update one task.
8065    ///
8066    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8067    /// neither may name the task itself or name one task twice — and land in the body's
8068    /// metadata slot under their reserved keys, in place of any caller metadata of those
8069    /// names.
8070    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8071        let near = write.target.as_ref().unwrap_or(&write.item.id);
8072        for (key, entries) in [
8073            (TaskRef::DELIVERS_KEY, &write.item.delivers),
8074            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8075        ] {
8076            TaskRef::listed(key, near, Some(&self.name), entries.clone())
8077                .map_err(|message| SourceError::Refused { message })?;
8078        }
8079        if self.priorities.is_none() && write.item.priority != Priority::None {
8080            return Err(self.holds_no_priority());
8081        }
8082        self.write_item(
8083            &Incoming {
8084                written: Written::Work(ItemKind::Task, &write.item.status),
8085                title: &write.item.title,
8086                content: write.item.content.as_deref(),
8087                labels: &write.item.labels,
8088                metadata: &write.item.metadata,
8089                repositories: &write.item.repositories,
8090                parent: write.item.project.as_ref(),
8091                delivers: &write.item.delivers,
8092                delivered_by: &write.item.delivered_by,
8093                priority: self.priorities.as_ref().map(|_| write.item.priority),
8094            },
8095            write.target.as_ref(),
8096            &write.depends_on,
8097        )
8098        .await
8099    }
8100
8101    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8102        self.write_item(
8103            &Incoming {
8104                written: Written::Work(ItemKind::Project, &write.item.status),
8105                title: &write.item.title,
8106                content: write.item.content.as_deref(),
8107                labels: &write.item.labels,
8108                metadata: &write.item.metadata,
8109                repositories: &write.item.repositories,
8110                parent: None,
8111                delivers: &[],
8112                delivered_by: &[],
8113                priority: None,
8114            },
8115            write.target.as_ref(),
8116            &write.depends_on,
8117        )
8118        .await
8119    }
8120
8121    /// Create or update one document, which is one issue titled the way this board spells
8122    /// a document.
8123    ///
8124    /// Everything else is exactly a task write: caller metadata goes to the same canonical
8125    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8126    /// or a field this board cannot carry is refused by name rather than dropped, a target
8127    /// naming an issue this board does not hold is refused rather than created, and an
8128    /// issue this call created is taken back when the rest of the write fails.
8129    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8130        // A document takes part in no dependency graph, so there is no far end to write
8131        // natively and none to record: a caller naming one is told so rather than having it
8132        // stored under the reserved key, where a later read would report an edge the
8133        // contract says cannot exist.
8134        if !write.depends_on.is_empty() {
8135            return Err(SourceError::Refused {
8136                message: format!(
8137                    "this write names {} dependencies for a document, and a document takes \
8138                     part in no dependency graph; next: put the dependency on the task or \
8139                     project the document is about",
8140                    write.depends_on.len()
8141                ),
8142            });
8143        }
8144        self.write_item(
8145            &Incoming {
8146                written: Written::Document,
8147                title: &write.item.title,
8148                content: write.item.content.as_deref(),
8149                labels: &write.item.labels,
8150                metadata: &write.item.metadata,
8151                repositories: &write.item.repositories,
8152                parent: write.item.project.as_ref(),
8153                delivers: &[],
8154                delivered_by: &[],
8155                priority: None,
8156            },
8157            write.target.as_ref(),
8158            &[],
8159        )
8160        .await
8161    }
8162
8163    /// Set one task's status alone.
8164    ///
8165    /// An open target reopens a closed issue with an `updateIssue` carrying only its
8166    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8167    /// terminal target selects its mapped option, then closes with its fixed reason. No
8168    /// request carries a title, a body or a label. The status
8169    /// answered is what [`StatusMapping::status`] reads off the state just written, which is
8170    /// what a re-read reports.
8171    async fn set_task_status(
8172        &self,
8173        id: &NativeId,
8174        category: StatusCategory,
8175    ) -> Result<Option<Status>, SourceError> {
8176        self.set_status(id, category).await
8177    }
8178
8179    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
8180    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
8181    /// for `none`. Refused by an instance with no `priority_mapping`.
8182    async fn set_task_priority(
8183        &self,
8184        id: &NativeId,
8185        priority: Priority,
8186    ) -> Result<Option<Priority>, SourceError> {
8187        self.set_priority(id, priority).await
8188    }
8189
8190    /// Replace one task's content with a single body update that keeps the metadata slot
8191    /// byte for byte.
8192    async fn set_task_content(
8193        &self,
8194        id: &NativeId,
8195        content: &str,
8196    ) -> Result<Option<()>, SourceError> {
8197        self.replace_content(id, content).await
8198    }
8199
8200    /// Replace one task issue's content and its provenance slot entry with a single body
8201    /// update. The answers are not kept: see `replace_rendering`.
8202    async fn set_task_rendering(
8203        &self,
8204        id: &NativeId,
8205        content: &str,
8206        provenance: &Value,
8207        _answers: &BTreeMap<String, Value>,
8208    ) -> Result<Option<()>, SourceError> {
8209        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
8210            .await
8211    }
8212
8213    /// Replace one design-document issue's content and its provenance slot entry, on exactly
8214    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
8215    async fn set_document_rendering(
8216        &self,
8217        id: &NativeId,
8218        content: &str,
8219        provenance: &Value,
8220        _answers: &BTreeMap<String, Value>,
8221    ) -> Result<Option<()>, SourceError> {
8222        self.replace_rendering(id, BoardKind::Document, content, provenance)
8223            .await
8224    }
8225
8226    /// Apply a targeted update with one read of the item and a write only for what differs:
8227    /// at most one `updateIssue` for title, body and state, one field write each for `Status`
8228    /// and `Priority`, and the `blockedBy` difference. See `targeted_update`.
8229    async fn update_task(
8230        &self,
8231        id: &NativeId,
8232        update: &TaskUpdate,
8233    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8234        self.targeted_update(id, update).await
8235    }
8236
8237    /// Replace one task's `delivered_by` with a single body update that changes the
8238    /// metadata slot and nothing outside it.
8239    async fn set_delivered_by(
8240        &self,
8241        id: &NativeId,
8242        delivered_by: &[TaskRef],
8243    ) -> Result<Option<()>, SourceError> {
8244        self.replace_delivered_by(id, delivered_by).await
8245    }
8246
8247    /// Set one key of one task issue's metadata with a single body update that changes the
8248    /// metadata slot and nothing outside it — no title, label, state or board field request —
8249    /// and sends nothing when the task already holds that value under the key.
8250    async fn set_task_metadata(
8251        &self,
8252        id: &NativeId,
8253        key: &MetadataKey,
8254        value: &Value,
8255    ) -> Result<Option<Task>, SourceError> {
8256        Ok(self
8257            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
8258            .await?
8259            .map(|item| item.task())
8260            .transpose()?)
8261    }
8262
8263    /// Set one key of one project issue's metadata, on exactly the terms of
8264    /// [`set_task_metadata`](TaskSource::set_task_metadata).
8265    async fn set_project_metadata(
8266        &self,
8267        id: &NativeId,
8268        key: &MetadataKey,
8269        value: &Value,
8270    ) -> Result<Option<Project>, SourceError> {
8271        Ok(self
8272            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
8273            .await?
8274            .map(|item| item.project()))
8275    }
8276
8277    /// Set one key of one design-document issue's metadata, on exactly the terms of
8278    /// [`set_task_metadata`](TaskSource::set_task_metadata).
8279    async fn set_document_metadata(
8280        &self,
8281        id: &NativeId,
8282        key: &MetadataKey,
8283        value: &Value,
8284    ) -> Result<Option<Document>, SourceError> {
8285        Ok(self
8286            .set_slot_key(id, BoardKind::Document, key, value)
8287            .await?
8288            .map(|item| item.document()))
8289    }
8290
8291    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
8292        self.delete_item(id).await
8293    }
8294
8295    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
8296        self.delete_item(id).await
8297    }
8298
8299    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
8300        self.delete_item(id).await
8301    }
8302
8303    /// One page of the task issue's own comments, walked by GitHub's own cursor.
8304    ///
8305    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
8306    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
8307    async fn task_comments(
8308        &self,
8309        task: &NativeId,
8310        page: &PageRequest,
8311    ) -> Result<Option<Page<Comment>>, SourceError> {
8312        validate_page(page)?;
8313        let Some(issue) = self.commented_issue(task).await? else {
8314            return Ok(None);
8315        };
8316        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
8317        let data = self
8318            .graphql(
8319                graphql::ISSUE_COMMENTS,
8320                json!({"id":issue.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after}),
8321            )
8322            .await?;
8323        // The issue was there a moment ago; one removed since is no longer a task here.
8324        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
8325            return Ok(None);
8326        };
8327        let connection = node
8328            .get("comments")
8329            .filter(|value| !value.is_null())
8330            .ok_or_else(|| SourceError::Malformed {
8331                message: format!(
8332                    "GitHub issue {} answered with no comments connection",
8333                    issue.0
8334                ),
8335            })?;
8336        let items = optional_nodes(Some(connection), "issue comments")?
8337            .into_iter()
8338            .flatten()
8339            .map(comment_from)
8340            .collect::<Result<Vec<_>, _>>()?;
8341        let next = next_cursor(connection)?;
8342        if let Some(next) = &next {
8343            validate_cursor_progress(after, &next.0)?;
8344        }
8345        Ok(Some(Page { items, next }))
8346    }
8347
8348    /// Add one comment to the task's issue, as the account the token belongs to.
8349    ///
8350    /// The author is refused before anything is sent — not even the task is read — because
8351    /// no answer GitHub could give would make posting under another name than the one asked
8352    /// for the right outcome.
8353    async fn add_comment(
8354        &self,
8355        task: &NativeId,
8356        comment: &NewComment,
8357    ) -> Result<Option<Comment>, SourceError> {
8358        if let Some(author) = &comment.author {
8359            return Err(SourceError::Refused {
8360                message: format!(
8361                    "source {} cannot post a comment as {author:?}: GitHub records the account \
8362                     the token signs in as the author of every comment; next: leave --author \
8363                     out, and the comment is posted as that account",
8364                    self.name
8365                ),
8366            });
8367        }
8368        let Some(issue) = self.commented_issue(task).await? else {
8369            return Ok(None);
8370        };
8371        let data = self
8372            .graphql(
8373                graphql::ADD_COMMENT,
8374                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
8375            )
8376            .await?;
8377        let subject = data
8378            .pointer("/addComment/subject")
8379            .filter(|value| !value.is_null())
8380            .ok_or_else(|| SourceError::Malformed {
8381                message: "GitHub comment addition returned no subject".into(),
8382            })?;
8383        if required_str(subject, "id")? != issue.0 {
8384            return Err(SourceError::Malformed {
8385                message: "GitHub comment addition answered about another issue".into(),
8386            });
8387        }
8388        let added = data
8389            .pointer("/addComment/commentEdge/node")
8390            .filter(|value| !value.is_null())
8391            .ok_or_else(|| SourceError::Malformed {
8392                message: "GitHub comment addition returned no comment".into(),
8393            })?;
8394        comment_from(added).map(Some)
8395    }
8396
8397    async fn edit_comment(
8398        &self,
8399        task: &NativeId,
8400        comment: &NativeId,
8401        body: &CommentBody,
8402    ) -> Result<Option<Comment>, SourceError> {
8403        let Some(issue) = self.commented_issue(task).await? else {
8404            return Ok(None);
8405        };
8406        if !self.comment_is_on(&issue, comment).await? {
8407            return Ok(None);
8408        }
8409        let data = self
8410            .graphql(
8411                graphql::UPDATE_COMMENT,
8412                json!({"input":{"id":comment.0,"body":body.as_str()}}),
8413            )
8414            .await?;
8415        let edited = data
8416            .pointer("/updateIssueComment/issueComment")
8417            .filter(|value| !value.is_null())
8418            .ok_or_else(|| SourceError::Malformed {
8419                message: "GitHub comment update returned no comment".into(),
8420            })?;
8421        let edited = comment_from(edited)?;
8422        if edited.id != *comment {
8423            return Err(SourceError::Malformed {
8424                message: "GitHub comment update returned the wrong comment".into(),
8425            });
8426        }
8427        Ok(Some(edited))
8428    }
8429
8430    async fn delete_comment(
8431        &self,
8432        task: &NativeId,
8433        comment: &NativeId,
8434    ) -> Result<Option<NativeId>, SourceError> {
8435        let Some(issue) = self.commented_issue(task).await? else {
8436            return Ok(None);
8437        };
8438        if !self.comment_is_on(&issue, comment).await? {
8439            return Ok(None);
8440        }
8441        let data = self
8442            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
8443            .await?;
8444        // The payload says nothing about the comment it removed, so what is checked is that
8445        // GitHub answered the mutation at all rather than leaving it unanswered.
8446        data.get("deleteIssueComment")
8447            .filter(|value| !value.is_null())
8448            .ok_or_else(|| SourceError::Malformed {
8449                message: "GitHub comment deletion returned no payload".into(),
8450            })?;
8451        Ok(Some(comment.clone()))
8452    }
8453
8454    /// Every request this source has recorded, and what each of GitHub's two budgets was
8455    /// attributed — read off the same accounting the session report is rendered from, so
8456    /// the two cannot count one request two ways.
8457    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
8458        Ok(Some(self.ledger.snapshot().metering()))
8459    }
8460}
8461
8462/// One issue comment as the contract carries it.
8463///
8464/// `author` is absent both when GitHub answers `null` for an account that no longer exists
8465/// and when it answers an actor with no login, because either way the source did not say who
8466/// wrote it — which is what an absent author means, rather than an author called nothing.
8467fn comment_from(value: &Value) -> Result<Comment, SourceError> {
8468    Ok(Comment {
8469        id: NativeId(required_str(value, "id")?.to_owned()),
8470        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
8471            .map(str::to_owned),
8472        created_at: optional_time(value, "createdAt")?,
8473        updated_at: optional_time(value, "updatedAt")?,
8474        body: required_str(value, "body")?.to_owned(),
8475        url: optional_str(value, "url")?.map(str::to_owned),
8476    })
8477}
8478
8479/// Where the recorded tail of a dependency walk resumes; see
8480/// [`GitHubProjectsSource::recorded_edges`].
8481const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
8482
8483/// The board text field this source keeps a copy's origin in.
8484///
8485/// Named after the key it holds, and held to that name by the guard below rather than by
8486/// a reader noticing.
8487const ORIGIN_FIELD: &str = "onetaskgraph.origin";
8488
8489/// The metadata key that field holds.
8490///
8491/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
8492/// constructs or interprets the qualified id it carries. This source names it only to
8493/// route it — a short, typed value belongs in a typed field rather than in the body slot
8494/// a caller's own prose shares.
8495///
8496/// Restated rather than imported, because no plugin crate may depend on the engine. What
8497/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
8498/// target in `check`: it reads the engine's own literal and fails naming the file and the
8499/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
8500/// that creates a second item every run instead of finding the one it wrote — and that is
8501/// too late to learn it.
8502const ORIGIN_KEY: &str = "onetaskgraph.origin";
8503
8504/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
8505///
8506/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
8507/// is derived from the far end, never written down on the near item — so only a forward
8508/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
8509/// it did not come from, and it is told so rather than answered with an empty page that
8510/// reads as a walk which ended.
8511fn recorded_offset(
8512    cursor: Option<&str>,
8513    direction: Direction,
8514) -> Result<Option<usize>, SourceError> {
8515    cursor
8516        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
8517        .map(|offset| {
8518            if direction != Direction::DependsOn {
8519                return Err(SourceError::Config {
8520                    message: format!(
8521                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
8522                         reverse dependency read never issues; resume it in the direction \
8523                         that reported it"
8524                    ),
8525                });
8526            }
8527            offset.parse().map_err(|_| SourceError::Config {
8528                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
8529            })
8530        })
8531        .transpose()
8532}
8533
8534fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
8535    let mut page = offset_page(edges, offset, limit.max(1));
8536    page.next = page
8537        .next
8538        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
8539    page
8540}
8541
8542/// The kind of one issue reached through a dependency connection.
8543///
8544/// The same questions the board scan asks, over the fields the dependency document
8545/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
8546/// then anything with sub-issues or the marker is a project.
8547///
8548/// # Errors
8549///
8550/// A far end this board holds as a document is refused rather than reported. The two
8551/// answers that are not refusals would both be wrong: reporting it as a task names an id
8552/// no task read of this source can find, and reporting it as a project names one no
8553/// project read can. There is no third value to return — `ItemKind` has no document
8554/// variant, because nothing may point at a document — so the relationship itself is what
8555/// the person is told about.
8556fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
8557    let id = required_str(value, "id")?;
8558    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
8559        return Err(SourceError::Refused {
8560            message: format!(
8561                "GitHub issue {id} is a document of this board — its title begins \
8562                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
8563                 on by one; next: remove that issue's blocking relationship on this board"
8564            ),
8565        });
8566    }
8567    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
8568    if parent.is_some() {
8569        return Ok(ItemKind::Task);
8570    }
8571    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
8572    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8573        message: format!("GitHub issue {id}: {message}"),
8574    })?;
8575    let sub_issues = sub_issue_total(value)?;
8576    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
8577        ItemKind::Project
8578    } else {
8579        ItemKind::Task
8580    })
8581}
8582
8583/// The `IssueStateUpdateInput` one status target asks for.
8584///
8585/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
8586/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
8587/// a currently-closed issue: without that the item would read back `Unknown` and a copy
8588/// would report a change forever. A document has no status at all, and asks for neither.
8589fn state_input(target: Option<&StatusTarget>) -> Value {
8590    match target {
8591        Some(StatusTarget::Terminal(_, reason)) => {
8592            json!({"value":"CLOSED","stateReason":reason.reason()})
8593        }
8594        Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
8595        // A document has no status, so a write of one says nothing about the issue's open
8596        // or closed state rather than forcing it open: `stateInput` is what carries that
8597        // instruction, and an explicit null asks for no change to it.
8598        None => Value::Null,
8599    }
8600}
8601
8602/// The metadata one write stores in the item's body slot.
8603///
8604/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
8605/// rather than carried: the kind marker so an empty project stays readable, the
8606/// repository list only when it is not exactly the issue's own repository, and the far
8607/// ends no relationship here can name.
8608///
8609/// The copy origin is the one typed field that is also mirrored here, and only as a
8610/// mirror: it lands in the board's origin field as well, which stays the one every reader
8611/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
8612/// and catches up with a write in seconds rather than minutes — can find the item by it.
8613/// A reader of the release before this one drops the slot's copy and reads the field, so an
8614/// item written here still reads with exactly one origin there.
8615fn slot_metadata(
8616    incoming: &Incoming<'_>,
8617    own_repository: Option<&Repository>,
8618    fallback: &[DependencyEdge],
8619) -> BTreeMap<String, Value> {
8620    let mut metadata = incoming.metadata.clone();
8621    match metadata.remove(ORIGIN_KEY) {
8622        Some(Value::String(origin)) if !origin.is_empty() => {
8623            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
8624        }
8625        _ => {}
8626    }
8627    match incoming.written.kind() {
8628        BoardKind::Work(kind) => metadata.insert(
8629            ItemKind::METADATA_KEY.to_owned(),
8630            Value::String(kind.marker().to_owned()),
8631        ),
8632        // A document is told by its title, so it carries no kind marker: that key names
8633        // what a dependency endpoint points at, and nothing may point at a document.
8634        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
8635    };
8636    let derivable = own_repository
8637        .map(|own| incoming.repositories == [own.clone()])
8638        .unwrap_or(incoming.repositories.is_empty());
8639    if derivable {
8640        metadata.remove(Repository::METADATA_KEY);
8641    } else {
8642        metadata.insert(
8643            Repository::METADATA_KEY.to_owned(),
8644            Value::Array(
8645                incoming
8646                    .repositories
8647                    .iter()
8648                    .map(|repository| Value::String(repository.as_str().to_owned()))
8649                    .collect(),
8650            ),
8651        );
8652    }
8653    // The typed lists are what land, whatever the caller's own metadata held under their
8654    // keys: a key of either name travelling beside the field would otherwise be a second
8655    // answer to the same question, and the field is the one the contract names.
8656    for (key, entries) in [
8657        (TaskRef::DELIVERS_KEY, incoming.delivers),
8658        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
8659    ] {
8660        set_task_list(&mut metadata, key, entries);
8661    }
8662    record_edges(&mut metadata, fallback);
8663    metadata
8664}
8665
8666/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
8667/// one slot's metadata, or no such key when there are none.
8668fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
8669    if fallback.is_empty() {
8670        metadata.remove(DependencyEdge::RECORDED_KEY);
8671    } else {
8672        metadata.insert(
8673            DependencyEdge::RECORDED_KEY.to_owned(),
8674            Value::Array(
8675                fallback
8676                    .iter()
8677                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
8678                    .collect(),
8679            ),
8680        );
8681    }
8682}
8683
8684/// Every label one item carries, from its content's own connection and nowhere else.
8685///
8686/// There is no second place to read one from: no document this source sends selects the
8687/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
8688/// cannot carry one at all. The module documentation records the three schema facts that
8689/// settle it.
8690fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
8691    optional_nodes(content.get("labels"), "content labels")?
8692        .into_iter()
8693        .flatten()
8694        .map(|v| {
8695            Ok(Label {
8696                id: NativeId(required_str(v, "id")?.to_owned()),
8697                name: required_str(v, "name")?.to_owned(),
8698                color: optional_str(v, "color")?.map(str::to_owned),
8699            })
8700        })
8701        .collect()
8702}
8703
8704/// The definition of each board field one item's values are values of, in the shape a read
8705/// of the board's own `fields` gives one.
8706///
8707/// A value names its field through a fragment on that field's own type, so the type is
8708/// known from which kind of value it is: a single-select value's field is a
8709/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
8710/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
8711fn field_definitions(field_values: &[Value]) -> Vec<Value> {
8712    field_values
8713        .iter()
8714        .filter_map(|value| {
8715            let field = value.get("field")?.as_object()?;
8716            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
8717            let typename = if value.get("text").is_some() {
8718                "ProjectV2Field"
8719            } else if value.get("name").is_some() {
8720                "ProjectV2SingleSelectField"
8721            } else {
8722                return None;
8723            };
8724            let mut defined = field.clone();
8725            defined.insert("__typename".to_owned(), json!(typename));
8726            Some(Value::Object(defined))
8727        })
8728        .collect()
8729}
8730
8731fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
8732    let Some(node) = field_values
8733        .iter()
8734        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
8735    else {
8736        return Ok(None);
8737    };
8738    Ok(optional_str(node, "text")?.map(str::to_owned))
8739}
8740
8741fn valid_github_owner(owner: &str) -> bool {
8742    !owner.is_empty()
8743        && owner.len() <= 39
8744        && !owner.starts_with('-')
8745        && !owner.ends_with('-')
8746        && !owner.contains("--")
8747        && owner
8748            .bytes()
8749            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
8750}
8751
8752/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
8753/// neither of the two names a path segment already means.
8754fn valid_github_repository_name(name: &str) -> bool {
8755    !name.is_empty()
8756        && name.len() <= 100
8757        && name != "."
8758        && name != ".."
8759        && name
8760            .bytes()
8761            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
8762}
8763
8764fn valid_environment_name(name: &str) -> bool {
8765    let mut bytes = name.bytes();
8766    bytes
8767        .next()
8768        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
8769        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
8770}
8771
8772/// How many sub-issues one issue has.
8773///
8774/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
8775/// absent or non-integer one is a response this source cannot read — and reading it as
8776/// zero would classify a project as a task, which is exactly the mistake the marker
8777/// exists to keep from happening quietly.
8778fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
8779    let summary = issue
8780        .get("subIssuesSummary")
8781        .ok_or_else(|| SourceError::Malformed {
8782            message: "GitHub issue is missing subIssuesSummary".into(),
8783        })?;
8784    summary
8785        .get("total")
8786        .and_then(Value::as_u64)
8787        .ok_or_else(|| SourceError::Malformed {
8788            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
8789        })
8790}
8791
8792/// One issue's own `number`.
8793///
8794/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
8795/// an issue in this module asks for it. So a read of one that comes back without it, or
8796/// with something that is not an unsigned integer, is a response this source cannot read —
8797/// absence here is **not** "this issue has no number". A draft is the content that has
8798/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
8799/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
8800fn issue_number(issue: &Value) -> Result<u64, SourceError> {
8801    issue
8802        .get("number")
8803        .and_then(Value::as_u64)
8804        .ok_or_else(|| SourceError::Malformed {
8805            message: "GitHub issue number is missing or is not an unsigned integer".into(),
8806        })
8807}
8808
8809/// The `number` a creating mutation answered with, and `None` when it answered without one;
8810/// why a missing one is tolerated is at the call in `create_and_file_issue`.
8811fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
8812    match created.get("number") {
8813        None | Some(Value::Null) => Ok(None),
8814        Some(value) => value
8815            .as_u64()
8816            .map(Some)
8817            .ok_or_else(|| SourceError::Malformed {
8818                message: "GitHub created issue number is not an unsigned integer".into(),
8819            }),
8820    }
8821}
8822
8823fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8824    value
8825        .get(field)
8826        .and_then(Value::as_str)
8827        .ok_or_else(|| SourceError::Malformed {
8828            message: format!("GitHub response is missing string field {field}"),
8829        })
8830}
8831
8832fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8833    let found = required_str(value, field)?;
8834    if found.trim().is_empty() {
8835        return Err(SourceError::Malformed {
8836            message: format!("GitHub response has blank string field {field}"),
8837        });
8838    }
8839    Ok(found)
8840}
8841
8842/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
8843/// needs one — Linear spells them too, in its own description field.
8844///
8845/// Restated rather than shared, because a plugin crate depends on the contract crate and
8846/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
8847/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
8848/// source round-trips its own writes perfectly well under its own spelling.
8849const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
8850const METADATA_CLOSE: &str = "\n-->";
8851
8852/// What the composer puts between a non-empty visible body and the slot, and the one thing
8853/// the parser takes off the visible body when it takes the slot off — exactly once, so every
8854/// other trailing byte of the body comes back as it was written.
8855// 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.
8856const METADATA_SEPARATOR: &str = "\n\n";
8857
8858/// The visible body and the metadata slot at the end of it.
8859///
8860/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
8861/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
8862/// own content and is left alone. The visible body is everything before the slot less the
8863/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
8864fn metadata_body(
8865    body: Option<String>,
8866) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
8867    let Some(body) = body else {
8868        return Ok((None, BTreeMap::new()));
8869    };
8870    let Some(slot) = slot_span(&body)? else {
8871        return Ok((Some(body), BTreeMap::new()));
8872    };
8873    let metadata =
8874        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
8875            SourceError::Malformed {
8876                message: format!(
8877                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
8878                ),
8879            }
8880        })?;
8881    let before = &body[..slot.start];
8882    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
8883    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
8884}
8885
8886/// Where the metadata slot sits in one body, as byte offsets into it.
8887struct SlotSpan {
8888    /// Where [`METADATA_OPEN`] begins.
8889    start: usize,
8890    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
8891    encoded_start: usize,
8892    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
8893    encoded_end: usize,
8894    /// Just past [`METADATA_CLOSE`].
8895    end: usize,
8896}
8897
8898/// The slot at the very end of `body`, or `None` when it has none.
8899///
8900/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
8901/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
8902/// slot.
8903fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
8904    let Some(start) = body.rfind(METADATA_OPEN) else {
8905        return Ok(None);
8906    };
8907    let encoded_start = start + METADATA_OPEN.len();
8908    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
8909        return Err(SourceError::Malformed {
8910            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
8911        });
8912    };
8913    let encoded_end = encoded_start + relative_end;
8914    let end = encoded_end + METADATA_CLOSE.len();
8915    if !body[end..].trim().is_empty() {
8916        return Ok(None);
8917    }
8918    Ok(Some(SlotSpan {
8919        start,
8920        encoded_start,
8921        encoded_end,
8922        end,
8923    }))
8924}
8925
8926/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
8927/// slot as it was.
8928///
8929/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
8930/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
8931/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
8932/// or alone in an empty body — and a body with no slot that is given no metadata is
8933/// returned as it is.
8934fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
8935    let encoded = if metadata.is_empty() {
8936        None
8937    } else {
8938        Some(
8939            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8940                message: error.to_string(),
8941            })?,
8942        )
8943    };
8944    Ok(match (slot_span(body)?, encoded) {
8945        (Some(slot), Some(encoded)) => format!(
8946            "{}{encoded}{}",
8947            &body[..slot.encoded_start],
8948            &body[slot.encoded_end..]
8949        ),
8950        (Some(slot), None) => {
8951            let before = &body[..slot.start];
8952            format!(
8953                "{}{}",
8954                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
8955                &body[slot.end..]
8956            )
8957        }
8958        (None, None) => body.to_owned(),
8959        (None, Some(encoded)) if body.is_empty() => {
8960            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8961        }
8962        (None, Some(encoded)) => {
8963            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8964        }
8965    })
8966}
8967
8968/// `body` with everything before its metadata slot replaced by `content`, and the slot
8969/// itself kept byte for byte.
8970///
8971/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
8972/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
8973/// `content` is empty — so a read of the result reports `content` as the visible body and
8974/// the slot's metadata exactly as it was.
8975fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
8976    let Some(slot) = slot_span(body)? else {
8977        return Ok(content.to_owned());
8978    };
8979    let kept = &body[slot.start..];
8980    Ok(if content.is_empty() {
8981        kept.to_owned()
8982    } else {
8983        format!("{content}{METADATA_SEPARATOR}{kept}")
8984    })
8985}
8986
8987/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
8988fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
8989    if entries.is_empty() {
8990        metadata.remove(key);
8991    } else {
8992        metadata.insert(
8993            key.to_owned(),
8994            Value::Array(
8995                entries
8996                    .iter()
8997                    .map(|entry| Value::String(entry.as_str().to_owned()))
8998                    .collect(),
8999            ),
9000        );
9001    }
9002}
9003
9004fn compose_body(
9005    content: Option<&str>,
9006    metadata: &BTreeMap<String, Value>,
9007) -> Result<Option<String>, SourceError> {
9008    let visible = content.unwrap_or_default();
9009    if metadata.is_empty() {
9010        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9011    }
9012    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9013        message: error.to_string(),
9014    })?;
9015    Ok(Some(if visible.is_empty() {
9016        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9017    } else {
9018        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9019    }))
9020}
9021
9022fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9023    value
9024        .get(field)
9025        .and_then(Value::as_bool)
9026        .ok_or_else(|| SourceError::Malformed {
9027            message: format!("GitHub response is missing boolean field {field}"),
9028        })
9029}
9030fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9031    match value.get(field) {
9032        None | Some(Value::Null) => Ok(None),
9033        Some(value) => value
9034            .as_str()
9035            .map(Some)
9036            .ok_or_else(|| SourceError::Malformed {
9037                message: format!("GitHub response field {field} is not a string or null"),
9038            }),
9039    }
9040}
9041fn optional_nodes<'a>(
9042    connection: Option<&'a Value>,
9043    name: &str,
9044) -> Result<Option<&'a Vec<Value>>, SourceError> {
9045    match connection {
9046        None | Some(Value::Null) => Ok(None),
9047        Some(value) => value
9048            .get("nodes")
9049            .and_then(Value::as_array)
9050            .map(Some)
9051            .ok_or_else(|| SourceError::Malformed {
9052                message: format!("GitHub {name}.nodes is not an array"),
9053            }),
9054    }
9055}
9056fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9057    let page_info = connection
9058        .get("pageInfo")
9059        .ok_or_else(|| SourceError::Malformed {
9060            message: format!("GitHub {name} has no pageInfo"),
9061        })?;
9062    if required_bool(page_info, "hasNextPage")? {
9063        return Err(SourceError::Malformed {
9064            message: format!(
9065                "GitHub {name} exceeds the supported nested connection size of {size}"
9066            ),
9067        });
9068    }
9069    Ok(())
9070}
9071fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9072    optional_str(value, field)?
9073        .map(|timestamp| {
9074            timestamp.parse().map_err(|error| SourceError::Malformed {
9075                message: format!("GitHub response field {field} is not a timestamp: {error}"),
9076            })
9077        })
9078        .transpose()
9079}
9080fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9081    if page.limit == 0 {
9082        Err(SourceError::Config {
9083            message: "page limit must be at least 1".into(),
9084        })
9085    } else {
9086        Ok(())
9087    }
9088}
9089fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9090    let page = connection
9091        .get("pageInfo")
9092        .filter(|value| value.is_object())
9093        .ok_or_else(|| SourceError::Malformed {
9094            message: "GitHub connection is missing pageInfo".into(),
9095        })?;
9096    if required_bool(page, "hasNextPage")? {
9097        let cursor = required_str(page, "endCursor")?;
9098        validate_cursor_progress(None, cursor)?;
9099        Ok(Some(Cursor(cursor.into())))
9100    } else {
9101        Ok(None)
9102    }
9103}
9104fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9105    if next.is_empty() || previous == Some(next) {
9106        Err(SourceError::Malformed {
9107            message: "GitHub pagination cursor is empty or did not advance".into(),
9108        })
9109    } else {
9110        Ok(())
9111    }
9112}
9113/// The version of this plugin's opaque narrowing-search cursor.
9114pub const SEARCH_CURSOR_VERSION: u32 = 4;
9115
9116#[derive(Serialize, Deserialize)]
9117#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9118enum SearchConnection {
9119    Initial {},
9120    Continuing { after: Cursor },
9121    Exhausted {},
9122}
9123impl SearchConnection {
9124    fn after(&self) -> Option<&str> {
9125        match self {
9126            Self::Continuing { after } => Some(&after.0),
9127            _ => None,
9128        }
9129    }
9130    fn exhausted(&self) -> bool {
9131        matches!(self, Self::Exhausted { .. })
9132    }
9133    /// Whether a cursor naming this position, `offset` rows into its page, is one this
9134    /// plugin could have handed out: a page is resumed only part of the way through it — an
9135    /// offset of a whole page or more would skip rows nobody was given — an initial page
9136    /// only once some of it was handed out, and an exhausted connection has no page to be
9137    /// part of the way through.
9138    fn valid_resume(&self, offset: usize) -> bool {
9139        let within = offset < SEARCH_PAGE_SIZE as usize;
9140        match self {
9141            Self::Initial { .. } => offset > 0 && within,
9142            Self::Continuing { after } => !after.0.is_empty() && within,
9143            Self::Exhausted { .. } => offset == 0,
9144        }
9145    }
9146}
9147
9148/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
9149#[derive(Serialize, Deserialize)]
9150#[serde(deny_unknown_fields)]
9151struct SearchPosition {
9152    version: u32,
9153    connection: SearchConnection,
9154    /// How many rows of the page `connection` starts were already handed out.
9155    #[serde(default, skip_serializing_if = "is_zero")]
9156    offset: usize,
9157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
9158    seen: Vec<NativeId>,
9159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
9160    own: Vec<NativeId>,
9161}
9162impl Default for SearchPosition {
9163    fn default() -> Self {
9164        Self {
9165            version: SEARCH_CURSOR_VERSION,
9166            connection: SearchConnection::Initial {},
9167            offset: 0,
9168            seen: Vec::new(),
9169            own: Vec::new(),
9170        }
9171    }
9172}
9173
9174fn is_zero(offset: &usize) -> bool {
9175    *offset == 0
9176}
9177
9178fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
9179    cursor.map_or(Ok(0), |c| {
9180        c.0.parse().map_err(|_| SourceError::Config {
9181            message: "page cursor is invalid".into(),
9182        })
9183    })
9184}
9185fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
9186    if offset > items.len() {
9187        return Page::last(vec![]);
9188    }
9189    let tail = items.split_off(offset);
9190    let mut selected = tail;
9191    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
9192    selected.truncate(limit);
9193    Page {
9194        items: selected,
9195        next,
9196    }
9197}