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 — paged at [`MAX_PAGE_SIZE`] | 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 board half of an issue — its board item's id, its `Status` option and this
193//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
194//! an item reached any of those ways resolves through the same
195//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
196//! same status, the same labels and the same qualified id. That connection comes back a
197//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
198//! on the page in hand and — only if that page reports more of the connection — in the
199//! last row's read of that one issue's memberships, resumed from the page's own cursor and
200//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
201//! report, which is what keeps an id naming another repository's issue from being answered
202//! as an item of this board; and because the page is where the search starts rather than
203//! where it ends, that answer is one about a connection read to exhaustion and never about
204//! an unread page. Nothing costs the extra read but an issue on more boards than a page
205//! holds: an issue this board really does not hold reports no next page, so its
206//! memberships are already exhausted where they arrived.
207//!
208//! **No document here selects the board's own `Labels` field, and nothing is lost by
209//! that.** An item's labels are read from its content alone, wherever that content is
210//! reached: the three documents above select `Issue.labels` on the fragment, and
211//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
212//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
213//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
214//! create one, and `ProjectV2FieldValue` — the whole of what
215//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
216//! it from the content, for every content type it exists on, and there is nothing it can
217//! hold that the content does not already say: for an `Issue` it *is* that issue's own
218//! labels, so selecting it beside them unions a set with itself.
219//!
220//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
221//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
222//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
223//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
224//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
225//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
226//! label set, and that set is the fixture issue's own, by
227//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
228//! item whose content is a draft reports an empty set, by
229//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
230//! selection is held over [`graphql::DOCUMENTS`] by
231//! `no_document_selects_the_boards_own_labels_field`.
232//!
233//! The whole-board row is still the board's own item connection, and deliberately: a
234//! **draft** board item is not an issue, so no search can list one, and the reads that have
235//! to answer for the whole board are the ones whose cost is the board's size anyway.
236//!
237//! **A question about one item this source already names by id never lists the board.**
238//! Whether that item is on this board, and what its board fields are, is answered by reading
239//! that item — its own `Issue.projectItems`, walked to exhaustion by
240//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
241//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
242//! holds. That covers a write's destination, the project a new item is filed under, a
243//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
244//! and the delete that takes back an item a copy made. What such a write needs of the board
245//! and the item does not carry — the board's id, the `Status` and origin field definitions —
246//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
247//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
248//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
249//! minutes. Scanning this host's 842-item board has refused a document copy and an update
250//! even though the items' own reads named that board. A scan there gives the wrong answer
251//! as well as paying for every page. So a `board.items` lookup does not belong on any of
252//! those paths.
253//!
254//! **What a read may return is capped too, and that cap is on the document rather than on
255//! the board.** GitHub limits the number of nodes **one query may return** to
256//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
257//! an error naming the connection the count crossed at, not a slow or a partial result.
258//! Every board this source reads is refused the same way, so no board is too big for these
259//! documents and none is small enough to save one that is over.
260//!
261//! The count is arithmetic over the document's own text: each connection contributes the
262//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
263//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
264//! restate them — `github-graphql-node-count` implements them, and
265//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
266//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
267//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
268//! same text on every run and fails naming any that reaches the limit — so a connection
269//! added to a shared fragment is caught there rather than by GitHub.
270//!
271//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
272//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
273//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
274//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
275//! effectively squared there, which is why it is the one the limit is most sensitive to.
276//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
277//! page of memberships misses is recovered by one further read rather than refused, so it
278//! buys a bound every read pays for at the price of a request only a multi-board issue
279//! pays.
280//!
281//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
282//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
283//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
284//! `cost` is rate-limit points, metered per hour across everything one credential does; it
285//! is what the two limiters [`Limiter`] tells apart meter, and a document under
286//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
287//! that second number, and `tests/point_cost.rs` pins every document in
288//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
289//! one under, the pin itself is the check. The credentialed lane reconciles both figures
290//! against GitHub's own, off a probe it already sends.
291//!
292//! **What is pinned that way is a per-document price and never a session's.** The record in
293//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
294//! **requests** and **worst-case nodes** — and neither is points. What one whole session
295//! consumes of the hourly point allowance is observable only from a credentialed run's own
296//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
297//! and what `tests/live.rs` prints at the end of every run.
298//!
299//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
300//!
301//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
302//! enumerations of a board can supply one alone.** Resolving a node id is strongly
303//! consistent, so a read by id and a project's own sub-issues are already current. The
304//! other two are not, and they are behind by different amounts and in different directions:
305//!
306//! - GitHub's **issue search** is an index and answers a write made moments ago with the
307//!   value from before it — usually for a second or two.
308//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
309//!   on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
310//!   content withheld, absent, with the connection walked to its own `hasNextPage: false` —
311//!   for *minutes*, while `Issue.projectItems` names the same membership at once.
312//!
313//! That second one is a measurement rather than a caution. This repository's own
314//! credentialed journey writes a project and waits for the board to report it, then writes a
315//! task and waits for the same thing seconds later on the same board: the project wait is
316//! answered through the search and converged in two or three attempts in each of three runs,
317//! and the task wait is answered through `ProjectV2.items` and converged in none of them
318//! inside thirty. Separately, an item added to a second and larger board was read back by
319//! `Issue.projectItems` on that board's own id while every one of that connection's nine
320//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
321//! through the lagging one alone is what had a board read deny an issue that had certainly
322//! landed on it.
323//!
324//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
325//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
326//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
327//! search reports what the projection is behind on. What closes the last
328//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
329//! source answers is completed with what this process itself wrote, so an item created
330//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
331//! nothing is written down, and the record dies with the process. **A wait that has to
332//! observe GitHub's own data cannot be answered from that record** — which is why the
333//! credentialed journey asks through a source built afresh, and why the union above rather
334//! than a longer wait is what makes such a wait converge.
335//!
336//! **A narrowed read is the same bargain, stated for each of the three predicates it
337//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
338//! than walking the board, and every such answer is completed with what this process wrote —
339//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
340//! each filtered by the same predicates as the rest — so an item this command wrote a moment
341//! ago is returned by a query that matches it whether or not the index has caught up. An item
342//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
343//! consistent. What is left is stated rather than papered over:
344//!
345//! | Read | Finds | Behind by |
346//! | --- | --- | --- |
347//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
348//! | 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 |
349//! | 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 |
350//! | origin, third read | this process's own writes | nothing |
351//!
352//! So an origin carrier another process added within the last second or two, before either
353//! index has it, can be missing from an origin query, and one written by the release before
354//! this one — its origin in the field alone — can be missing for as long as the board's own
355//! item connection is behind on it. A copy that must not duplicate its own earlier write
356//! relies on the link it records, not on either index. **A board draft is not an issue**, so
357//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
358//! lists one, the origin lookup drops any the board's own field filter names, and one this
359//! process wrote is not added back either.
360//!
361//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
362//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
363//! body's metadata slot, so the issue search can find it in seconds. The field is
364//! authoritative: this source reads an item's origin from the field alone, so a slot that
365//! disagrees with it, or holds one where the field holds none, is never read as a second
366//! origin — and the release before this one reads the slot, drops that key's copy for the
367//! field's, and sees the same one origin.
368//!
369//! Filtering happens before paging, so a page of a filtered result is a page of the
370//! survivors rather than the survivors of a page. Label matching and the substring rule a
371//! text candidate is confirmed by answer the same question the same way the local Markdown
372//! source's do; which candidates a text search has to confirm is GitHub's word match, which
373//! is the one place the two sources can answer the same text differently.
374//!
375//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
376//! has one source, `capabilities`, and the note above is the reasoning behind it rather
377//! than a second copy of it: without the three facts recorded here a reader takes the
378//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
379//! crate's own capabilities test, which pins every field of it against a fully spelled-out
380//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
381//! fails to compile there rather than going unasserted. -->
382//! The fixture-server tests above run wherever this crate is selected; the credentialed
383//! lane runs in the same required check, beside them, and can fail it — it verifies the
384//! 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,
385//! one filed under neither, a label on one of the three and a closed status on another —
386//! because that shape is what tells an honoured predicate from an ignored one: a board
387//! holding a single project answers a project filter the same way whether or not this
388//! source applies it, which is exactly how the defect above went unseen.
389//!
390//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
391//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
392//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
393//! nominated is what keeps a credentialed write lane off a board and a repository nobody
394//! nominated; it never asks GitHub which project was updated most recently. Before it
395//! starts, the lane also clears any item titled — and any repository label named — the way
396//! it titles and names its own artifacts, which is self-healing after an interrupted run:
397//! a process killed between its writes and its cleanup leaves artifacts the next run
398//! removes.
399//!
400//! # What a session of requests costs, and where the report is
401//!
402//! This source records **every** request it sends into [`accounting::Accounting`], at
403//! `send_once` — the one place a request leaves this crate, which is why a read path added
404//! later is counted without anybody remembering to count it. That is the whole of what this
405//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
406//! session's spend is arrived at, and what it deliberately does not know are set out.
407//!
408//! What one whole session of the live journey costs, counted that way against this crate's
409//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
410//! reduction it came out of, and with what it does and does not say about rate-limit points.
411//!
412//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
413//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
414//! code path — no environment variable, no feature, no build configuration — because an
415//! instrument nobody switches on measures nothing, and
416//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
417//! source's counts the whole session rather than this source's share. The credentialed lane
418//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
419//! passed or failed.
420//!
421//! **A live session refuses to start unless the account can afford it.** Before it does any
422//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
423//! GitHub documents as not counting against the REST rate limit and which answers both of
424//! its budgets at once — and starts only if, for each of them, what remains minus this
425//! session's estimated cost is still at least
426//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
427//! allowance. A session that cannot **declines**: it did not run, so it is
428//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
429//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
430//! waiting for the budget to come back. The estimate is derived offline from
431//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
432//! which is also where the published rule that model rests on is cited; the accounting
433//! above records the gate's own read like any other request, and
434//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
435//!
436//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
437//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
438//! text, which is what lets it run on every platform and on a pull request from a fork with
439//! no credential — and that is what actually stops a regression merging. But an offline
440//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
441//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
442//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
443//! number of nodes this query may return"* and whose `cost` is what that document would
444//! spend, both for a document **without executing it**, and the lane asks it for every query
445//! document this source sends, under the largest bindings this source sends, and fails when
446//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
447//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
448//! one; the offline pins still cover it. It records what those calls reported about the
449//! account's own allowance, because whether asking is free is a thing to observe rather than
450//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
451//! and `cost` is metered against an hourly allowance the accounting above reads off a
452//! credentialed run's own response headers.
453//!
454//! **GitHub has two rate limiters and this source is refused by both, so nothing here
455//! treats them as one thing.** The primary budget is the hourly allowance `gh api
456//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
457//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
458//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
459//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
460//! one part of.
461#![deny(missing_docs)]
462
463use std::collections::BTreeMap;
464use std::sync::{Arc, Mutex};
465use std::time::{Duration, Instant};
466
467use chrono::{DateTime, Utc};
468use onetaskgraph_plugin_api::{
469    Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
470    DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
471    LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
472    Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
473    SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskRef,
474    TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
475};
476use reqwest::{Client, StatusCode, Url};
477use schemars::{Schema, schema_for};
478use secrecy::{ExposeSecret, SecretString};
479use serde::{Deserialize, Serialize};
480use serde_json::{Value, json};
481
482pub mod accounting;
483
484use accounting::Accounting;
485
486/// The registry name for this plugin.
487pub const KIND: &str = "github-projects";
488/// GitHub's maximum connection page size.
489pub const MAX_PAGE_SIZE: u32 = 100;
490
491/// The most nodes any one document this source sends may be asked to return.
492///
493/// GitHub's own published per-query ceiling, taken from
494/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
495/// workspace cannot hold a stale copy of somebody else's number. A query above it is
496/// **refused before it is executed**, whoever is asking and whatever board they are
497/// asking about — so this is a bound on the documents rather than a budget that runs out.
498///
499/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
500/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
501/// everything the credential does — two numbers against two limits, and this constant
502/// bounds only the first. The second is computed offline too, per document:
503/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
504/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
505/// lane. There is no constant like this one to hold a price under, because points are an
506/// hourly allowance rather than a per-call bound.
507///
508/// Neither is a session's price. What `session-cost.md` records of a whole session is its
509/// **requests** and its **worst-case nodes**; what a whole session spends in points is
510/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
511/// [`accounting`]. The module section on the three ways this source reaches an item says how
512/// the count is arrived at, and which of the page sizes below decide it.
513pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
514
515/// Nested connection size for the connections that hang off one item.
516///
517/// It multiplies through every document that reaches an item under a page — the count
518/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
519/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
520/// every document under these constants and fails naming any that reaches the limit, so
521/// raising this is caught there rather than by GitHub.
522const NESTED_PAGE_SIZE: u32 = 50;
523/// How many of one issue's board memberships are read when an issue is reached directly.
524///
525/// An issue reached through a search or through its own node id carries its board half in
526/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
527/// under a page of issues, so every point of it multiplies through the whole document and
528/// is paid for whether or not any issue is on a second board — which is why it is
529/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
530///
531/// **Three, because what a page misses is now recovered rather than refused**, and the
532/// recovery is what the value is chosen against. An issue whose entry for this board sits
533/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
534/// that page's own cursor — so the value trades a bound every read pays for a request only
535/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
536/// boards would pay that request *per issue*, which is order N against the one page per
537/// hundred issues a read costs today. At three it is only reached by an issue on four or
538/// more boards at once, which keeps the recovery path exceptional rather than routine for
539/// a plausible deployment.
540const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
541/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
542/// its two connections for.
543///
544/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
545/// second is a duplicate a copy already takes the first of. Both connections are walked to
546/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
547/// never what the answer is. It is small because every point of it is paid on every lookup,
548/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
549/// worst-case nodes than the one whole-board read they replaced.
550const ORIGIN_PAGE_SIZE: u32 = 3;
551
552pub use github_graphql_node_count::{NodeCountError, Variables};
553
554/// The largest value this source can bind to each page-size variable its documents name.
555///
556/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
557/// constant above it wherever a caller's own limit could reach it — `$first` at
558/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
559/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
560/// not one configuration of it, which is what makes a bound computed under it a bound on
561/// every read.
562pub fn largest_page_sizes() -> Variables {
563    Variables::from([
564        ("first".to_owned(), MAX_PAGE_SIZE),
565        ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
566        ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
567        ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
568    ])
569}
570
571/// The most nodes `document` could be asked to return, by GitHub's published rules.
572///
573/// Computed offline from the document's own text under [`largest_page_sizes`] — no
574/// network, no credential and no schema — by
575/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
576/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
577/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
578///
579/// # Errors
580///
581/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
582/// no single operation, or binds a page size this source does not name — each of which is
583/// a defect in the document rather than a number.
584pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
585    node_count(document, &largest_page_sizes())
586}
587
588/// The most rate-limit points one call of `document` could spend, by GitHub's published
589/// rules.
590///
591/// Computed offline from the document's own text under [`largest_page_sizes`] — no
592/// network, no credential and no schema — by
593/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
594/// This is `cost`, metered **per hour** against the allowance one credential shares across
595/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
596/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
597/// under, so what `tests/point_cost.rs` does with it is pin every document in
598/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
599/// figures against GitHub's own reported `cost`.
600///
601/// # Errors
602///
603/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
604/// no single operation, or binds a page size this source does not name — each of which is
605/// a defect in the document rather than a number.
606pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
607    github_graphql_node_count::point_cost(document, &largest_page_sizes())
608}
609
610/// The most nodes `document` could be asked to return under `variables`.
611///
612/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
613/// [`accounting`] is this under the bindings one request really sent — one spelling of the
614/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
615/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
616///
617/// # Errors
618///
619/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
620/// no single operation, or binds a page size `variables` does not name.
621pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
622    github_graphql_node_count::node_count(document, variables)
623}
624
625/// The issue-title prefix that makes a board issue a document.
626///
627/// A GitHub Projects board has no document type — it holds issues — so the discriminator
628/// is the title, and this is the whole of it: an issue whose title begins with these bytes
629/// is a document and every other issue is the task or project the sub-issue rule makes it.
630///
631/// It is spelled **once**, here, and read rather than restated everywhere else — including
632/// by the shared journeys, which take it from this constant so a board fixture cannot
633/// drift from what this source reads. `docs/metadata.md` records the two consequences that
634/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
635/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
636/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
637pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
638
639/// Exact GraphQL query documents issued by this plugin.
640///
641/// Keeping the production documents here lets the pinned-schema test validate the same
642/// bytes that are sent to GitHub, rather than a test-only copy which could drift
643/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
644/// field, and its guarded caller always supplies the complete existing option set with ids.
645pub mod graphql {
646    /// The board half of one item: the field values every document here reads it from.
647    ///
648    /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
649    /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
650    /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
651    /// *the same value*, because
652    /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
653    /// one path. Three spellings of it is what would drift, so there is one.
654    ///
655    /// The `Status` option and this source's own origin text field are the whole of it. It
656    /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
657    /// content, so it holds nothing the content's own `labels` do not already say, and it
658    /// would sit a label connection two page sizes deep.
659    macro_rules! board_item_values {
660        () => {
661            r#"fieldValues(first:$nestedFirst){nodes{
662          ... on ProjectV2ItemFieldSingleSelectValue{name field{
663            ... on ProjectV2SingleSelectField{id name options{id name}}
664          }}
665          ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
666        }pageInfo{hasNextPage}}"#
667        };
668    }
669
670    /// Everything this source reads about one issue, wherever it reaches that issue.
671    ///
672    /// A macro rather than a constant so the three documents below can `concat!` it: one
673    /// spelling of these fields is what makes an issue read through the board-scoped
674    /// search, through its own node id, and through its project's sub-issue relationship
675    /// resolve to *the same* item, which is the whole of what
676    /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
677    ///
678    /// `projectItems` is what carries the board half of an issue: the board item's own id
679    /// and the [`board_item_values!`] above — the `Status` option and this source's origin
680    /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
681    /// issue rather than on the board, which is what makes the cost of a read proportional
682    /// to what was asked for instead of to the board's size.
683    ///
684    /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
685    /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
686    /// not on that page: a page here is where the search for the entry starts rather than
687    /// where it ends.
688    ///
689    /// It does **not** select the board's `Labels` field value, and that is the whole of
690    /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
691    /// a label connection there sits under `fieldValues` under `projectItems` under a page
692    /// of issues, spending `$nestedFirst` twice down one path, and took
693    /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
694    /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
695    /// above, and that connection is where every label this source reports comes from. No
696    /// document in this module selects the board field any longer, [`BOARD`] included; the
697    /// module documentation records why nothing it could have held is lost.
698    macro_rules! board_issue {
699        () => {
700            concat!(
701                r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
702      labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
703      projectItems(first:$boardItems){nodes{id project{id number}
704        "#,
705                board_item_values!(),
706                r#"}pageInfo{hasNextPage endCursor}}}"#
707            )
708        };
709    }
710
711    /// Every issue of one board, found by a search scoped to that board.
712    ///
713    /// This is how the projects a board holds are listed, and it selects no `items`
714    /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
715    /// container walked page by page, so nothing nested inside a board item is paid for.
716    /// Which of the issues it returns is a project is then read off `parent` — GitHub
717    /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
718    /// discriminator has to be applied to the field, which is a scalar on the issue and
719    /// costs nothing.
720    pub const SEARCH_ISSUES: &str = concat!(
721        r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
722      search(query:$search,type:$type,first:$first,after:$after){
723        pageInfo{hasNextPage endCursor}
724        nodes{__typename ...BoardIssue}
725      }
726    }"#,
727        board_issue!()
728    );
729
730    /// One issue by its own node id, which is what a qualified id names here.
731    ///
732    /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
733    /// answers a write made moments ago with the value from before it, and resolving a node
734    /// id does not.
735    pub const ISSUE: &str = concat!(
736        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
737      node(id:$id){__typename ...BoardIssue}
738    }"#,
739        board_issue!()
740    );
741
742    /// One project's tasks: the sub-issues of the issue that project is.
743    ///
744    /// The work this costs is the project's own size. Nothing about it grows as the board
745    /// gains projects, or as those projects gain tasks.
746    pub const SUB_ISSUES: &str = concat!(
747        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
748      node(id:$id){__typename
749        ... on Issue{subIssues(first:$first,after:$after){
750          pageInfo{hasNextPage endCursor}
751          nodes{__typename ...BoardIssue}
752        }}}
753    }"#,
754        board_issue!()
755    );
756
757    /// What a read of the board's own `items` selects of each item's content.
758    ///
759    /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
760    /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
761    /// content by one spelling.
762    macro_rules! board_item_content {
763        () => {
764            r#" content{
765        ... 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}}}
766        ... on PullRequest{__typename id}
767        ... on DraftIssue{__typename id title body createdAt updatedAt}
768      }"#
769        };
770    }
771
772    /// Reads the board's fields and one page of its items.
773    pub const BOARD: &str = concat!(
774        r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
775      owner:repositoryOwner(login:$owner){
776        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
777      }
778    } fragment Board on ProjectV2 { id title
779      fields(first:$nestedFirst){nodes{
780        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
781        ... on ProjectV2Field{__typename id name}
782      }pageInfo{hasNextPage}}
783      items(first:$first,after:$after){nodes{id "#,
784        board_item_values!(),
785        board_item_content!(),
786        r#"} pageInfo{hasNextPage endCursor}}
787    }"#
788    );
789
790    /// Every carrier of one copy origin, by two reads in one request, and nothing else of
791    /// the board.
792    ///
793    /// **`originItems`** is the board's own items narrowed by its own field filter —
794    /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
795    /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
796    /// qualified id, quoted. It reads the field every carrier already holds, whichever release
797    /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
798    /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
799    /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
800    /// fresh `addProjectV2ItemById` the way that connection does.
801    ///
802    /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
803    /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
804    /// indexes that comment, and the index catches up with a write in a second or two rather
805    /// than in minutes, so it finds a carrier another process wrote that the first read is
806    /// still behind on.
807    ///
808    /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
809    /// — and resumes from its own cursor; a connection already walked to its end is resumed
810    /// from its last cursor, which answers an empty page. Every candidate either read returns
811    /// is confirmed against its own origin field before it is reported, so a token match of
812    /// the search or anything else the filter admits never is.
813    ///
814    /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
815    /// own whole reads counts this one among them.
816    pub const ORIGIN_LOOKUP: &str = concat!(
817        r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
818      originItems:repositoryOwner(login:$owner){
819        ... on ProjectV2Owner{projectV2(number:$number){
820          items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
821        board_item_values!(),
822        board_item_content!(),
823        r#"} pageInfo{hasNextPage endCursor}}
824        }}
825      }
826      search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
827        pageInfo{hasNextPage endCursor}
828        nodes{__typename ...BoardIssue}
829      }
830    }"#,
831        board_issue!()
832    );
833
834    /// The board's own id and field definitions, and not one of its items.
835    ///
836    /// What a write needs of the board when the item it writes does not say: the id a field
837    /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
838    /// origin fields. It selects no `items`, so what it costs is the board's field list
839    /// however many items the board holds — and it decides nothing about which items those
840    /// are, which is the question a read of one item by its own id answers instead.
841    ///
842    /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
843    /// board's item reads by their root counts this one among them.
844    pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
845      boardFields:repositoryOwner(login:$owner){
846        ... on ProjectV2Owner{projectV2(number:$number){id
847          fields(first:$nestedFirst){nodes{
848            ... on ProjectV2SingleSelectField{__typename id name options{id name}}
849            ... on ProjectV2Field{__typename id name}
850          }pageInfo{hasNextPage}}
851        }}
852      }
853    }"#;
854
855    /// One board draft by its own node id, with the board item it sits in.
856    ///
857    /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
858    /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
859    /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
860    /// issue fragment reads, so a draft reached by id resolves through the same resolver a
861    /// board listing hands it to, and nothing has to list the board to find one.
862    pub const DRAFT: &str = concat!(
863        r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
864      node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
865        projectV2Items(first:$boardItems){nodes{id project{id number}
866        "#,
867        board_item_values!(),
868        r#"}pageInfo{hasNextPage endCursor}}}}
869    }"#
870    );
871
872    /// One issue's board memberships alone, walked past the page a read of it carried.
873    ///
874    /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
875    /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
876    /// boards than that page holds may have this board's entry past its end. This asks that
877    /// one issue for its memberships and nothing else — the caller already holds the issue —
878    /// so an answer of "this board does not hold it" is only ever given about a connection
879    /// read to exhaustion.
880    ///
881    /// It selects the board item's id, its project number and the same
882    /// [`board_item_values!`] the fragment does, because what it produces is handed to the
883    /// very same resolver: an issue recovered this way reports the same title, the same
884    /// status, the same labels and the same qualified id as one whose entry was on the
885    /// page.
886    ///
887    /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
888    /// multiplies through it and the membership connection can be walked at
889    /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
890    /// further request for any issue a person really keeps.
891    pub const ISSUE_BOARD_ITEMS: &str = concat!(
892        r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
893      node(id:$id){
894        ... on Issue{projectItems(first:$first,after:$after){
895          nodes{id project{id number}
896        "#,
897        board_item_values!(),
898        r#"}
899          pageInfo{hasNextPage endCursor}}}
900      }
901    }"#
902    );
903    /// Resolves the configured repository's node id, which creating an issue requires.
904    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
905    /// Reads both dependency directions for one issue, with each far end's own kind — and
906    /// the issue's own body, which is where an edge to another source is recorded, so that
907    /// half of a dependency read needs no second read of the issue or of the board.
908    pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
909      ... on Issue{body
910        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
911        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
912      }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
913    /// Creates one issue in the configured repository.
914    pub const CREATE_ISSUE: &str =
915        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
916    /// Puts an existing issue on the configured board.
917    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
918    /// Updates an issue's visible fields and its open or closed state in one call.
919    pub const UPDATE_ISSUE: &str =
920        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
921    /// Updates an existing draft's user-visible fields.
922    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
923    /// Updates a text or single-select value on one project item.
924    pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
925    /// Clears one project item's value of one field, which is what a `none` priority is.
926    pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
927    /// Creates one single-select field with its options. Only the guarded field setup may use
928    /// this document, and only for a field the board lacks.
929    pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
930    /// Replaces a single-select field's options. Only the guarded field setup — the
931    /// `status-options` and `fields` operations — may use this document, because GitHub
932    /// treats the input as the complete option list.
933    pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
934    /// A fresh snapshot of the Status field and every board item's assignment.
935    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}}}}}}"#;
936    /// Files one issue under another as a sub-issue, which is what project membership is.
937    pub const ADD_SUB_ISSUE: &str =
938        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
939    /// Takes one issue back out of its parent.
940    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
941    /// Adds GitHub's native issue blocked-by relationship.
942    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
943    /// Removes one native issue blocked-by relationship.
944    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
945    /// Deletes one issue, which takes its board item with it.
946    ///
947    /// The engine sends this in one situation only: undoing a copy that could not finish,
948    /// over the items that same copy created. Deleting the issue removes the board item
949    /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
950    pub const DELETE_ISSUE: &str =
951        r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
952
953    /// Everything this source reads about one issue comment, wherever it reaches one.
954    ///
955    /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
956    /// and a comment just edited are handed to one mapper, so they are selected by one
957    /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
958    /// longer exists, and `login` is the one member every kind of actor carries.
959    macro_rules! issue_comment {
960        () => {
961            "id author{login} createdAt updatedAt body url"
962        };
963    }
964
965    /// One task's comments: a page of its issue's own `comments` connection.
966    ///
967    /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
968    /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
969    /// list every time somebody edited it; left unordered the connection answers in the order
970    /// the comments were written, which is the order GitHub documents for the same collection
971    /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
972    /// node count and the caller's own page size is pushed straight down.
973    pub const ISSUE_COMMENTS: &str = concat!(
974        r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
975        issue_comment!(),
976        r#"}pageInfo{hasNextPage endCursor}}}}}"#
977    );
978    /// Which issue one comment is on, read before that comment is edited or removed.
979    ///
980    /// GitHub's comment mutations take the comment's id and nothing else, so without this a
981    /// comment id given against the wrong task would change a comment on another issue.
982    pub const COMMENT_ISSUE: &str =
983        r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
984    /// Adds one comment to an issue, signed as the account the token belongs to.
985    pub const ADD_COMMENT: &str = concat!(
986        r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
987        issue_comment!(),
988        r#"}}}}"#
989    );
990    /// Replaces the body of one issue comment.
991    pub const UPDATE_COMMENT: &str = concat!(
992        r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
993        issue_comment!(),
994        r#"}}}"#
995    );
996    /// Removes one issue comment. Its payload carries nothing about the comment it removed.
997    pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
998
999    /// Every document above, with what this source is doing when it sends one.
1000    ///
1001    /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1002    /// name the call that was refused, and a `match` with a catch-all arm would answer a
1003    /// document added later with "talking to GitHub" and never say so.
1004    ///
1005    /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1006    /// const` here that this list omits, so the two cannot part — which is the same guard
1007    /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1008    pub const DOCUMENTS: [(&str, &str); 29] = [
1009        (SEARCH_ISSUES, "searching this board's issues"),
1010        (ISSUE, "reading one issue"),
1011        (
1012            ISSUE_BOARD_ITEMS,
1013            "reading one issue's board memberships past the page it came with",
1014        ),
1015        (SUB_ISSUES, "reading a project's tasks"),
1016        (BOARD, "reading the board"),
1017        (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1018        (BOARD_FIELDS, "reading the board's fields"),
1019        (DRAFT, "reading one draft"),
1020        (REPOSITORY, "reading the destination repository"),
1021        (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1022        (CREATE_ISSUE, "creating an issue"),
1023        (ADD_TO_BOARD, "adding an issue to the board"),
1024        (UPDATE_ISSUE, "updating an issue"),
1025        (UPDATE_DRAFT, "updating a draft item"),
1026        (UPDATE_FIELD, "writing a board field"),
1027        (CLEAR_FIELD, "clearing a board field"),
1028        (
1029            CREATE_FIELD,
1030            "creating a board single-select field with its options",
1031        ),
1032        (
1033            STATUS_OPTIONS_SNAPSHOT,
1034            "snapshotting board Status options and assignments",
1035        ),
1036        (
1037            STATUS_OPTIONS_UPDATE,
1038            "safely replacing the board Status option list",
1039        ),
1040        (ADD_SUB_ISSUE, "filing an issue under its project"),
1041        (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1042        (ADD_BLOCKED_BY, "recording a dependency"),
1043        (REMOVE_BLOCKED_BY, "removing a dependency"),
1044        (DELETE_ISSUE, "deleting an issue"),
1045        (ISSUE_COMMENTS, "reading a task's comments"),
1046        (COMMENT_ISSUE, "reading which issue a comment is on"),
1047        (ADD_COMMENT, "adding a comment"),
1048        (UPDATE_COMMENT, "editing a comment"),
1049        (DELETE_COMMENT, "deleting a comment"),
1050    ];
1051}
1052
1053/// Which of GitHub's two rate limiters refused a request.
1054///
1055/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1056/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1057/// the whole reason this is carried rather than collapsed into "rate limited".
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059enum Limiter {
1060    /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1061    Primary,
1062    /// The burst limiter over content-generating requests, which nothing reports.
1063    Secondary,
1064}
1065
1066/// The wordings GitHub answers a secondary rate limit with.
1067///
1068/// It sends them under a forbidden status, under a too-many-requests status, and inside
1069/// the `errors` of a *successful* response, which is why the text is what this matches on
1070/// rather than the status. `abuse detection` is the wording GitHub used before the
1071/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1072/// what a burst of content creation is refused with.
1073///
1074/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1075/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1076/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1077/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1078pub const SECONDARY_WORDINGS: [&str; 5] = [
1079    "secondary rate limit",
1080    "temporarily blocked from content creation",
1081    "abuse detection",
1082    "submitted too quickly",
1083    "exceeded a secondary",
1084];
1085
1086/// The wordings GitHub answers an exhausted primary budget with.
1087///
1088/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1089/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1090/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1091/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1092/// two phrases is a substring of it, so without it that answer read as a refusal that will
1093/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1094/// one reason.
1095pub const PRIMARY_WORDINGS: [&str; 4] = [
1096    "api rate limit exceeded",
1097    "api rate limit already exceeded",
1098    "rate limit exceeded",
1099    "rate_limited",
1100];
1101
1102/// What a response *says about itself*, which is the only place a refusal can be read.
1103///
1104/// Deliberately not the whole response body. A board is a place people write about their
1105/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1106/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1107/// reported. So the item data is never read: what is read is GitHub's own REST-style
1108/// `message` envelope, which is what a forbidden status carries, and the `message` and
1109/// `type` of each GraphQL error, which is where a *successful* response says it.
1110///
1111/// A body that is not JSON at all has nothing structured to read, so only a failing
1112/// response's own text is taken — a successful response that is not JSON is malformed
1113/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1114fn refusal_wording(status: StatusCode, body: &str) -> String {
1115    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1116        return if status.is_success() {
1117            String::new()
1118        } else {
1119            body.to_owned()
1120        };
1121    };
1122    let mut said: Vec<&str> = parsed
1123        .get("message")
1124        .and_then(Value::as_str)
1125        .into_iter()
1126        .collect();
1127    if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1128        for error in errors {
1129            said.extend(
1130                ["message", "type"]
1131                    .into_iter()
1132                    .filter_map(|key| error.get(key).and_then(Value::as_str)),
1133            );
1134        }
1135    }
1136    said.join("; ")
1137}
1138
1139impl Limiter {
1140    /// Which limiter refused this response, or `None` when none of them did.
1141    ///
1142    /// The wording is read first and the status only decides what carries none of it,
1143    /// because GitHub answers a secondary limit with a forbidden status far more often
1144    /// than with too-many-requests — while a forbidden status saying nothing about a limit
1145    /// really is a credential this token lacks.
1146    ///
1147    /// A response is a refusal because of its status or its own wording. A spent budget
1148    /// only ever explains one; it never turns an answer into a refusal.
1149    fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1150        let normalized = refusal_wording(status, body).to_ascii_lowercase();
1151        if SECONDARY_WORDINGS
1152            .iter()
1153            .any(|wording| normalized.contains(wording))
1154        {
1155            return Some(Self::Secondary);
1156        }
1157        if status == StatusCode::TOO_MANY_REQUESTS {
1158            return Some(Self::Primary);
1159        }
1160        // An exhausted budget *explains* a response that failed; it does not make one that
1161        // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1162        // request the budget allowed as well as on the ones it then refuses, so reading
1163        // the header alone threw away a good answer — and, once refusals were retried,
1164        // replayed a request that had already taken effect.
1165        if !status.is_success() && budget_exhausted {
1166            return Some(Self::Primary);
1167        }
1168        // A successful response saying it: GitHub reports a GraphQL rate limit in the
1169        // `errors` of an HTTP 200, where nothing about the status says so at all.
1170        if status.is_success()
1171            && PRIMARY_WORDINGS
1172                .iter()
1173                .any(|wording| normalized.contains(wording))
1174        {
1175            return Some(Self::Primary);
1176        }
1177        None
1178    }
1179
1180    /// What this limiter is called where an operator can look it up.
1181    const fn name(self) -> &'static str {
1182        match self {
1183            Self::Primary => "GitHub's primary API rate limit",
1184            Self::Secondary => "GitHub's secondary rate limit",
1185        }
1186    }
1187
1188    /// What the endpoint an operator would go and check says about this limiter.
1189    const fn where_to_look(self) -> &'static str {
1190        match self {
1191            Self::Primary => {
1192                "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1193                 comes back."
1194            }
1195            Self::Secondary => {
1196                "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1197                 primary budget and does not report this one, so budget showing there says \
1198                 nothing about this refusal, and every further attempt extends it."
1199            }
1200        }
1201    }
1202
1203    /// The next step this limiter actually calls for.
1204    const fn what_to_do(self) -> &'static str {
1205        match self {
1206            Self::Primary => {
1207                "wait for the reset `gh api rate_limit` reports, then run the command again."
1208            }
1209            Self::Secondary => {
1210                "leave this board alone for a few minutes, then run the command again — or \
1211                 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1212            }
1213        }
1214    }
1215}
1216
1217/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1218#[derive(Debug, Clone, Copy)]
1219struct Limited {
1220    limiter: Limiter,
1221    hint: Option<u64>,
1222}
1223
1224impl Limited {
1225    /// What the caller is told once this source has waited as long as it may.
1226    ///
1227    /// Both limiters report as [`SourceError::RateLimited`], because that is what
1228    /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1229    /// about *which* limiter it was makes it a different kind of failure. What differs is
1230    /// the operator's next step, and that is what the message carries — a secondary
1231    /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1232    /// budget looks fine, and then back to retry the very burst that was refused.
1233    fn exhausted(
1234        self,
1235        doing: &str,
1236        waits: u32,
1237        waited: Duration,
1238        needed: Duration,
1239        budget: Duration,
1240    ) -> SourceError {
1241        SourceError::RateLimited {
1242            retry_after_seconds: self.hint,
1243            message: Some(format!(
1244                "{} refused this source while {doing}; it waited {} out over {} and was refused \
1245                 again, and the next wait of {} would take it past the {} one call may spend \
1246                 waiting. {} next: {}",
1247                self.limiter.name(),
1248                plural(waits, "refusal"),
1249                seconds(waited),
1250                seconds(needed),
1251                seconds(budget),
1252                self.limiter.where_to_look(),
1253                self.limiter.what_to_do(),
1254            )),
1255        }
1256    }
1257}
1258
1259/// One HTTP attempt's result, with what its response said about the rate limit.
1260///
1261/// The two travel together so the record and the outcome are written from the same place:
1262/// what a response said about the budget is only readable while that response is in hand,
1263/// and what the attempt *meant* is only decidable once its body has been read.
1264struct Attempted {
1265    result: Result<Value, Attempt>,
1266    limits: accounting::RateLimit,
1267    /// GitHub's own reported cost for this call, for a document that asked for it.
1268    reported_cost: Option<u64>,
1269}
1270
1271/// One attempt's outcome: an error to report, or a rate limit to wait out.
1272enum Attempt {
1273    Failed(SourceError),
1274    Limited(Limited),
1275}
1276
1277fn plural(count: u32, thing: &str) -> String {
1278    if count == 1 {
1279        format!("{count} {thing}")
1280    } else {
1281        format!("{count} {thing}s")
1282    }
1283}
1284
1285fn seconds(duration: Duration) -> String {
1286    format!("{:.1}s", duration.as_secs_f64())
1287}
1288
1289/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1290///
1291/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1292/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1293/// header, and neither is what makes a response a refusal — so the whole cost of one this
1294/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1295/// instead. Refusing the response over the header would turn a readable refusal into an
1296/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1297fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1298    value
1299        .and_then(|value| value.to_str().ok())
1300        .and_then(|value| value.trim().parse::<u64>().ok())
1301}
1302
1303/// Every mutation this source sends creates content — an issue, a board item, a field of
1304/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1305/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1306/// and what the keyword says are the same set. That is what makes the keyword a sound test
1307/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1308/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1309fn is_mutation(query: &str) -> bool {
1310    query.trim_start().starts_with("mutation")
1311}
1312
1313/// What this source was doing, for a diagnostic that has to say so.
1314///
1315/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1316/// a document added without a description is caught by that list's own gate instead of
1317/// falling through to the vague arm below.
1318fn operation_description(query: &str) -> &'static str {
1319    graphql::DOCUMENTS
1320        .iter()
1321        .find(|(document, _)| *document == query)
1322        .map_or("talking to GitHub", |(_, doing)| *doing)
1323}
1324
1325/// GitHub's published ceiling on content-generating requests, per minute.
1326///
1327/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1328/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1329/// from it, so a pacing value checked only against itself cannot go stale here.
1330pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1331/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1332/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1333/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1334pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1335/// Shortest interval between two content-creating mutations, in milliseconds.
1336///
1337/// GitHub documents two secondary limits on content-generating requests:
1338/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1339/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1340/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1341/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1342/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1343/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1344/// deliberately *not* what this paces at. An installation that wants the hourly bound
1345/// honoured for a long sequence of copies says so through
1346/// `pacing.min_mutation_interval_ms`.
1347pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1348/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1349///
1350/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1351/// own advice for a secondary limit — wait, and wait longer each time — without spending
1352/// the first minute of a transient refusal doing nothing.
1353pub const RETRY_BACKOFF_MS: u64 = 1_000;
1354/// Total time one call may spend waiting out rate limits before it reports a failure.
1355///
1356/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1357/// short enough that a command an operator is watching returns. The bound is what makes
1358/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1359/// the limiter, not in a process nobody can tell from a wedged one.
1360pub const RETRY_BUDGET_MS: u64 = 120_000;
1361
1362fn default_token_env() -> String {
1363    "GH_PROJECTS_TOKEN".to_owned()
1364}
1365fn default_endpoint() -> String {
1366    "https://api.github.com/graphql".to_owned()
1367}
1368
1369/// Where one status category lands on this board.
1370///
1371/// `null` — an absent value — disables the category for this instance, and using a
1372/// disabled status is a refusal naming the status and the instance.
1373#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1374#[serde(untagged)]
1375pub enum StatusTargetConfig {
1376    /// The name of a `Status` single-select option already on the board.
1377    Column(ColumnName),
1378}
1379
1380/// The name of a `Status` single-select option on the board.
1381///
1382/// Validated on the way in rather than checked later, so a blank option name — which
1383/// nothing on a board can be — is a state this type cannot hold.
1384#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1385#[serde(try_from = "String")]
1386#[schemars(extend("minLength" = 1))]
1387pub struct ColumnName(String);
1388
1389impl ColumnName {
1390    /// The option name, as the board spells it.
1391    fn as_str(&self) -> &str {
1392        &self.0
1393    }
1394}
1395
1396impl TryFrom<String> for ColumnName {
1397    type Error = String;
1398
1399    fn try_from(name: String) -> Result<Self, Self::Error> {
1400        if name.trim().is_empty() {
1401            return Err("a status_mapping option name cannot be blank".to_owned());
1402        }
1403        Ok(Self(name))
1404    }
1405}
1406
1407/// The two closed states this product can mean.
1408///
1409/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1410/// work nor abandoned work, so nothing here ever writes it.
1411#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1412#[serde(rename_all = "kebab-case")]
1413pub enum ClosedState {
1414    /// `COMPLETED` — precisely done.
1415    Completed,
1416    /// `NOT_PLANNED` — precisely cancelled.
1417    NotPlanned,
1418}
1419
1420impl ClosedState {
1421    const fn reason(self) -> &'static str {
1422        match self {
1423            Self::Completed => "COMPLETED",
1424            Self::NotPlanned => "NOT_PLANNED",
1425        }
1426    }
1427}
1428
1429/// Configuration for one GitHub Projects v2 board.
1430#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1431#[serde(default, deny_unknown_fields)]
1432pub struct GitHubProjectsConfig {
1433    /// Login of the user or organization which owns the board.
1434    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1435    /// The project number shown in the board's GitHub URL.
1436    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1437    // 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.
1438    /// `owner/name` of the repository this source creates an issue in when the item's own
1439    /// `repositories` field does not decide it.
1440    ///
1441    /// An item naming exactly one repository is created there; a task or a document naming
1442    /// none or several is created in its parent project's repository; and a project, or a
1443    /// task or document with no parent, naming none or several is created here. A board
1444    /// has no repository of its own and `createIssue` requires one, so a write without
1445    /// this is refused naming the field. Reads never need it.
1446    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1447    // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1448    /// Environment variable containing a fine-grained token with Projects and Issues
1449    /// read/write plus Pull requests read-only access for every repository represented on
1450    /// the board.
1451    #[serde(default = "default_token_env")]
1452    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1453    /// GraphQL endpoint. GitHub Enterprise installations may override it.
1454    #[serde(default = "default_endpoint")]
1455    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1456    /// Per-instance mapping from a status category to where it lands on this board.
1457    ///
1458    /// A category this does not mention keeps its shipped default: `backlog` to
1459    /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1460    /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1461    /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1462    /// board option; every unknown word then lands on that option and reads back as
1463    /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1464    /// word because it never creates board options.
1465    #[serde(default)]
1466    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.
1467    /// Per-instance mapping from a task's priority to an option of this board's
1468    /// single-select field named `Priority`.
1469    ///
1470    /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1471    /// other priority is refused before it reaches this board. Present, each of `urgent`,
1472    /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1473    /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1474    /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1475    /// no two levels may name one option. Reads and writes never create the field or an
1476    /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1477    /// the board lacks is refused pointing there.
1478    #[serde(default)]
1479    pub priority_mapping: Option<PriorityMappingConfig>,
1480    /// How fast this source writes, and how long it waits out a rate-limit refusal.
1481    ///
1482    /// Every field keeps its shipped default when it is absent, and the defaults are
1483    /// GitHub's own published limits rather than taste. See [`Pacing`].
1484    #[serde(default)]
1485    pub pacing: PacingConfig,
1486}
1487
1488/// Which option of the board's `Priority` field each priority lands on.
1489///
1490/// One member per level rather than a map, so a key that is not a level is refused where
1491/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1492/// value in the field, not an option of it.
1493#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1494#[serde(default, deny_unknown_fields)]
1495pub struct PriorityMappingConfig {
1496    /// The option `urgent` lands on; `Urgent` when absent.
1497    pub urgent: Option<PriorityOptionName>,
1498    /// The option `high` lands on; `High` when absent.
1499    pub high: Option<PriorityOptionName>,
1500    /// The option `medium` lands on; `Medium` when absent.
1501    pub medium: Option<PriorityOptionName>,
1502    /// The option `low` lands on; `Low` when absent.
1503    pub low: Option<PriorityOptionName>,
1504}
1505
1506/// The name of an option of the board's `Priority` single-select field.
1507///
1508/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1509/// blank name.
1510#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1511#[serde(try_from = "String")]
1512#[schemars(extend("minLength" = 1))]
1513pub struct PriorityOptionName(String);
1514
1515impl PriorityOptionName {
1516    /// The option name, as the board spells it.
1517    fn as_str(&self) -> &str {
1518        &self.0
1519    }
1520}
1521
1522impl TryFrom<String> for PriorityOptionName {
1523    type Error = String;
1524
1525    fn try_from(name: String) -> Result<Self, Self::Error> {
1526        if name.trim().is_empty() {
1527            return Err("a priority_mapping option name cannot be blank".to_owned());
1528        }
1529        Ok(Self(name))
1530    }
1531}
1532
1533/// The name of the board field a priority is held in.
1534pub const PRIORITY_FIELD: &str = "Priority";
1535
1536/// The four priorities a board option can hold, in the order a new `Priority` field lists
1537/// them. `none` is not among them: it is the field holding no value.
1538///
1539/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1540/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1541/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1542/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1543pub const PRIORITY_LEVELS: [Priority; 4] = [
1544    Priority::Urgent,
1545    Priority::High,
1546    Priority::Medium,
1547    Priority::Low,
1548];
1549
1550/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1551/// see that list for what this pins.
1552#[must_use]
1553pub const fn level_position(priority: Priority) -> Option<usize> {
1554    match priority {
1555        Priority::None => None,
1556        Priority::Urgent => Some(0),
1557        Priority::High => Some(1),
1558        Priority::Medium => Some(2),
1559        Priority::Low => Some(3),
1560    }
1561}
1562
1563/// This instance's complete priority-to-option mapping, read in both directions.
1564///
1565/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1566/// two levels name one option.
1567#[derive(Debug, Clone)]
1568struct PriorityMapping {
1569    options: [PriorityOptionName; 4],
1570}
1571
1572impl PriorityMapping {
1573    fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1574        let shipped = |name: &str| PriorityOptionName(name.to_owned());
1575        let mapping = Self {
1576            options: [
1577                config.urgent.unwrap_or_else(|| shipped("Urgent")),
1578                config.high.unwrap_or_else(|| shipped("High")),
1579                config.medium.unwrap_or_else(|| shipped("Medium")),
1580                config.low.unwrap_or_else(|| shipped("Low")),
1581            ],
1582        };
1583        for (index, option) in mapping.options.iter().enumerate() {
1584            if let Some(earlier) = mapping.options[..index]
1585                .iter()
1586                .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1587            {
1588                return Err(SourceError::Config {
1589                    message: format!(
1590                        "priority_mapping of source {instance} sends both {} and {} to the board \
1591                         option {:?}; one option cannot read back as two priorities",
1592                        PRIORITY_LEVELS[earlier],
1593                        PRIORITY_LEVELS[index],
1594                        option.as_str()
1595                    ),
1596                });
1597            }
1598        }
1599        Ok(mapping)
1600    }
1601
1602    /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1603    fn option(&self, priority: Priority) -> Option<&str> {
1604        level_position(priority).map(|index| self.options[index].as_str())
1605    }
1606
1607    /// The priority a board option name reports, or `None` when nothing maps to it.
1608    fn priority_of(&self, option: &str) -> Option<Priority> {
1609        self.options
1610            .iter()
1611            .position(|name| name.as_str().eq_ignore_ascii_case(option))
1612            .map(|index| PRIORITY_LEVELS[index])
1613    }
1614
1615    /// Every mapped option name, in the order a new `Priority` field lists them.
1616    fn names(&self) -> impl Iterator<Item = &str> {
1617        self.options.iter().map(PriorityOptionName::as_str)
1618    }
1619}
1620
1621/// What one item's `Priority` field says, read through this instance's mapping.
1622#[derive(Debug, Clone, PartialEq, Eq)]
1623enum HeldPriority {
1624    /// A priority this source reports: an option the mapping names, or no value (`none`).
1625    Read(Priority),
1626    /// An option the mapping does not name, which is never read as a level or as `none`.
1627    Unmapped(String),
1628}
1629
1630/// How fast this source writes, and how long it waits out a rate-limit refusal.
1631///
1632/// Configurable because a GitHub Enterprise installation sets its own limits and an
1633/// operator who has already been refused may want to go slower still — not because the
1634/// defaults are guesses.
1635#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1636#[serde(default, deny_unknown_fields)]
1637pub struct PacingConfig {
1638    /// Shortest interval between two content-creating mutations, in milliseconds.
1639    ///
1640    /// Zero sends them as fast as they are asked for, which is what a fixture server on
1641    /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1642    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.
1643    /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1644    /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1645    /// zero while there is a budget to spend, because a schedule of zero-length waits
1646    /// consumes none of it and so never ends.
1647    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.
1648    /// Total time one call may spend waiting out rate limits, in milliseconds.
1649    ///
1650    /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1651    /// the bound is what makes this a wait rather than a hang.
1652    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.
1653}
1654
1655/// The largest any pacing setting may be, in milliseconds.
1656///
1657/// One hour. GitHub's own harshest published bound on content-generating requests works
1658/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1659/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1660/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1661/// and an interval beyond it is a command that never sends its second mutation. It also
1662/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1663/// what an `Instant` can hold on every platform.
1664pub const MAX_PACING_MS: u64 = 3_600_000;
1665
1666/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1667/// source holds.
1668#[derive(Debug, Clone, Copy)]
1669struct Pacing {
1670    min_mutation_interval: Duration,
1671    retry_backoff: Duration,
1672    retry_budget: Duration,
1673}
1674
1675impl Pacing {
1676    /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1677    fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1678        let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1679            Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1680                message: format!(
1681                    "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1682                     setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1683                     GitHub's own harshest published limit"
1684                ),
1685            }),
1686            Some(value) => Ok(Duration::from_millis(value)),
1687            None => Ok(Duration::from_millis(default)),
1688        };
1689        let retry_backoff = bounded(
1690            config.retry_backoff_ms,
1691            RETRY_BACKOFF_MS,
1692            "retry_backoff_ms",
1693        )?;
1694        let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1695        if retry_backoff.is_zero() && !retry_budget.is_zero() {
1696            return Err(SourceError::Config {
1697                message: format!(
1698                    "pacing.retry_backoff_ms of source {instance} is 0 while \
1699                     pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1700                     none of that budget, so it would retry a refusal forever. Set a backoff of \
1701                     at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1702                     waiting at all",
1703                    retry_budget.as_millis()
1704                ),
1705            });
1706        }
1707        Ok(Self {
1708            min_mutation_interval: bounded(
1709                config.min_mutation_interval_ms,
1710                MIN_MUTATION_INTERVAL_MS,
1711                "min_mutation_interval_ms",
1712            )?,
1713            retry_backoff,
1714            retry_budget,
1715        })
1716    }
1717}
1718
1719/// Factory for [`GitHubProjectsSource`].
1720#[derive(Debug, Clone, Copy, Default)]
1721pub struct Plugin;
1722
1723impl SourcePlugin for Plugin {
1724    fn kind(&self) -> &'static str {
1725        KIND
1726    }
1727    fn config_schema(&self) -> Schema {
1728        schema_for!(GitHubProjectsConfig)
1729    }
1730    fn build(
1731        &self,
1732        name: &SourceName,
1733        config: &Value,
1734        secrets: &dyn SecretResolver,
1735    ) -> Result<Box<dyn TaskSource>, SourceError> {
1736        self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1737    }
1738}
1739
1740impl Plugin {
1741    /// Build a source recording every request it sends into an accounting the caller holds.
1742    ///
1743    /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
1744    /// registry gets. This is for a caller that is also calling GitHub itself and wants one
1745    /// session total rather than two — see [`accounting`] and
1746    /// [`GitHubProjectsSource::recording_into`].
1747    ///
1748    /// # Errors
1749    ///
1750    /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
1751    /// [`SourceError::Config`] for configuration this plugin cannot use and
1752    /// [`SourceError::Auth`] for a credential it cannot find.
1753    pub fn build_recording_into(
1754        &self,
1755        name: &SourceName,
1756        config: &Value,
1757        secrets: &dyn SecretResolver,
1758        ledger: Arc<Accounting>,
1759    ) -> Result<Box<dyn TaskSource>, SourceError> {
1760        let config: GitHubProjectsConfig =
1761            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1762                message: format!("source {name}: {e}"),
1763            })?;
1764        let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
1765            |error| match error {
1766                SourceError::Config { message } => SourceError::Config {
1767                    message: format!("source {name}: {message}"),
1768                },
1769                SourceError::Auth { message } => SourceError::Auth {
1770                    message: format!("source {name}: {message}"),
1771                },
1772                other => other,
1773            },
1774        )?;
1775        Ok(Box::new(source))
1776    }
1777}
1778
1779/// Where a status category lands on this board, once configuration is resolved.
1780#[derive(Debug, Clone, PartialEq, Eq)]
1781enum StatusTarget {
1782    /// Not usable against this instance.
1783    Disabled,
1784    /// The board's `Status` option of this name.
1785    Column(ColumnName),
1786    /// A closed issue, with both its board option and the reason that says which closed it means.
1787    // 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.
1788    Terminal(ColumnName, ClosedState),
1789}
1790
1791/// Every status category, in the order the vocabulary declares them.
1792///
1793/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
1794/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
1795/// added to the shared vocabulary fails to compile until it is named there, and this
1796/// crate's suite reconciles this list against that enum's own derived schema, which is
1797/// generated from the variants rather than written beside them. The schema is what
1798/// catches a list left one short — a list checking only the positions it already holds
1799/// would pass while every mapping indexed by the new position panicked.
1800pub const CATEGORIES: [StatusCategory; 8] = [
1801    StatusCategory::Draft,
1802    StatusCategory::Backlog,
1803    StatusCategory::Todo,
1804    StatusCategory::Queued,
1805    StatusCategory::InProgress,
1806    StatusCategory::Done,
1807    StatusCategory::Cancelled,
1808    StatusCategory::Unknown,
1809];
1810
1811/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
1812#[must_use]
1813pub const fn category_position(category: StatusCategory) -> usize {
1814    match category {
1815        StatusCategory::Draft => 0,
1816        StatusCategory::Backlog => 1,
1817        StatusCategory::Todo => 2,
1818        StatusCategory::Queued => 3,
1819        StatusCategory::InProgress => 4,
1820        StatusCategory::Done => 5,
1821        StatusCategory::Cancelled => 6,
1822        StatusCategory::Unknown => 7,
1823    }
1824}
1825
1826/// The spelling a status category is configured and reported under.
1827fn category_name(category: StatusCategory) -> &'static str {
1828    match category {
1829        StatusCategory::Draft => "draft",
1830        StatusCategory::Backlog => "backlog",
1831        StatusCategory::Todo => "todo",
1832        StatusCategory::Queued => "queued",
1833        StatusCategory::InProgress => "in-progress",
1834        StatusCategory::Done => "done",
1835        StatusCategory::Cancelled => "cancelled",
1836        StatusCategory::Unknown => "unknown",
1837    }
1838}
1839
1840/// A shipped default's option name.
1841///
1842/// The literals below are this file's own and non-blank, and they are validated by the
1843/// one constructor a configured name goes through rather than beside it.
1844fn shipped_column(name: &'static str) -> ColumnName {
1845    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
1846}
1847
1848/// The shipped default for one category, before this instance's configuration.
1849fn shipped_default(category: StatusCategory) -> StatusTarget {
1850    match category {
1851        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
1852        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
1853        StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
1854        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
1855        StatusCategory::Done => {
1856            StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
1857        }
1858        StatusCategory::Cancelled => {
1859            StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
1860        }
1861        StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
1862    }
1863}
1864
1865/// This instance's complete category-to-target mapping, read in both directions.
1866///
1867/// One target per category, held at that category's own [`category_position`], so a
1868/// category missing from the mapping, named twice in it, or filed out of order is a
1869/// state this type cannot hold rather than one [`Self::target`] has to defend against.
1870#[derive(Debug, Clone)]
1871struct StatusMapping {
1872    targets: [StatusTarget; CATEGORIES.len()],
1873}
1874
1875impl StatusMapping {
1876    fn resolve(
1877        configured: BTreeMap<String, Option<StatusTargetConfig>>,
1878        instance: &SourceName,
1879    ) -> Result<Self, SourceError> {
1880        let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
1881        for (key, value) in configured {
1882            let category = CATEGORIES
1883                .iter()
1884                .find(|category| category_name(**category) == key)
1885                .ok_or_else(|| SourceError::Config {
1886                    message: format!(
1887                        "status_mapping names {key:?}, which is not a status category of source \
1888                         {instance}; the categories are {}",
1889                        CATEGORIES
1890                            .iter()
1891                            .map(|category| category_name(*category))
1892                            .collect::<Vec<_>>()
1893                            .join(", ")
1894                    ),
1895                })?;
1896            overrides.insert(category_name(*category), value);
1897        }
1898        // `CATEGORIES[position] == category` for every category — the crate's suite
1899        // asserts it — so mapping the list in order fills each category's own slot.
1900        let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
1901            None => shipped_default(category),
1902            Some(None) => StatusTarget::Disabled,
1903            Some(Some(StatusTargetConfig::Column(option))) => match category {
1904                StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
1905                StatusCategory::Cancelled => {
1906                    StatusTarget::Terminal(option, ClosedState::NotPlanned)
1907                }
1908                _ => StatusTarget::Column(option),
1909            },
1910        });
1911        let mapping = Self { targets };
1912        for (index, category) in CATEGORIES.into_iter().enumerate() {
1913            let option = match mapping.target(category) {
1914                StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
1915                StatusTarget::Disabled => continue,
1916            };
1917            if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
1918                matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1919                    if name.as_str().eq_ignore_ascii_case(option.as_str()))
1920            }) {
1921                return Err(SourceError::Config {
1922                    message: format!(
1923                        "status_mapping of source {instance} sends both {} and {} to the board \
1924                         option {:?}; one option cannot read back as two categories",
1925                        category_name(*other),
1926                        category_name(category),
1927                        option.as_str()
1928                    ),
1929                });
1930            }
1931        }
1932        Ok(mapping)
1933    }
1934
1935    fn target(&self, category: StatusCategory) -> &StatusTarget {
1936        &self.targets[category_position(category)]
1937    }
1938
1939    /// The category a board option name reports, or `None` when nothing maps to it.
1940    fn category_of(&self, option: &str) -> Option<StatusCategory> {
1941        CATEGORIES.into_iter().find(|category| {
1942            matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1943                if name.as_str().eq_ignore_ascii_case(option))
1944        })
1945    }
1946
1947    /// The status an item reports, from the three things a read of it says: its board
1948    /// `Status` option, whether its issue is closed, and the reason it was closed with.
1949    ///
1950    /// The closed state decides the category and the `Status` option decides the name, so
1951    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
1952    /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
1953    /// duplicate is not finished work, and calling it done is a lie the next copy would
1954    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
1955    /// it is read permissively rather than refused — reads are faithful, and refusals
1956    /// belong on writes.
1957    ///
1958    /// One function of those three rather than of a response, so a narrow status write can
1959    /// answer what a re-read would report by applying it to the state it has just written.
1960    fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
1961        if closed {
1962            let category = match reason {
1963                None | Some("COMPLETED") => StatusCategory::Done,
1964                Some("NOT_PLANNED") => StatusCategory::Cancelled,
1965                Some(_) => StatusCategory::Unknown,
1966            };
1967            let fallback = match category {
1968                StatusCategory::Done => "Done",
1969                StatusCategory::Cancelled => "Cancelled",
1970                _ => "Closed",
1971            };
1972            return Status {
1973                category,
1974                name: option.unwrap_or(fallback).to_owned(),
1975            };
1976        }
1977        let name = option.unwrap_or("Open").to_owned();
1978        Status {
1979            category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
1980            name,
1981        }
1982    }
1983}
1984
1985// 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.
1986/// One repository this source can create an issue in, as `owner/name`.
1987///
1988/// Every `createIssue` this source sends names one of these: the item's own single
1989/// `repositories` entry, else its parent project issue's repository, else the configured
1990/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
1991/// that choice and says what it refuses before `createIssue`.
1992// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
1993#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1994struct RepositoryTarget {
1995    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1996    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1997}
1998
1999impl RepositoryTarget {
2000    fn parse(value: &str) -> Result<Self, SourceError> {
2001        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2002            message: format!(
2003                "repository must be spelled owner/name; {value:?} names no repository"
2004            ),
2005        })?;
2006        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2007            return Err(SourceError::Config {
2008                message: format!(
2009                    "repository must be spelled owner/name with a GitHub login and one \
2010                     repository name; {value:?} is not"
2011                ),
2012            });
2013        }
2014        Ok(Self {
2015            owner: owner.to_owned(),
2016            name: name.to_owned(),
2017        })
2018    }
2019
2020    /// The one host whose repositories this source creates issues in, spelled once: it is
2021    /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2022    const HOST: &str = "github.com";
2023
2024    fn origin(&self) -> String {
2025        format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2026    }
2027
2028    /// The repository a normalized origin names, or why it is none this source can create
2029    /// an issue in: another host, or more or fewer than `owner/name` under this one.
2030    fn from_origin(origin: &Repository) -> Result<Self, String> {
2031        let not_here = || {
2032            format!(
2033                "{} is not a {}/owner/name repository",
2034                origin.as_str(),
2035                Self::HOST
2036            )
2037        };
2038        let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2039        if host != Self::HOST {
2040            return Err(not_here());
2041        }
2042        Self::parse(rest).map_err(|_| not_here())
2043    }
2044
2045    fn slug(&self) -> String {
2046        format!("{}/{}", self.owner, self.name)
2047    }
2048}
2049
2050/// A source which reads GitHub afresh for every operation.
2051pub struct GitHubProjectsSource {
2052    /// This source's configured name, used both to tell a far end naming this source
2053    /// from one naming a system it knows nothing about, and to name the instance a
2054    /// status refusal is about.
2055    name: SourceName,
2056    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2057    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2058    repository: Option<RepositoryTarget>,
2059    endpoint: Url,
2060    token: SecretString,
2061    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2062    statuses: StatusMapping,
2063    /// Where each priority lands on this board, or `None` when this instance holds none.
2064    priorities: Option<PriorityMapping>,
2065    client: Client,
2066    /// Every item this source has created since it was built, in the order it created
2067    /// them.
2068    ///
2069    /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2070    /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2071    /// a copy resolving a dependency on an item it had just created refused it as not
2072    /// found. A board read is completed from this — an item remembered here and absent from
2073    /// the read is added back, because the board really does hold it and only the read is
2074    /// behind.
2075    ///
2076    /// It is not a cache of a user's work: nothing is remembered that this process did not
2077    /// itself just write, it lives and dies with the process, and it is never consulted for
2078    /// an item this source did not create.
2079    created: Mutex<Vec<Resolved>>,
2080    /// Every item that already existed and that this source has written since it was built,
2081    /// as it wrote it.
2082    ///
2083    /// The other half of [`Self::created`], held on the same terms and for the reason a
2084    /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2085    /// filter is an index behind a write this process made moments ago, so a query matching
2086    /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2087    /// is remembered that this process did not itself just write.
2088    updated: Mutex<Vec<Resolved>>,
2089    /// How fast this source writes, and how long it waits out a refusal.
2090    pacing: Pacing,
2091    /// When the last content-creating mutation finished, or the moment the furthest-out
2092    /// reserved slot releases the next one, whichever is later — so the one after it can be
2093    /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2094    /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2095    /// what it is measured from.
2096    last_mutation: Mutex<Option<Instant>>,
2097    /// The board as this process last read it, for the length of one command.
2098    ///
2099    /// A copy of a project used to re-read the whole board, paged, before writing each of
2100    /// its items, which is by far the largest part of a copy's request count and none of
2101    /// its work. Nothing else changes this board while a command runs — this source's own
2102    /// writes are the only writer — so one read answers them all.
2103    ///
2104    /// It is not a store of a user's work and it is not the cache the no-persistence
2105    /// invariant forbids: it lives and dies with the process exactly as `created` does,
2106    /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2107    /// an item this command created and then depends on resolves whether or not GitHub's
2108    /// own eventually-consistent read has caught up. A write to an item already on the
2109    /// board updates the entry here too, so what this holds is the last read plus this
2110    /// process's own writes rather than a snapshot taken before them.
2111    board_cache: Mutex<Option<Board>>,
2112    /// Every issue this board's own search reported, for the length of one command.
2113    ///
2114    /// The second half of a board read, and cached for the same reason and on the same
2115    /// terms as the first: it lives and dies with the process, nothing is written down, and
2116    /// a write this process makes updates the entry here exactly as it updates the one in
2117    /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2118    /// that lists this board's projects and its tasks pays for one search rather than two.
2119    search_cache: Mutex<Option<Vec<Resolved>>>,
2120    /// What each narrowed question GitHub was asked answered, keyed by that question, for
2121    /// the length of one command.
2122    ///
2123    /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2124    /// and dies with the process, nothing is written down, a write this process makes
2125    /// updates the entry here as it updates the other two, and every answer is completed
2126    /// with this process's own writes each time it is given. A command that asks the same
2127    /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2128    /// write — pays for it once, which is what the whole-board read it replaced gave it.
2129    narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2130    /// The board's own id and field definitions as this process last read them on their
2131    /// own, for the length of one command.
2132    ///
2133    /// What a write needs of the board and its item does not say, read once per command
2134    /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2135    /// lives and dies with the process and nothing is written down. It holds no item and so
2136    /// can answer no question about one — see [`Self::board_fields`].
2137    fields_cache: Mutex<Option<BoardFields>>,
2138    /// Each destination repository's node id, resolved once per repository
2139    /// rather than per issue created.
2140    ///
2141    /// A repository's node id does not change, and re-reading it for every issue of a copy
2142    /// spent one request per item on an answer this source already had. It is a map rather
2143    /// than one entry because a copy files each item in the repository its own
2144    /// `repositories` field names, so a plan across five repositories asks GitHub five
2145    /// times and not once per item.
2146    repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2147    /// What every request this source sends is recorded into.
2148    ///
2149    /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2150    /// a request leaves this crate, so nothing has to be switched on for a session to be
2151    /// counted. It is shared rather than owned so a caller accounting for a whole session —
2152    /// its own schema verification, board lookups, residue sweep and cleanup beside this
2153    /// source's reads and writes — adds up one accounting instead of two. See
2154    /// [`accounting`] for what a record carries and what a session's spend is and is not.
2155    ledger: Arc<Accounting>,
2156}
2157
2158/// GitHub's closed single-select color vocabulary.
2159#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2160#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2161pub enum StatusOptionColor {
2162    /// Gray.
2163    Gray,
2164    /// Blue.
2165    Blue,
2166    /// Green.
2167    Green,
2168    /// Yellow.
2169    Yellow,
2170    /// Purple.
2171    Purple,
2172    /// Red.
2173    Red,
2174    /// Orange.
2175    Orange,
2176    /// Pink.
2177    Pink,
2178}
2179
2180/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2181/// applies its additions.
2182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2183pub enum SetupMode {
2184    /// Read without mutation.
2185    Plan,
2186    /// Apply and verify.
2187    Apply,
2188}
2189
2190/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2191/// against it goes on compiling.
2192pub type StatusOptionsMode = SetupMode;
2193
2194/// The explicit result of the requested operation.
2195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2196#[serde(rename_all = "kebab-case")]
2197pub enum StatusOptionsOutcome {
2198    /// A read-only plan.
2199    Planned,
2200    /// Apply found nothing missing.
2201    Unchanged,
2202    /// Additions were applied and verified.
2203    Applied,
2204}
2205
2206/// A GitHub single-select option's opaque GraphQL node identifier.
2207#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2208#[serde(transparent)]
2209pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2210
2211impl TryFrom<String> for StatusOptionId {
2212    type Error = String;
2213
2214    fn try_from(id: String) -> Result<Self, Self::Error> {
2215        if id.trim().is_empty() {
2216            return Err("a GitHub Status option id cannot be blank".to_owned());
2217        }
2218        Ok(Self(id))
2219    }
2220}
2221
2222/// One existing or proposed option in a guarded Status-field update.
2223#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2224pub struct StatusOption {
2225    /// GitHub's stable id.
2226    pub id: StatusOptionId,
2227    /// The visible option name.
2228    pub name: ColumnName,
2229    /// GitHub's single-select color token.
2230    pub color: StatusOptionColor,
2231    /// The option description, including an empty one.
2232    pub description: String,
2233}
2234
2235/// One board item's Status assignment, retained as recovery data.
2236#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2237pub struct StatusAssignment {
2238    /// The project item id whose assignment this is.
2239    // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2240    // carried verbatim as operator recovery data; introducing a semantic type would claim
2241    // validation rules GitHub does not publish and no operation here interprets.
2242    pub item_id: String,
2243    /// The selected option, absent when the item has no status.
2244    #[serde(skip_serializing_if = "Option::is_none")]
2245    pub option: Option<AssignedStatusOption>,
2246}
2247
2248/// The inseparable id and name of an assigned option.
2249#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2250pub struct AssignedStatusOption {
2251    /// GitHub's stable id.
2252    pub id: StatusOptionId,
2253    /// The visible name.
2254    pub name: ColumnName,
2255}
2256
2257/// The plan and verified outcome of reconciling configured Status options.
2258#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2259pub struct StatusOptionsReport {
2260    /// The configured source name.
2261    pub source: SourceName,
2262    /// Configured option names absent before the operation.
2263    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2264    // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2265    // serialized string here preserves the report's intentionally simple public contract.
2266    pub missing: Vec<String>,
2267    /// What the requested operation did.
2268    pub outcome: StatusOptionsOutcome,
2269    /// The complete option list observed before any mutation.
2270    pub existing: Vec<StatusOption>,
2271}
2272
2273#[derive(Debug, Clone, PartialEq, Eq)]
2274struct StatusSnapshot {
2275    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2276    // passed back as the mutation's project identity; a newtype could enforce no stronger
2277    // invariant because GitHub publishes no grammar for it.
2278    board_id: String,
2279    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2280    // passed back as the mutation's field identity; a newtype could enforce no stronger
2281    // invariant because GitHub publishes no grammar for it.
2282    field_id: String,
2283    options: Vec<StatusOption>,
2284    assignments: Vec<StatusAssignment>,
2285}
2286
2287/// The name of the board field a status is held in.
2288const STATUS_FIELD: &str = "Status";
2289
2290/// Every item's value of each field `report` names, as it stood before the setup wrote
2291/// anything — what a person puts back when the setup is refused part way.
2292fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2293    let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2294        .fields
2295        .iter()
2296        .map(|field| (field.field.name(), before.assignments(field.field)))
2297        .collect();
2298    serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2299        message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2300    })
2301}
2302
2303/// One board field the guarded setup reads and writes — every one it reads, and the only
2304/// ones it writes.
2305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2306pub enum BoardField {
2307    /// The single-select `Status` field every instance's `status_mapping` resolves into.
2308    Status,
2309    /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2310    Priority,
2311}
2312
2313impl BoardField {
2314    /// The field's name on the board.
2315    #[must_use]
2316    pub const fn name(self) -> &'static str {
2317        match self {
2318            Self::Status => STATUS_FIELD,
2319            Self::Priority => PRIORITY_FIELD,
2320        }
2321    }
2322
2323    /// The field a board calls `name`, or `None` for one this setup does not own.
2324    fn named(name: &str) -> Option<Self> {
2325        [Self::Status, Self::Priority]
2326            .into_iter()
2327            .find(|field| field.name() == name)
2328    }
2329}
2330
2331/// What the guarded setup did to one field.
2332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2333#[serde(rename_all = "kebab-case")]
2334pub enum FieldOutcome {
2335    /// A read-only plan.
2336    Planned,
2337    /// Apply found the field there with every configured option.
2338    Unchanged,
2339    /// Missing options were added to the field that was there, and verified.
2340    Applied,
2341    /// The field was not there; it was created holding the configured options, and verified.
2342    Created,
2343}
2344
2345/// One field's plan, or its verified outcome.
2346#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2347pub struct FieldReport {
2348    /// Which field.
2349    pub field: BoardField,
2350    /// Whether the board had the field before the operation.
2351    // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2352    // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2353    // "outcome", "existing"}` — so folding one into the other would change a published JSON
2354    // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2355    // one constructor, and it derives `outcome` from `exists` in one match.
2356    pub exists: bool,
2357    /// Configured option names the field lacked before the operation — every one of them,
2358    /// in the order a new field lists them, when the field was not there at all.
2359    // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2360    // mapping name and has therefore already passed its nonblank validation; the serialized
2361    // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2362    pub missing: Vec<String>,
2363    /// What the requested operation did.
2364    pub outcome: FieldOutcome,
2365    /// The field's complete option list observed before any mutation; empty when the field
2366    /// was not there.
2367    pub existing: Vec<StatusOption>,
2368}
2369
2370/// The plan and verified outcome of setting up every field a source's configuration names.
2371#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2372pub struct FieldsReport {
2373    /// The configured source name.
2374    pub source: SourceName,
2375    /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2376    // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2377    // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2378    // per field would change a published JSON shape. The states the list could hold and the
2379    // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2380    // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2381    pub fields: Vec<FieldReport>,
2382}
2383
2384/// Which options one field is configured with, in the order a new field would list them.
2385struct FieldPlan {
2386    field: BoardField,
2387    wanted: Vec<String>,
2388}
2389
2390/// One single-select field as the guarded setup snapshots it.
2391#[derive(Debug, Clone, PartialEq, Eq)]
2392struct SnapshotField {
2393    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2394    // passed back as the mutation's field identity; a newtype could enforce no stronger
2395    // invariant because GitHub publishes no grammar for it.
2396    field_id: String,
2397    options: Vec<StatusOption>,
2398}
2399
2400/// Every single-select field of a board and every item's value of each.
2401#[derive(Debug, Clone, PartialEq, Eq)]
2402struct BoardSnapshot {
2403    // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2404    // passed back as the mutation's project identity; a newtype could enforce no stronger
2405    // invariant because GitHub publishes no grammar for it.
2406    board_id: String,
2407    fields: BTreeMap<BoardField, SnapshotField>,
2408    /// Each board item's id, and its value of each field this setup owns that it holds one of.
2409    items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2410}
2411
2412impl BoardSnapshot {
2413    /// Every item's value of `field`, in board order — the recovery data a drift refusal
2414    /// carries.
2415    fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2416        self.items
2417            .iter()
2418            .map(|(item_id, values)| StatusAssignment {
2419                item_id: item_id.clone(),
2420                option: values.get(&field).cloned(),
2421            })
2422            .collect()
2423    }
2424}
2425
2426impl GitHubProjectsSource {
2427    /// Report missing configured Status options and, when `apply` is true, add them with
2428    /// a whole-list mutation that preserves every existing id and verifies the result.
2429    ///
2430    /// # Errors
2431    ///
2432    /// Refuses a board without a single-select `Status` field. A post-write difference in
2433    /// any pre-existing option id or item assignment is refused with the complete pre-write
2434    /// assignment snapshot in the diagnostic for recovery.
2435    // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2436    // successful mutation, both drift refusals, source selection, missing Status, casing,
2437    // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2438    // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2439    // responses from entering the defensive malformed-response branches below.
2440    pub async fn status_options(
2441        &self,
2442        mode: StatusOptionsMode,
2443    ) -> Result<StatusOptionsReport, SourceError> {
2444        let before = self.status_snapshot().await?;
2445        let configured = self
2446            .statuses
2447            .targets
2448            .iter()
2449            // A terminal category's option is as configured as an open one's: a terminal
2450            // write validates it before closing and refuses when the board lacks it.
2451            .filter_map(|target| match target {
2452                StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2453                    Some(name.as_str().to_owned())
2454                }
2455                StatusTarget::Disabled => None,
2456            });
2457        let missing = configured
2458            .filter(|wanted| {
2459                !before
2460                    .options
2461                    .iter()
2462                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2463            })
2464            .collect::<Vec<_>>();
2465        let report = StatusOptionsReport {
2466            source: self.name.clone(),
2467            missing: missing.clone(),
2468            outcome: match (mode, missing.is_empty()) {
2469                (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2470                (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2471                (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2472            },
2473            existing: before.options.clone(),
2474        };
2475        if mode == StatusOptionsMode::Plan || missing.is_empty() {
2476            return Ok(report);
2477        }
2478        let mut options = before
2479            .options
2480            .iter()
2481            .map(|option| {
2482                json!({
2483                    "id": option.id, "name": option.name, "color": option.color,
2484                    "description": option.description,
2485                })
2486            })
2487            .collect::<Vec<_>>();
2488        options.extend(missing.iter().map(|name| {
2489            json!({
2490                "name": name, "color": "GRAY", "description": ""
2491            })
2492        }));
2493        self.graphql(
2494            graphql::STATUS_OPTIONS_UPDATE,
2495            json!({"input": {
2496                "projectId": before.board_id, "fieldId": before.field_id,
2497                "singleSelectOptions": options,
2498            }}),
2499        )
2500        .await?;
2501        let after = self.status_snapshot().await?;
2502        let options_preserved = before
2503            .options
2504            .iter()
2505            .all(|old| after.options.iter().any(|new| new == old));
2506        let additions_present = missing.iter().all(|wanted| {
2507            after
2508                .options
2509                .iter()
2510                .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2511        });
2512        if !options_preserved || !additions_present || after.assignments != before.assignments {
2513            let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2514                SourceError::Malformed {
2515                    message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2516                }
2517            })?;
2518            return Err(SourceError::Refused {
2519                message: format!(
2520                    "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}"
2521                ),
2522            });
2523        }
2524        Ok(report)
2525    }
2526
2527    /// A fresh snapshot of the Status field and every board item's assignment of it.
2528    ///
2529    /// # Errors
2530    ///
2531    /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2532    async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2533        // Status alone, as this operation has always read it: a `Priority` field is another
2534        // operation's, so nothing about it can refuse this one.
2535        let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2536        let field = board
2537            .fields
2538            .remove(&BoardField::Status)
2539            .ok_or_else(|| self.no_status_field())?;
2540        Ok(StatusSnapshot {
2541            assignments: board.assignments(BoardField::Status),
2542            board_id: board.board_id,
2543            field_id: field.field_id,
2544            options: field.options,
2545        })
2546    }
2547
2548    /// The refusal a board with no `Status` field is answered with by the guarded setup.
2549    fn no_status_field(&self) -> SourceError {
2550        SourceError::Refused {
2551            message: format!("source {} board has no Status field", self.name),
2552        }
2553    }
2554
2555    // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2556    // the real CLI loopback journey, including pagination. The individual malformed guards
2557    // are defensive validation of a schema-pinned third-party response, not separate user
2558    // journeys; drift and missing-field failures cover the operation's recovery behavior.
2559    /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2560    /// every board item's value of each, walked to the end of the board's items. A field not
2561    /// in `owned` is read past whatever it holds.
2562    async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2563        let mut after: Option<String> = None;
2564        let mut snapshot: Option<BoardSnapshot> = None;
2565        loop {
2566            let data = self
2567                .graphql(
2568                    graphql::STATUS_OPTIONS_SNAPSHOT,
2569                    json!({
2570                        "owner": self.owner, "number": self.project_number,
2571                        "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2572                    }),
2573                )
2574                .await?;
2575            let board = data
2576                .pointer("/owner/projectV2")
2577                .filter(|board| board.is_object())
2578                .ok_or_else(|| SourceError::Refused {
2579                    message: format!(
2580                        "source {} has no accessible GitHub Projects board",
2581                        self.name
2582                    ),
2583                })?;
2584            if board
2585                .pointer("/fields/pageInfo/hasNextPage")
2586                .and_then(Value::as_bool)
2587                != Some(false)
2588            {
2589                return Err(SourceError::Malformed {
2590                    message:
2591                        "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2592                            .into(),
2593                });
2594            }
2595            let mut fields = BTreeMap::new();
2596            // Only the fields this setup owns, by name: a node the single-select fragment did not
2597            // match carries no name, and a person's own single-select field — a `Size`, a
2598            // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2599            // `Status` or `Priority` field without its options is malformed, not absent.
2600            // 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.
2601            for (owned, field) in board
2602                .pointer("/fields/nodes")
2603                .and_then(Value::as_array)
2604                .ok_or_else(|| SourceError::Malformed {
2605                    message: "GitHub project fields.nodes is not an array".into(),
2606                })?
2607                .iter()
2608                .filter_map(|field| {
2609                    let named = BoardField::named(field.get("name")?.as_str()?)?;
2610                    owned.contains(&named).then_some((named, field))
2611                })
2612            {
2613                let options = field
2614                    .get("options")
2615                    .and_then(Value::as_array)
2616                    .ok_or_else(|| SourceError::Malformed {
2617                        message: "GitHub single-select field options is not an array".into(),
2618                    })?
2619                    .iter()
2620                    .map(|option| {
2621                        Ok(StatusOption {
2622                            id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2623                                .map_err(|message| SourceError::Malformed { message })?,
2624                            name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2625                                .map_err(|message| SourceError::Malformed {
2626                                    message: format!(
2627                                        "GitHub single-select option name is invalid: {message}"
2628                                    ),
2629                                })?,
2630                            color: serde_json::from_value(
2631                                option.get("color").cloned().unwrap_or(Value::Null),
2632                            )
2633                            .map_err(|error| {
2634                                SourceError::Malformed {
2635                                    message: format!(
2636                                        "GitHub single-select option color is invalid: {error}"
2637                                    ),
2638                                }
2639                            })?,
2640                            description: optional_str(option, "description")?
2641                                .unwrap_or_default()
2642                                .to_owned(),
2643                        })
2644                    })
2645                    .collect::<Result<Vec<_>, SourceError>>()?;
2646                let snapshot = SnapshotField {
2647                    field_id: required_nonblank_str(field, "id")?.to_owned(),
2648                    options,
2649                };
2650                // A board's field names are unique, so a second one is an answer that cannot
2651                // say which field the setup would act on — refused rather than one chosen.
2652                if fields.insert(owned, snapshot).is_some() {
2653                    return Err(SourceError::Malformed {
2654                        message: format!(
2655                            "GitHub answered two {} fields for this board",
2656                            owned.name()
2657                        ),
2658                    });
2659                }
2660            }
2661            let board_id = required_nonblank_str(board, "id")?.to_owned();
2662            let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2663                board_id,
2664                fields,
2665                items: Vec::new(),
2666            });
2667            let items = board
2668                .pointer("/items/nodes")
2669                .and_then(Value::as_array)
2670                .ok_or_else(|| SourceError::Malformed {
2671                    message: "GitHub project items.nodes is not an array".into(),
2672                })?;
2673            for item in items {
2674                let field_values =
2675                    item.get("fieldValues")
2676                        .ok_or_else(|| SourceError::Malformed {
2677                            message: "GitHub project item is missing fieldValues".into(),
2678                        })?;
2679                if field_values
2680                    .pointer("/pageInfo/hasNextPage")
2681                    .and_then(Value::as_bool)
2682                    != Some(false)
2683                {
2684                    return Err(SourceError::Malformed {
2685                        message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2686                    });
2687                }
2688                let values = item
2689                    .pointer("/fieldValues/nodes")
2690                    .and_then(Value::as_array)
2691                    .ok_or_else(|| SourceError::Malformed {
2692                        message: "GitHub project item fieldValues.nodes is not an array".into(),
2693                    })?;
2694                let item_id = required_nonblank_str(item, "id")?;
2695                let mut assigned = BTreeMap::new();
2696                for value in values {
2697                    let Some(field) = value
2698                        .pointer("/field/name")
2699                        .and_then(Value::as_str)
2700                        .and_then(BoardField::named)
2701                        .filter(|field| owned.contains(field))
2702                    else {
2703                        continue;
2704                    };
2705                    let held = assigned.insert(
2706                        field,
2707                        AssignedStatusOption {
2708                            id: StatusOptionId::try_from(
2709                                required_str(value, "optionId")?.to_owned(),
2710                            )
2711                            .map_err(|message| SourceError::Malformed { message })?,
2712                            name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2713                                .map_err(|message| SourceError::Malformed {
2714                                    message: format!(
2715                                        "GitHub assigned {} name is invalid: {message}",
2716                                        field.name()
2717                                    ),
2718                                })?,
2719                        },
2720                    );
2721                    // An item holds one value of a field, so a second one leaves no way to
2722                    // tell which it holds — and a verification or recovery built on either
2723                    // could restore the wrong one.
2724                    if held.is_some() {
2725                        return Err(SourceError::Malformed {
2726                            message: format!(
2727                                "GitHub answered two {} values for board item {item_id}",
2728                                field.name()
2729                            ),
2730                        });
2731                    }
2732                }
2733                current.items.push((item_id.to_owned(), assigned));
2734            }
2735            let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2736                message: "GitHub project is missing items".into(),
2737            })?;
2738            let has_next = page
2739                .pointer("/pageInfo/hasNextPage")
2740                .and_then(Value::as_bool)
2741                .ok_or_else(|| SourceError::Malformed {
2742                    message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
2743                })?;
2744            if !has_next {
2745                break;
2746            }
2747            let next =
2748                required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
2749            validate_cursor_progress(after.as_deref(), next)?;
2750            after = Some(next.to_owned());
2751        }
2752        snapshot.ok_or_else(|| SourceError::Malformed {
2753            message: "GitHub returned no board field snapshot".into(),
2754        })
2755    }
2756    // llmlint: ignore-end[changed_behavior_has_e2e]
2757
2758    /// Report every board field this source's configuration names and, with
2759    /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
2760    /// the `Priority` field when the board has none.
2761    ///
2762    /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
2763    /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
2764    /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
2765    /// color and description: the whole option list goes back with every existing id, because
2766    /// a re-minted id clears every item's value.
2767    ///
2768    /// # Errors
2769    ///
2770    /// Refuses a board without a single-select `Status` field. After an apply the board is
2771    /// read again, and a pre-existing option or any item's value of either field that moved is
2772    /// refused with the complete pre-write assignments in the diagnostic, for recovery.
2773    // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
2774    // unchanged apply, a created field, an added option to each field, drift refusal, a board
2775    // with no Status field and a non-github-projects source through the compiled CLI against
2776    // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
2777    pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
2778        let owned: Vec<BoardField> = if self.priorities.is_some() {
2779            vec![BoardField::Status, BoardField::Priority]
2780        } else {
2781            vec![BoardField::Status]
2782        };
2783        let before = self.board_snapshot(&owned).await?;
2784        let mut plans = vec![FieldPlan {
2785            field: BoardField::Status,
2786            wanted: self
2787                .statuses
2788                .targets
2789                .iter()
2790                .filter_map(|target| match target {
2791                    StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2792                        Some(name.as_str().to_owned())
2793                    }
2794                    StatusTarget::Disabled => None,
2795                })
2796                .collect(),
2797        }];
2798        if !before.fields.contains_key(&BoardField::Status) {
2799            return Err(self.no_status_field());
2800        }
2801        if let Some(mapping) = &self.priorities {
2802            plans.push(FieldPlan {
2803                field: BoardField::Priority,
2804                wanted: mapping.names().map(str::to_owned).collect(),
2805            });
2806        }
2807        // The snapshot reads single-select fields alone, so a field it did not find may still
2808        // be on the board under the name, of another type: creating one beside it would fail
2809        // part way, or leave two fields of one name. Asked of the board's own field list, and
2810        // only when a field is missing.
2811        if plans
2812            .iter()
2813            .any(|plan| !before.fields.contains_key(&plan.field))
2814        {
2815            let board = self.board_fields().await?;
2816            for plan in plans
2817                .iter()
2818                .filter(|plan| !before.fields.contains_key(&plan.field))
2819            {
2820                if let Some(field) = Board::field(&board.fields, plan.field.name())? {
2821                    return Err(SourceError::Refused {
2822                        message: format!(
2823                            "source {}'s board has a {} field that is not a single-select field \
2824                             (it is a {}), so it cannot hold this source's options; next: rename \
2825                             or remove that field, then run this again",
2826                            self.name,
2827                            plan.field.name(),
2828                            optional_str(field, "__typename")?.unwrap_or("field of another type")
2829                        ),
2830                    });
2831                }
2832            }
2833        }
2834        let mut reports = Vec::new();
2835        for plan in &plans {
2836            let held = before.fields.get(&plan.field);
2837            let existing = held.map(|field| field.options.clone()).unwrap_or_default();
2838            let mut missing: Vec<String> = Vec::new();
2839            for wanted in &plan.wanted {
2840                let present = existing
2841                    .iter()
2842                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2843                    || missing
2844                        .iter()
2845                        .any(|named| named.eq_ignore_ascii_case(wanted));
2846                if !present {
2847                    missing.push(wanted.clone());
2848                }
2849            }
2850            reports.push(FieldReport {
2851                field: plan.field,
2852                exists: held.is_some(),
2853                outcome: match (mode, held.is_some(), missing.is_empty()) {
2854                    (SetupMode::Plan, _, _) => FieldOutcome::Planned,
2855                    (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
2856                    (SetupMode::Apply, true, false) => FieldOutcome::Applied,
2857                    (SetupMode::Apply, false, _) => FieldOutcome::Created,
2858                },
2859                missing,
2860                existing,
2861            });
2862        }
2863        let report = FieldsReport {
2864            source: self.name.clone(),
2865            fields: reports,
2866        };
2867        let writes: Vec<&FieldReport> = report
2868            .fields
2869            .iter()
2870            .filter(|field| !field.missing.is_empty() || !field.exists)
2871            .collect();
2872        if mode == SetupMode::Plan || writes.is_empty() {
2873            return Ok(report);
2874        }
2875        let mut landed: Vec<&str> = Vec::new();
2876        for field in &writes {
2877            let added = field
2878                .missing
2879                .iter()
2880                .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
2881            let sent = match before.fields.get(&field.field) {
2882                Some(held) => {
2883                    let mut options = held
2884                        .options
2885                        .iter()
2886                        .map(|option| {
2887                            json!({
2888                                "id": option.id, "name": option.name, "color": option.color,
2889                                "description": option.description,
2890                            })
2891                        })
2892                        .collect::<Vec<_>>();
2893                    options.extend(added);
2894                    self.graphql(
2895                        graphql::STATUS_OPTIONS_UPDATE,
2896                        json!({"input": {
2897                            "projectId": before.board_id, "fieldId": held.field_id,
2898                            "singleSelectOptions": options,
2899                        }}),
2900                    )
2901                    .await
2902                }
2903                None => {
2904                    self.graphql(
2905                        graphql::CREATE_FIELD,
2906                        json!({"input": {
2907                            "projectId": before.board_id, "dataType": "SINGLE_SELECT",
2908                            "name": field.field.name(),
2909                            "singleSelectOptions": added.collect::<Vec<_>>(),
2910                        }}),
2911                    )
2912                    .await
2913                }
2914            };
2915            // A mutation that failed does not establish that GitHub left its field as it was,
2916            // so every failure from here on carries the recovery data a drift refusal does.
2917            match sent {
2918                Ok(_) => landed.push(field.field.name()),
2919                Err(error) => {
2920                    let changed = if landed.is_empty() {
2921                        String::new()
2922                    } else {
2923                        format!("changed the {} field and then ", landed.join(" and "))
2924                    };
2925                    return Err(SourceError::Refused {
2926                        message: format!(
2927                            "the guarded field setup {changed}failed on the {} field, which it may \
2928                             have changed part way: {error}; the pre-write item assignments \
2929                             are:\n{}",
2930                            field.field.name(),
2931                            recovery(&report, &before)?
2932                        ),
2933                    });
2934                }
2935            }
2936        }
2937        // The board has been written, so a verification read that fails leaves it unverified
2938        // rather than unchanged, and says what to put back.
2939        let after = match self.board_snapshot(&owned).await {
2940            Ok(after) => after,
2941            Err(error) => {
2942                return Err(SourceError::Refused {
2943                    message: format!(
2944                        "the guarded field setup changed the {} field and then could not read the \
2945                         board back to verify it: {error}; the pre-write item assignments are:\n{}",
2946                        landed.join(" and "),
2947                        recovery(&report, &before)?
2948                    ),
2949                });
2950            }
2951        };
2952        let mut moved = Vec::new();
2953        for field in &report.fields {
2954            let name = field.field.name();
2955            let now = after
2956                .fields
2957                .get(&field.field)
2958                .map(|held| held.options.as_slice())
2959                .unwrap_or_default();
2960            if !field.existing.iter().all(|old| now.contains(old)) {
2961                moved.push(format!(
2962                    "a pre-existing {name} option id, name, color or description"
2963                ));
2964            }
2965            if !field.missing.iter().all(|wanted| {
2966                now.iter()
2967                    .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2968            }) {
2969                moved.push(format!("an added {name} option"));
2970            }
2971            if after.assignments(field.field) != before.assignments(field.field) {
2972                moved.push(format!("an item's {name} value"));
2973            }
2974        }
2975        if !moved.is_empty() {
2976            return Err(SourceError::Refused {
2977                message: format!(
2978                    "GitHub changed {} after the guarded field setup; the pre-write item \
2979                     assignments are:\n{}",
2980                    moved.join(", "),
2981                    recovery(&report, &before)?
2982                ),
2983            });
2984        }
2985        Ok(report)
2986    }
2987
2988    /// Validate configuration and capture the named credential without exposing it.
2989    ///
2990    /// # Errors
2991    ///
2992    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
2993    /// [`SourceError::Auth`] when the named credential is missing or empty.
2994    pub fn new(
2995        name: &SourceName,
2996        config: GitHubProjectsConfig,
2997        secrets: &dyn SecretResolver,
2998    ) -> Result<Self, SourceError> {
2999        Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3000    }
3001
3002    /// The same, recording every request it sends into an accounting the caller holds too.
3003    ///
3004    /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3005    /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3006    /// up — passes the one it records those into, so the session total accounts for the
3007    /// whole session rather than for this source's share of it.
3008    ///
3009    /// # Errors
3010    ///
3011    /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3012    /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3013    pub fn recording_into(
3014        name: &SourceName,
3015        config: GitHubProjectsConfig,
3016        secrets: &dyn SecretResolver,
3017        ledger: Arc<Accounting>,
3018    ) -> Result<Self, SourceError> {
3019        if !valid_github_owner(&config.owner) {
3020            return Err(SourceError::Config {
3021                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3022            });
3023        }
3024        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3025            return Err(SourceError::Config {
3026                message: format!("project_number must be between 1 and {}", i32::MAX),
3027            });
3028        }
3029        if !valid_environment_name(&config.token_env) {
3030            return Err(SourceError::Config {
3031                message: "token_env must be a valid environment-variable name".into(),
3032            });
3033        }
3034        let repository = config
3035            .repository
3036            .as_deref()
3037            .map(RepositoryTarget::parse)
3038            .transpose()?;
3039        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3040            message: format!("endpoint is not a valid URL: {e}"),
3041        })?;
3042        if endpoint.scheme() != "https"
3043            && !(endpoint.scheme() == "http"
3044                && endpoint
3045                    .host_str()
3046                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3047        {
3048            return Err(SourceError::Config {
3049                message:
3050                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3051                        .into(),
3052            });
3053        }
3054        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3055            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),
3056        })?;
3057        Ok(Self {
3058            name: name.clone(),
3059            owner: config.owner,
3060            project_number: config.project_number,
3061            repository,
3062            endpoint,
3063            token,
3064            credential_name: config.token_env,
3065            statuses: StatusMapping::resolve(config.status_mapping, name)?,
3066            priorities: config
3067                .priority_mapping
3068                .map(|mapping| PriorityMapping::resolve(mapping, name))
3069                .transpose()?,
3070            client: Client::builder()
3071                .user_agent("onetaskgraph")
3072                .build()
3073                .map_err(|e| SourceError::Config {
3074                    message: format!("cannot build HTTP client: {e}"),
3075                })?,
3076            created: Mutex::new(Vec::new()),
3077            updated: Mutex::new(Vec::new()),
3078            pacing: Pacing::resolve(config.pacing, name)?,
3079            last_mutation: Mutex::new(None),
3080            board_cache: Mutex::new(None),
3081            search_cache: Mutex::new(None),
3082            narrowed_cache: Mutex::new(BTreeMap::new()),
3083            fields_cache: Mutex::new(None),
3084            repository_cache: Mutex::new(BTreeMap::new()),
3085            ledger,
3086        })
3087    }
3088
3089    /// A snapshot of every request this source has sent, and what each cost.
3090    ///
3091    /// A value to hold and compare rather than a borrow of the accounting itself, so two
3092    /// of them can sit side by side. When this source was built with
3093    /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3094    /// point of building it that way.
3095    #[must_use]
3096    pub fn accounting(&self) -> accounting::Session {
3097        self.ledger.snapshot()
3098    }
3099
3100    /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3101    /// rate limit rather than handing it straight back as an error.
3102    ///
3103    /// Retrying is safe for every document here, including the mutations, and the reason
3104    /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3105    /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3106    /// this replays has already taken effect. An outcome this source cannot know — the
3107    /// send failed, or the body could not be read, so the mutation may well have landed —
3108    /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3109    /// attempt. A duplicate write would come from replaying one of those, and none is
3110    /// replayed.
3111    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3112        let doing = operation_description(query);
3113        let mut waited = Duration::ZERO;
3114        let mut waits = 0_u32;
3115        let mut backoff = self.pacing.retry_backoff;
3116        loop {
3117            if is_mutation(query) {
3118                let spacing = self.reserve_mutation_slot();
3119                if !spacing.is_zero() {
3120                    tokio::time::sleep(spacing).await;
3121                }
3122            }
3123            let attempt = self.send_once(query, &variables).await;
3124            if is_mutation(query) {
3125                self.finish_mutation();
3126            }
3127            let limited = match attempt {
3128                Ok(data) => return Ok(data),
3129                Err(Attempt::Failed(error)) => return Err(error),
3130                Err(Attempt::Limited(limited)) => limited,
3131            };
3132            // GitHub really does send `retry-after: 0`, and retrying at once is the one
3133            // move that extends a secondary limit, so a hint below the schedule's own next
3134            // wait is raised to it.
3135            let wait = match limited.hint {
3136                Some(hint) => Duration::from_secs(hint).max(backoff),
3137                None => backoff,
3138            };
3139            let remaining = self.pacing.retry_budget.saturating_sub(waited);
3140            // A wait of nothing spends none of the budget, so it is exhaustion rather
3141            // than a retry. `Pacing::resolve` rules out every way of configuring one
3142            // except a budget of zero, where reporting the first refusal is the ask.
3143            if wait.is_zero() || wait > remaining {
3144                return Err(limited.exhausted(
3145                    doing,
3146                    waits,
3147                    waited,
3148                    wait,
3149                    self.pacing.retry_budget,
3150                ));
3151            }
3152            tokio::time::sleep(wait).await;
3153            waited += wait;
3154            waits += 1;
3155            backoff = backoff.saturating_mul(2);
3156        }
3157    }
3158
3159    /// The next moment a content-creating mutation may leave this source, as a wait from
3160    /// now.
3161    ///
3162    /// The slot is reserved under the lock and the waiting happens outside it, so two
3163    /// callers take two slots rather than the same one — and no lock is held across an
3164    /// await.
3165    ///
3166    /// The moment it is spaced from is the previous mutation's *completion*, which
3167    /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3168    /// own is the wrong thing to measure from.
3169    fn reserve_mutation_slot(&self) -> Duration {
3170        if self.pacing.min_mutation_interval.is_zero() {
3171            return Duration::ZERO;
3172        }
3173        // A poisoned lock here costs pacing, not correctness, and refusing the write over
3174        // it would turn an earlier failure into a second one for no gain.
3175        let mut last = self
3176            .last_mutation
3177            .lock()
3178            .unwrap_or_else(std::sync::PoisonError::into_inner);
3179        let now = Instant::now();
3180        // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3181        // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3182        let at = last.map_or(now, |previous| {
3183            previous
3184                .checked_add(self.pacing.min_mutation_interval)
3185                .map_or(now, |earliest| earliest.max(now))
3186        });
3187        *last = Some(at);
3188        at.saturating_duration_since(now)
3189    }
3190
3191    /// Record that a content-creating mutation has finished, so the next one is spaced
3192    /// from here rather than from the moment this one was released.
3193    ///
3194    /// This source can only choose when a request *departs*; the limiter counts when it
3195    /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3196    /// departure from the last therefore hands the limiter a gap of the interval less that
3197    /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3198    /// exactly how a copy paced well inside a board's threshold was refused by it on a
3199    /// slower machine while passing on a quick one.
3200    ///
3201    /// Spacing from completion removes the subtraction rather than budgeting for it. The
3202    /// previous request had already arrived before its response came back, so its arrival
3203    /// is no later than this moment, and the next mutation is released at least the
3204    /// interval after this moment and arrives no earlier than it is released: the gap the
3205    /// limiter measures is therefore at least the interval, whatever transit costs and on
3206    /// whatever platform. The price is that a mutation's own round trip no longer counts
3207    /// towards its spacing, which makes this source slightly slower than the configured
3208    /// rate rather than slightly faster — the safe side of a limit that punishes being
3209    /// wrong by refusing reads for the next fifty minutes.
3210    ///
3211    /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3212    /// and one that never left costs only a wait nobody needed.
3213    fn finish_mutation(&self) {
3214        if self.pacing.min_mutation_interval.is_zero() {
3215            return;
3216        }
3217        // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3218        let mut last = self
3219            .last_mutation
3220            .lock()
3221            .unwrap_or_else(std::sync::PoisonError::into_inner);
3222        let now = Instant::now();
3223        // `max` rather than an assignment: a concurrent caller may already have reserved a
3224        // slot further out, and completing this request must never pull that slot back in.
3225        *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3226    }
3227
3228    /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3229    /// failure that waiting cannot help — and recorded, whichever of the three it was.
3230    ///
3231    /// This is the one place a request leaves this crate, which is why the accounting is
3232    /// here rather than at each of the callers: a read path added later is counted without
3233    /// anybody remembering to count it, and
3234    /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3235    /// when one is not.
3236    async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3237        let Attempted {
3238            result,
3239            limits,
3240            reported_cost,
3241        } = self.attempt(query, variables).await;
3242        // No `otherwise` name: every document this source sends is one of its own, and the
3243        // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3244        let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3245        let outcome = match &result {
3246            Ok(_) => accounting::Outcome::Answered,
3247            Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3248            Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3249        };
3250        self.ledger.record(sending.finished(outcome, limits));
3251        result
3252    }
3253
3254    /// The attempt itself, with what its response said about the rate limit alongside.
3255    ///
3256    /// The two are returned together rather than recorded here because every one of the
3257    /// early exits below is a different outcome, and a record written at each of them is a
3258    /// record one of them can be added without.
3259    async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3260        let mut limits = accounting::RateLimit::default();
3261        let mut reported_cost = None;
3262        let result = self
3263            .attempted(query, variables, &mut limits, &mut reported_cost)
3264            .await;
3265        Attempted {
3266            result,
3267            limits,
3268            reported_cost,
3269        }
3270    }
3271
3272    /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3273    async fn attempted(
3274        &self,
3275        query: &str,
3276        variables: &Value,
3277        limits: &mut accounting::RateLimit,
3278        reported_cost: &mut Option<u64>,
3279    ) -> Result<Value, Attempt> {
3280        let response = self
3281            .client
3282            .post(self.endpoint.clone())
3283            .bearer_auth(self.token.expose_secret())
3284            .json(&json!({"query": query, "variables": variables}))
3285            .send()
3286            .await
3287            .map_err(|e| {
3288                Attempt::Failed(SourceError::Unavailable {
3289                    message: format!("GitHub GraphQL request failed: {e}"),
3290                })
3291            })?;
3292        let status = response.status();
3293        let header = |name: &str| whole_seconds(response.headers().get(name));
3294        *limits = accounting::RateLimit::read(|name| {
3295            response
3296                .headers()
3297                .get(name)
3298                .and_then(|value| value.to_str().ok())
3299                .map(str::to_owned)
3300        });
3301        // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3302        // that are not text at all — is "not known to be exhausted". This never makes a
3303        // response a refusal on its own: it says which limiter a refusal is attributed to
3304        // and where its hint comes from, so a value this cannot read costs a hint rather
3305        // than an answer.
3306        let exhausted = response
3307            .headers()
3308            .get("x-ratelimit-remaining")
3309            .and_then(|value| value.to_str().ok())
3310            == Some("0");
3311        // `retry-after` is what GitHub asks for when it asks; when it does not and the
3312        // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3313        // which is the same question answered as an absolute time. Nothing else here is a
3314        // hint, and a schedule is what answers a refusal that carries none.
3315        let hint = header("retry-after").or_else(|| {
3316            exhausted
3317                .then(|| header("x-ratelimit-reset"))
3318                .flatten()
3319                .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3320        });
3321        // Read before it is parsed, because the evidence which tells a secondary rate
3322        // limit from a rejected credential is in the body of a response whose status says
3323        // only "forbidden" — and a non-success response was never parsed at all.
3324        let body = response.text().await.map_err(|e| {
3325            Attempt::Failed(SourceError::Unavailable {
3326                message: format!("GitHub GraphQL response could not be read: {e}"),
3327            })
3328        })?;
3329        if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3330            return Err(Attempt::Limited(Limited { limiter, hint }));
3331        }
3332        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3333            return Err(Attempt::Failed(SourceError::Auth {
3334                message: format!(
3335                    "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"
3336                ),
3337            }));
3338        }
3339        if !status.is_success() {
3340            return Err(Attempt::Failed(SourceError::Unavailable {
3341                message: format!("GitHub GraphQL returned HTTP {status}"),
3342            }));
3343        }
3344        // GitHub reports what a call cost only when the document asked it to, and no
3345        // document this source sends does — so this is `None` here and carries the figure
3346        // for a caller whose own document selects `rateLimit { cost }`. What it must never
3347        // pick up is a `dryRun` probe's cost, which is some other document's.
3348        *reported_cost = serde_json::from_str::<Value>(&body)
3349            .ok()
3350            .as_ref()
3351            .and_then(|body| body.pointer("/data/rateLimit/cost"))
3352            .and_then(Value::as_u64);
3353        self.answer(&body).map_err(Attempt::Failed)
3354    }
3355
3356    /// What one successful HTTP response says, once its GraphQL errors are read.
3357    fn answer(&self, body: &str) -> Result<Value, SourceError> {
3358        let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3359            message: format!("GitHub returned invalid JSON: {e}"),
3360        })?;
3361        let errors = body
3362            .get("errors")
3363            .map(|value| {
3364                value.as_array().ok_or_else(|| SourceError::Malformed {
3365                    message: "GitHub response errors is not an array".into(),
3366                })
3367            })
3368            .transpose()?;
3369        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3370            let messages = errors
3371                .iter()
3372                .filter_map(|e| e.get("message").and_then(Value::as_str))
3373                .collect::<Vec<_>>()
3374                .join("; ");
3375            let message = if messages.is_empty() {
3376                "GitHub returned GraphQL errors".into()
3377            } else {
3378                messages
3379            };
3380            let normalized = message.to_ascii_lowercase();
3381            if normalized.contains("resource not accessible") || normalized.contains("scope") {
3382                return Err(SourceError::Auth {
3383                    message: format!(
3384                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3385                        self.credential_name
3386                    ),
3387                });
3388            }
3389            return Err(SourceError::Refused { message });
3390        }
3391        body.get("data")
3392            .filter(|data| data.is_object())
3393            .cloned()
3394            .ok_or_else(|| SourceError::Malformed {
3395                message: "GitHub response has no data object".into(),
3396            })
3397    }
3398
3399    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3400    // GraphQL cannot independently page them inside the outer item page. This source page is
3401    // deliberately bounded at that published maximum; the live drift journey exercises it.
3402    async fn board_page(
3403        &self,
3404        items_after: Option<&str>,
3405        items_first: u32,
3406    ) -> Result<Value, SourceError> {
3407        let data = self
3408            .graphql(
3409                graphql::BOARD,
3410                json!({"owner":self.owner,"number":self.project_number,
3411                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3412                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3413            )
3414            .await?;
3415        data.pointer("/owner/projectV2")
3416            .filter(|v| !v.is_null())
3417            .cloned()
3418            .ok_or_else(|| SourceError::Refused {
3419                message: format!(
3420                    "GitHub project {}/{} was not found or is not visible to the token",
3421                    self.owner, self.project_number
3422                ),
3423            })
3424    }
3425
3426    /// The search that finds the issues of this board, narrowed by `also` when it is
3427    /// given.
3428    ///
3429    /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3430    /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3431    /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3432    /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3433    /// from a task by the `parent` field each issue carries rather than by the search.
3434    fn board_search(&self, also: Option<&str>) -> String {
3435        let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3436        match also {
3437            Some(also) => format!("{scope} {also}"),
3438            None => scope,
3439        }
3440    }
3441
3442    /// One issue this source reached directly, as the board item a read of the board would
3443    /// have produced — or `None` when this board does not hold it.
3444    ///
3445    /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3446    /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3447    /// item's own id, that item's field values, and the issue as its content. One resolver
3448    /// for both routes is what makes an issue read through a search, through its own node
3449    /// id, or through its project's sub-issues report the same title, the same status, the
3450    /// same labels and the same qualified id.
3451    ///
3452    /// An issue with no entry for *this* board is not this source's to report, which is
3453    /// what keeps an id naming some other repository's issue from being answered as an item
3454    /// of this board. That answer is given about an **exhausted** connection and never
3455    /// about an unread page: the entry is looked for on the page in hand, and only if that
3456    /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3457    /// rest of it.
3458    async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3459        if optional_str(issue, "__typename")? != Some("Issue") {
3460            return Ok(None);
3461        }
3462        let memberships = issue
3463            .get("projectItems")
3464            .ok_or_else(|| SourceError::Malformed {
3465                message: "GitHub issue is missing projectItems".into(),
3466            })?;
3467        let nodes = memberships
3468            .get("nodes")
3469            .and_then(Value::as_array)
3470            .ok_or_else(|| SourceError::Malformed {
3471                message: "GitHub issue projectItems.nodes is not an array".into(),
3472            })?;
3473        let held = match self.board_entry(nodes) {
3474            Some(held) => held.clone(),
3475            None => {
3476                let info = memberships
3477                    .get("pageInfo")
3478                    .ok_or_else(|| SourceError::Malformed {
3479                        message: "GitHub issue projectItems has no pageInfo".into(),
3480                    })?;
3481                // The page held no entry for this board. Whether that means the issue is
3482                // not on it is a question about the rest of the connection, and only a
3483                // connection with no rest answers it here.
3484                if !required_bool(info, "hasNextPage")? {
3485                    return Ok(None);
3486                }
3487                let cursor = required_str(info, "endCursor")?;
3488                validate_cursor_progress(None, cursor)?;
3489                let issue_id = required_str(issue, "id")?;
3490                match self.board_membership(issue_id, cursor).await? {
3491                    Some(held) => held,
3492                    None => return Ok(None),
3493                }
3494            }
3495        };
3496        let item = json!({
3497            "id": required_str(&held, "id")?,
3498            "project": held.get("project"),
3499            "fieldValues": held.get("fieldValues"),
3500            "content": issue,
3501        });
3502        self.resolve(&item)
3503    }
3504
3505    /// This board's own entry among one page of an issue's `Issue.projectItems`.
3506    ///
3507    /// One spelling of *which membership is this board's*, so the page a read carries and
3508    /// the pages [`Self::board_membership`] walks are searched by the same rule.
3509    fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3510        nodes.iter().find(|node| {
3511            node.pointer("/project/number").and_then(Value::as_u64)
3512                == Some(u64::from(self.project_number))
3513        })
3514    }
3515
3516    /// The rest of one issue's board memberships, from `after`, for this board's entry.
3517    ///
3518    /// The recovery read: a page of memberships that holds no entry for this board says
3519    /// nothing about the memberships past it, so the connection is walked to exhaustion
3520    /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3521    /// positive answer — the whole connection was read and no entry named this board —
3522    /// rather than a failure, and the walk is held to
3523    /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3524    /// with a cursor that does not advance is refused instead of spun on.
3525    async fn board_membership(
3526        &self,
3527        issue: &str,
3528        after: &str,
3529    ) -> Result<Option<Value>, SourceError> {
3530        let mut after = after.to_owned();
3531        loop {
3532            let data = self
3533                .graphql(
3534                    graphql::ISSUE_BOARD_ITEMS,
3535                    json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3536                           "nestedFirst":NESTED_PAGE_SIZE}),
3537                )
3538                .await?;
3539            let Some(connection) = data
3540                .pointer("/node/projectItems")
3541                .filter(|value| !value.is_null())
3542            else {
3543                // The id resolved to nothing, or to something with no memberships to walk —
3544                // which is the same answer as a connection holding no entry for this board.
3545                return Ok(None);
3546            };
3547            let nodes = connection
3548                .get("nodes")
3549                .and_then(Value::as_array)
3550                .ok_or_else(|| SourceError::Malformed {
3551                    message: "GitHub issue projectItems.nodes is not an array".into(),
3552                })?;
3553            if let Some(held) = self.board_entry(nodes) {
3554                return Ok(Some(held.clone()));
3555            }
3556            let info = connection
3557                .get("pageInfo")
3558                .ok_or_else(|| SourceError::Malformed {
3559                    message: "GitHub issue projectItems has no pageInfo".into(),
3560                })?;
3561            let next = required_bool(info, "hasNextPage")?
3562                .then(|| required_str(info, "endCursor"))
3563                .transpose()?;
3564            match next {
3565                Some(next) => {
3566                    validate_cursor_progress(Some(&after), next)?;
3567                    after = next.to_owned();
3568                }
3569                None => return Ok(None),
3570            }
3571        }
3572    }
3573
3574    /// One page of a board-scoped issue search, and where the next page resumes.
3575    async fn search_page(
3576        &self,
3577        search: &str,
3578        first: u32,
3579        after: Option<&str>,
3580    ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3581        let data = self
3582            .graphql(
3583                graphql::SEARCH_ISSUES,
3584                json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3585                       "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3586                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3587            )
3588            .await?;
3589        let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3590            message: "GitHub search response has no search connection".into(),
3591        })?;
3592        let mut found = Vec::new();
3593        for node in connection
3594            .get("nodes")
3595            .and_then(Value::as_array)
3596            .ok_or_else(|| SourceError::Malformed {
3597                message: "GitHub search nodes is not an array".into(),
3598            })?
3599        {
3600            if let Some(resolved) = self.resolve_issue(node).await? {
3601                found.push(resolved);
3602            }
3603        }
3604        let info = connection
3605            .get("pageInfo")
3606            .ok_or_else(|| SourceError::Malformed {
3607                message: "GitHub search connection has no pageInfo".into(),
3608            })?;
3609        let next = required_bool(info, "hasNextPage")?
3610            .then(|| required_str(info, "endCursor"))
3611            .transpose()?
3612            .map(str::to_owned);
3613        if let Some(next) = &next {
3614            validate_cursor_progress(after, next)?;
3615        }
3616        Ok((found, next))
3617    }
3618
3619    /// Every issue this board holds, completed with what this run wrote.
3620    ///
3621    /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3622    /// is an index and is eventually consistent, so an issue this run created seconds ago
3623    /// can be absent from it, and a project listed straight after being written would
3624    /// otherwise be missing from its own board. What is added back is only what this
3625    /// process itself wrote, out of [`Self::created`], which lives and dies with the
3626    /// process.
3627    async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3628        let found = self.searched_issues().await?;
3629        self.completed_with_written(found, |_| true)
3630    }
3631
3632    /// Every issue this board's own search reports, walked to exhaustion, read once per
3633    /// source.
3634    ///
3635    /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3636    /// needs it too and the two would otherwise walk the same search twice in one command.
3637    /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3638    /// is.
3639    async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3640        let cached = self.search_cache()?.clone();
3641        if let Some(held) = cached {
3642            return Ok(held);
3643        }
3644        let mut after: Option<String> = None;
3645        let mut found = Vec::new();
3646        let search = self.board_search(None);
3647        loop {
3648            let (page, next) = self
3649                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3650                .await?;
3651            found.extend(page);
3652            match next {
3653                Some(next) => after = Some(next),
3654                None => break,
3655            }
3656        }
3657        *self.search_cache()? = Some(found.clone());
3658        Ok(found)
3659    }
3660
3661    /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3662    fn search_cache(
3663        &self,
3664    ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3665        self.search_cache
3666            .lock()
3667            .map_err(|_| SourceError::Unavailable {
3668                message: "this source's view of the board's issues was left inconsistent by an \
3669                      earlier failure; next: run the command again"
3670                    .into(),
3671            })
3672    }
3673
3674    /// `found`, with everything this run wrote that `keep` accepts and the read did not
3675    /// report.
3676    ///
3677    /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3678    /// at all: the search index is behind, and a node read of an item filed moments ago can
3679    /// be too.
3680    fn completed_with_written(
3681        &self,
3682        mut found: Vec<Resolved>,
3683        keep: impl Fn(&Resolved) -> bool,
3684    ) -> Result<Vec<Resolved>, SourceError> {
3685        for own in self.created()?.iter().filter(|own| keep(own)) {
3686            if !found.iter().any(|item| item.id == own.id) {
3687                found.push(own.clone());
3688            }
3689        }
3690        Ok(found)
3691    }
3692
3693    /// What resolving one node id reached.
3694    ///
3695    /// Three answers rather than an `Option`, because a board *draft* is none of the other
3696    /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3697    /// is completed by a read of the draft itself rather than reported as nothing.
3698    async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3699        let asked = self
3700            .graphql(
3701                graphql::ISSUE,
3702                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3703                       "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3704            )
3705            .await;
3706        let data = match asked {
3707            Ok(data) => data,
3708            // A string that is not a node id at all is not a failure to report: it is an id
3709            // this board does not hold, which is what every read of one already answers.
3710            Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3711            Err(error) => return Err(error),
3712        };
3713        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3714            return Ok(Reached::Nothing);
3715        };
3716        if optional_str(node, "__typename")? == Some("DraftIssue") {
3717            return Ok(Reached::Draft);
3718        }
3719        Ok(match self.resolve_issue(node).await? {
3720            Some(item) => Reached::Held(Box::new(item)),
3721            None => Reached::Nothing,
3722        })
3723    }
3724
3725    /// One item of this board by its own id, whatever kind it is.
3726    ///
3727    /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
3728    /// run wrote is read first, because a node read of an item created moments ago can
3729    /// still be behind the board field values written onto it — see [`Self::created`].
3730    async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3731        if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
3732            return Ok(Some(own.clone()));
3733        }
3734        match self.reach(id).await? {
3735            Reached::Held(item) => Ok(Some(*item)),
3736            Reached::Nothing => Ok(None),
3737            Reached::Draft => self.draft_by_id(id).await,
3738        }
3739    }
3740
3741    /// One board draft by its own id, with the board item it sits in — or `None` when no
3742    /// item of this board is that draft's.
3743    ///
3744    /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
3745    /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
3746    /// links a draft to one board item, so the page this read carries is the whole of that
3747    /// connection, and a page that reports more than it holds is refused rather than read
3748    /// as an answer about memberships nobody read.
3749    async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3750        let data = self
3751            .graphql(
3752                graphql::DRAFT,
3753                json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3754                       "boardItems":BOARD_ITEMS_PAGE_SIZE}),
3755            )
3756            .await?;
3757        // Gone between the two reads is an answer — the draft is no longer there. Anything
3758        // else than the draft [`Self::reach`] was just told this id is, is not one.
3759        let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
3760            return Ok(None);
3761        };
3762        if optional_str(draft, "__typename")? != Some("DraftIssue") {
3763            return Err(SourceError::Malformed {
3764                message: format!(
3765                    "GitHub answered {} as a draft and then as something else",
3766                    id.0
3767                ),
3768            });
3769        }
3770        if required_str(draft, "id")? != id.0 {
3771            return Err(SourceError::Malformed {
3772                message: format!("GitHub answered a different draft for {}", id.0),
3773            });
3774        }
3775        let memberships = draft
3776            .get("projectV2Items")
3777            .ok_or_else(|| SourceError::Malformed {
3778                message: format!("GitHub draft {} is missing projectV2Items", id.0),
3779            })?;
3780        let nodes = memberships
3781            .get("nodes")
3782            .and_then(Value::as_array)
3783            .ok_or_else(|| SourceError::Malformed {
3784                message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
3785            })?;
3786        let info = memberships
3787            .get("pageInfo")
3788            .ok_or_else(|| SourceError::Malformed {
3789                message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
3790            })?;
3791        // Read whether or not this board's entry is on the page: a page claiming more than
3792        // the one item GitHub links a draft to is a malformed answer either way.
3793        if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
3794            return Err(SourceError::Malformed {
3795                message: format!(
3796                    "GitHub draft {} reports more board items than the one GitHub links a draft \
3797                     to",
3798                    id.0
3799                ),
3800            });
3801        }
3802        if let Some(node) = nodes.first()
3803            && node
3804                .pointer("/project/number")
3805                .and_then(Value::as_u64)
3806                .is_none()
3807        {
3808            return Err(SourceError::Malformed {
3809                message: format!(
3810                    "GitHub draft {} board item has no numeric project number",
3811                    id.0
3812                ),
3813            });
3814        }
3815        let Some(held) = self.board_entry(nodes) else {
3816            return Ok(None);
3817        };
3818        if required_str(
3819            held.get("project").ok_or_else(|| SourceError::Malformed {
3820                message: format!("GitHub draft {} board item has no project", id.0),
3821            })?,
3822            "id",
3823        )? != self.board_fields().await?.id.as_str()
3824        {
3825            return Ok(None);
3826        }
3827        let item = json!({
3828            "id": required_str(held, "id")?,
3829            "project": held.get("project"),
3830            "fieldValues": held.get("fieldValues"),
3831            "content": draft,
3832        });
3833        self.resolve(&item)
3834    }
3835
3836    /// The board's own id and field definitions, for a write whose item does not carry
3837    /// them — never its items.
3838    ///
3839    /// A board this command has already listed supplies them, since it read them beside its
3840    /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
3841    /// is consulted about which items the board holds: see the module documentation for
3842    /// why a question about one known item is answered by reading that item.
3843    async fn board_fields(&self) -> Result<BoardFields, SourceError> {
3844        if let Some(board) = self.board_cache()?.as_ref() {
3845            return Ok(BoardFields {
3846                id: BoardId::parse(&board.id)?,
3847                fields: board.fields.clone(),
3848            });
3849        }
3850        if let Some(held) = self.fields_cache()?.clone() {
3851            return Ok(held);
3852        }
3853        let data = self
3854            .graphql(
3855                graphql::BOARD_FIELDS,
3856                json!({"owner":self.owner,"number":self.project_number,
3857                       "nestedFirst":NESTED_PAGE_SIZE}),
3858            )
3859            .await?;
3860        let board = data
3861            .pointer("/boardFields/projectV2")
3862            .filter(|value| !value.is_null())
3863            .ok_or_else(|| SourceError::Refused {
3864                message: format!(
3865                    "GitHub project {}/{} was not found or is not visible to the token",
3866                    self.owner, self.project_number
3867                ),
3868            })?;
3869        let read = BoardFields {
3870            id: BoardId::parse(required_str(board, "id")?)?,
3871            fields: board.get("fields").cloned().unwrap_or(Value::Null),
3872        };
3873        *self.fields_cache()? = Some(read.clone());
3874        Ok(read)
3875    }
3876
3877    /// This process's own view of the board's fields, or the refusal a poisoned lock is.
3878    fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
3879        self.fields_cache
3880            .lock()
3881            .map_err(|_| SourceError::Unavailable {
3882                message: "this source's view of the board's fields was left inconsistent by an \
3883                      earlier failure; next: run the command again"
3884                    .into(),
3885            })
3886    }
3887
3888    /// What a write to `item` needs of the board, read off that item when it says enough and
3889    /// off [`Self::board_fields`] when it does not.
3890    ///
3891    /// A node read of an item names its board and carries the definition of every field it
3892    /// holds a value of — so an item naming its board, holding a value of the origin field,
3893    /// and, when the write carries a status, holding a `Status` value, needs no read of the
3894    /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
3895    /// of may still be on the board, and a view reading it as absent would refuse a write the
3896    /// board can take or skip a field write the board needs, so such an item — and a create,
3897    /// which has no item yet — takes the board's fields from their own read instead.
3898    async fn fields_for(
3899        &self,
3900        item: Option<&Resolved>,
3901        writes_status: bool,
3902        selects_priority: bool,
3903    ) -> Result<BoardFields, SourceError> {
3904        if let Some(item) = item
3905            && let Some(board_id) = item.named_board()
3906            && item.defines(ORIGIN_FIELD)
3907            && (!writes_status || item.defines("Status"))
3908            && (!selects_priority || item.defines(PRIORITY_FIELD))
3909        {
3910            return Ok(BoardFields {
3911                id: board_id,
3912                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
3913            });
3914        }
3915        self.board_fields().await
3916    }
3917
3918    /// Everything filed under one issue of this board, walked to exhaustion — or `None`
3919    /// when that id names nothing here with a sub-issue relationship to walk.
3920    ///
3921    /// `None` and an empty answer are different: `None` is *this is not an issue of this
3922    /// GitHub*, which is what sends a project selector on to be read as a name, and an
3923    /// empty vector is a project that holds nothing.
3924    async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
3925        let mut after: Option<String> = None;
3926        let mut children = Vec::new();
3927        loop {
3928            let asked = self
3929                .graphql(
3930                    graphql::SUB_ISSUES,
3931                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
3932                           "nestedFirst":NESTED_PAGE_SIZE,
3933                           "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3934                )
3935                .await;
3936            let data = match asked {
3937                Ok(data) => data,
3938                // A string that is not a node id at all is not a failure to report: it is
3939                // the ordinary answer to a selector naming a project by its name.
3940                Err(error) if unresolvable_node(&error) => return Ok(None),
3941                Err(error) => return Err(error),
3942            };
3943            let Some(connection) = data
3944                .pointer("/node/subIssues")
3945                .filter(|value| !value.is_null())
3946            else {
3947                // No such node, or one with no sub-issue relationship — a board draft is
3948                // the one this board can really hold.
3949                return Ok(None);
3950            };
3951            for node in connection
3952                .get("nodes")
3953                .and_then(Value::as_array)
3954                .ok_or_else(|| SourceError::Malformed {
3955                    message: "GitHub subIssues.nodes is not an array".into(),
3956                })?
3957            {
3958                if let Some(resolved) = self.resolve_issue(node).await? {
3959                    children.push(resolved);
3960                }
3961            }
3962            let info = connection
3963                .get("pageInfo")
3964                .ok_or_else(|| SourceError::Malformed {
3965                    message: "GitHub subIssues connection has no pageInfo".into(),
3966                })?;
3967            let next = required_bool(info, "hasNextPage")?
3968                .then(|| required_str(info, "endCursor"))
3969                .transpose()?;
3970            match next {
3971                Some(next) => {
3972                    validate_cursor_progress(after.as_deref(), next)?;
3973                    after = Some(next.to_owned());
3974                }
3975                None => return Ok(Some(children)),
3976            }
3977        }
3978    }
3979
3980    /// Which issue of this board a project *name* is, or `None` when none is.
3981    ///
3982    /// One bounded query which filters on that name at the server, rather than a walk of
3983    /// every issue the board holds. The name is compared again here: the qualifier narrows
3984    /// what GitHub sends, and this source decides what it names.
3985    async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
3986        let search = self.board_search(Some(&title_qualifier(name)));
3987        let (candidates, _) = self.search_page(&search, MAX_PAGE_SIZE, None).await?;
3988        Ok(candidates
3989            .into_iter()
3990            .find(|item| {
3991                item.kind == BoardKind::Work(ItemKind::Project)
3992                    && item.title.eq_ignore_ascii_case(name)
3993            })
3994            .map(|item| item.id))
3995    }
3996
3997    /// Everything filed under one project of this board: the sub-issues of the issue that
3998    /// project is.
3999    ///
4000    /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4001    /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4002    /// gains projects, or as another project gains tasks.
4003    ///
4004    /// A qualified id names the issue and is asked for its sub-issues directly: one
4005    /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4006    /// read as a project *name*, which costs the one bounded search
4007    /// [`Self::project_by_name`] makes.
4008    async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4009        let (project, children) = match self.sub_issues(selector).await? {
4010            Some(children) => (selector.clone(), children),
4011            None => match self.project_by_name(&selector.0).await? {
4012                Some(project) => {
4013                    let children = self.sub_issues(&project).await?.unwrap_or_default();
4014                    (project, children)
4015                }
4016                None => return Ok(Vec::new()),
4017            },
4018        };
4019        self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4020    }
4021
4022    /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4023    /// completed with what this run wrote — the candidates a comment-activity read confirms.
4024    ///
4025    /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4026    /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4027    /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4028    /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4029    /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4030    /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4031    /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4032    /// rather than silently narrowing a caller's answer.
4033    ///
4034    /// The instant is written to the second, rounded down, which can only widen what the
4035    /// search returns; confirmation against each candidate's own comments is what makes the
4036    /// answer exact. The search is an index that lags a write by a second or two — the module
4037    /// documentation records it — so a caller that asks again from its last instant should
4038    /// overlap the two by more than that.
4039    async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4040        let found = self.searched(&updated_qualifier(since)).await?;
4041        self.completed_with_written(found, |_| true)
4042    }
4043
4044    /// Every issue of this board GitHub's issue search reports for the board-scoped search
4045    /// narrowed by `also`, walked to exhaustion at [`MAX_PAGE_SIZE`].
4046    ///
4047    /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4048    /// own record is the fresher of the two.
4049    async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4050        let search = self.board_search(Some(also));
4051        let mut after: Option<String> = None;
4052        let mut found = Vec::new();
4053        loop {
4054            let (page, next) = self
4055                .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4056                .await?;
4057            found.extend(page);
4058            match next {
4059                Some(next) => after = Some(next),
4060                None => return Ok(found),
4061            }
4062        }
4063    }
4064
4065    /// The candidates for a task query carrying a text, metadata or origin predicate, read
4066    /// without enumerating the board — or `None` for a query carrying none of the three, which
4067    /// keeps the reads it always had.
4068    ///
4069    /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4070    /// because it names at most a handful of items. Text and metadata are answered by one
4071    /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4072    /// further by `updated:>=` when the query also asks for comment activity, since both
4073    /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4074    /// process afterwards by the same predicates [`task_matches`] applies to every read.
4075    ///
4076    /// Completed with what this process wrote, its own record winning over the index's copy
4077    /// of the same item: see [`Self::with_own_writes`].
4078    async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4079        let asked = match (&query.origin, narrowing_qualifiers(query)) {
4080            (Some(origin), _) => Narrowing::Origin(origin.clone()),
4081            (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4082                Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4083                None => qualifiers,
4084            }),
4085            (None, None) => return Ok(None),
4086        };
4087        // A question about comment activity is asked afresh every time, as it always was: it
4088        // is the one a caller polls from one source while waiting for the index, and an
4089        // answer held from the first poll would be the answer to every later one.
4090        let key = query.commented_since.is_none().then(|| asked.key());
4091        let cached = match &key {
4092            Some(key) => self.narrowed_cache()?.get(key).cloned(),
4093            None => None,
4094        };
4095        let found = match cached {
4096            Some(found) => found,
4097            None => {
4098                let found = match &asked {
4099                    Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4100                    Narrowing::Search(also) => self.searched(also).await?,
4101                };
4102                if let Some(key) = key {
4103                    self.narrowed_cache()?.insert(key, found.clone());
4104                }
4105                found
4106            }
4107        };
4108        self.with_own_writes(found).map(Some)
4109    }
4110
4111    /// Every item of this board that may carry `origin` — a superset of those that do — found
4112    /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4113    ///
4114    /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4115    /// which reads the field every carrier holds, whichever release wrote it — and the
4116    /// board-scoped issue search for the same id as a phrase in the body, where this source
4117    /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4118    /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4119    /// query's, exactly.
4120    ///
4121    /// Both connections are walked to exhaustion, each from its own cursor. One that has
4122    /// already ended is sent its last cursor again, which answers an empty page, so the one
4123    /// document serves every page of either. What the two leave is stated in the module
4124    /// documentation: a carrier another process added within the last second or two, before
4125    /// either index has it.
4126    async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4127        let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4128        let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4129        let mut items_after: Option<String> = None;
4130        let mut search_after: Option<String> = None;
4131        let mut found: Vec<Resolved> = Vec::new();
4132        let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4133            if !found.iter().any(|held| held.id == resolved.id) {
4134                found.push(resolved);
4135            }
4136        };
4137        loop {
4138            let data = self
4139                .graphql(
4140                    graphql::ORIGIN_LOOKUP,
4141                    json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4142                           "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4143                           "itemsAfter":items_after,"searchAfter":search_after,
4144                           "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4145                           "duplicates":true}),
4146                )
4147                .await?;
4148            let items = data
4149                .pointer("/originItems/projectV2/items")
4150                .filter(|value| !value.is_null())
4151                .ok_or_else(|| SourceError::Refused {
4152                    message: format!(
4153                        "GitHub project {}/{} was not found or is not visible to the token",
4154                        self.owner, self.project_number
4155                    ),
4156                })?;
4157            for item in optional_nodes(Some(items), "project items")?
4158                .into_iter()
4159                .flatten()
4160            {
4161                // The board's own items list its drafts too, and a draft is not an issue: no
4162                // narrowed read answers with one, whatever its origin field holds.
4163                if let Some(resolved) = self.resolve(item)?
4164                    && resolved.content_kind == ContentKind::Issue
4165                {
4166                    keep(resolved, &mut found);
4167                }
4168            }
4169            let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4170                message: "GitHub search response has no search connection".into(),
4171            })?;
4172            for node in optional_nodes(Some(searched), "search")?
4173                .into_iter()
4174                .flatten()
4175            {
4176                if let Some(resolved) = self.resolve_issue(node).await? {
4177                    keep(resolved, &mut found);
4178                }
4179            }
4180            let items_next = resumed(items, items_after.as_deref())?;
4181            let search_next = resumed(searched, search_after.as_deref())?;
4182            if !items_next.has_more() && !search_next.has_more() {
4183                return Ok(found);
4184            }
4185            items_after = items_next.cursor();
4186            search_after = search_next.cursor();
4187        }
4188    }
4189
4190    /// `found`, with every item this process created or wrote in its place, and every one of
4191    /// them the read did not report added.
4192    ///
4193    /// This process's own record wins over the read's copy of the same item, because a read
4194    /// of an item written moments ago can still be behind what was written onto it — the
4195    /// origin field included, which is the one a narrowed read is confirmed against — and a
4196    /// read that still names an item under a predicate this process's write moved it out of
4197    /// must not return it. The one thing the read knows that the record cannot is when GitHub
4198    /// last saw the item change, which is what a comment-activity read rules a candidate out
4199    /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4200    /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4201    fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4202        // A board draft is not an issue, so no narrowed read returns one, and this process
4203        // having written one does not make it an answer either.
4204        let own: Vec<Resolved> = self
4205            .created()?
4206            .iter()
4207            .chain(self.updated()?.iter())
4208            .filter(|own| own.content_kind == ContentKind::Issue)
4209            .cloned()
4210            .collect();
4211        for mut own in own {
4212            match found.iter_mut().find(|read| read.id == own.id) {
4213                Some(read) => {
4214                    own.updated_at = own.updated_at.max(read.updated_at);
4215                    *read = own;
4216                }
4217                None => found.push(own),
4218            }
4219        }
4220        Ok(found)
4221    }
4222
4223    /// Whether `item` has a comment created or last edited at or after `since` — always, when
4224    /// there is no instant to hold it to.
4225    ///
4226    /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4227    /// or after the instant moved it there: an issue not updated since holds no such comment,
4228    /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4229    /// only as far as the first that matches. A board draft is not an issue and has no
4230    /// comments, so it never matches.
4231    async fn commented_since(
4232        &self,
4233        item: &Resolved,
4234        since: Option<DateTime<Utc>>,
4235    ) -> Result<bool, SourceError> {
4236        let Some(since) = since else {
4237            return Ok(true);
4238        };
4239        if item.content_kind == ContentKind::DraftIssue
4240            || item.updated_at.is_some_and(|updated| updated < since)
4241        {
4242            return Ok(false);
4243        }
4244        let query = TaskQuery {
4245            commented_since: Some(since),
4246            ..TaskQuery::default()
4247        };
4248        let mut after: Option<String> = None;
4249        loop {
4250            let data = self
4251                .graphql(
4252                    graphql::ISSUE_COMMENTS,
4253                    json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4254                )
4255                .await?;
4256            let Some(connection) = data
4257                .get("node")
4258                .filter(|value| !value.is_null())
4259                .and_then(|node| node.get("comments"))
4260                .filter(|value| !value.is_null())
4261            else {
4262                // Removed since the search reported it: no longer an issue with comments.
4263                return Ok(false);
4264            };
4265            let comments = optional_nodes(Some(connection), "issue comments")?
4266                .into_iter()
4267                .flatten()
4268                .map(comment_from)
4269                .collect::<Result<Vec<_>, _>>()?;
4270            if query.comments_match(&comments) {
4271                return Ok(true);
4272            }
4273            match next_cursor(connection)? {
4274                Some(next) => {
4275                    validate_cursor_progress(after.as_deref(), &next.0)?;
4276                    after = Some(next.0);
4277                }
4278                None => return Ok(false),
4279            }
4280        }
4281    }
4282
4283    /// Every item on the board: the union of both enumerations GitHub offers of one.
4284    ///
4285    /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4286    /// board **draft** and reads the board's own fields beside its items, and only the search
4287    /// reports an item that connection is behind on. The module documentation is where the lag and the
4288    /// measurements behind it are written down.
4289    ///
4290    /// A search result is admitted on the same terms as any other issue this source reaches
4291    /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4292    /// names *this* board — so an issue the index still believes is here after it was taken
4293    /// off is refused rather than reported.
4294    ///
4295    /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4296    /// which is what the cache could otherwise have broken.
4297    async fn board(&self) -> Result<Board, SourceError> {
4298        let cached = self.board_cache()?.clone();
4299        let mut board = match cached {
4300            Some(board) => board,
4301            None => {
4302                let read = self.read_board().await?;
4303                *self.board_cache()? = Some(read.clone());
4304                read
4305            }
4306        };
4307        for held in self.searched_issues().await? {
4308            if !board.items.iter().any(|item| item.id == held.id) {
4309                board.items.push(held);
4310            }
4311        }
4312        for own in self.created()?.iter() {
4313            if !board.items.iter().any(|item| item.id == own.id) {
4314                board.items.push(own.clone());
4315            }
4316        }
4317        Ok(board)
4318    }
4319
4320    /// This process's own view of the board, or the refusal a poisoned lock is.
4321    fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4322        self.board_cache
4323            .lock()
4324            .map_err(|_| SourceError::Unavailable {
4325                message: "this source's view of the board was left inconsistent by an earlier \
4326                      failure; next: run the command again"
4327                    .into(),
4328            })
4329    }
4330
4331    /// Bring this process's own view of the board up to an item it has just written.
4332    ///
4333    /// A created item goes to `created`, which is what completes a board read GitHub's own
4334    /// eventual consistency has left behind. An item that was already there is replaced
4335    /// where it sits, so a second write of it in the same command reads its real parent
4336    /// rather than the one it had before the first write.
4337    ///
4338    /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4339    /// that wins: an item this same run created is held in `created` and not in the cached
4340    /// board, and `board` completes the cached board *from* `created`, so replacing only
4341    /// the cached copy of such an item replaces nothing and the read still reports the
4342    /// title it was created with. The search is the third, and it is the one an item the
4343    /// board's own projection is behind on sits in *alone* — which is exactly the item this
4344    /// source is least able to re-read, so leaving it out would put the stale title back on
4345    /// the only items the completion in [`Self::board`] exists for.
4346    fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4347        if created {
4348            self.created()?.push(item);
4349            return Ok(());
4350        }
4351        {
4352            let mut own = self.created()?;
4353            if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4354                *held = item;
4355                return Ok(());
4356            }
4357        }
4358        {
4359            let mut own = self.updated()?;
4360            match own.iter_mut().find(|held| held.id == item.id) {
4361                Some(held) => *held = item.clone(),
4362                None => own.push(item.clone()),
4363            }
4364        }
4365        if let Some(board) = self.board_cache()?.as_mut()
4366            && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4367        {
4368            *held = item.clone();
4369        }
4370        if let Some(found) = self.search_cache()?.as_mut()
4371            && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4372        {
4373            *held = item.clone();
4374        }
4375        for found in self.narrowed_cache()?.values_mut() {
4376            if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4377                *held = item.clone();
4378            }
4379        }
4380        Ok(())
4381    }
4382
4383    /// Forget one item this process has just deleted, from every half of its own view.
4384    fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4385        self.created()?.retain(|own| own.id != *id);
4386        self.updated()?.retain(|own| own.id != *id);
4387        if let Some(board) = self.board_cache()?.as_mut() {
4388            board.items.retain(|item| item.id != *id);
4389        }
4390        if let Some(found) = self.search_cache()?.as_mut() {
4391            found.retain(|item| item.id != *id);
4392        }
4393        for found in self.narrowed_cache()?.values_mut() {
4394            found.retain(|item| item.id != *id);
4395        }
4396        Ok(())
4397    }
4398
4399    /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4400    fn narrowed_cache(
4401        &self,
4402    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4403        self.narrowed_cache
4404            .lock()
4405            .map_err(|_| SourceError::Unavailable {
4406                message: "this source's view of a narrowed read was left inconsistent by an \
4407                      earlier failure; next: run the command again"
4408                    .into(),
4409            })
4410    }
4411
4412    /// Every page of the board, read from GitHub.
4413    async fn read_board(&self) -> Result<Board, SourceError> {
4414        let mut after: Option<String> = None;
4415        let mut items = Vec::new();
4416        let mut board;
4417        loop {
4418            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
4419            for item in page
4420                .pointer("/items/nodes")
4421                .and_then(Value::as_array)
4422                .ok_or_else(|| SourceError::Malformed {
4423                    message: "GitHub project items.nodes is not an array".into(),
4424                })?
4425            {
4426                if let Some(resolved) = self.resolve(item)? {
4427                    items.push(resolved);
4428                }
4429            }
4430            let info = page
4431                .pointer("/items/pageInfo")
4432                .ok_or_else(|| SourceError::Malformed {
4433                    message: "GitHub project items have no pageInfo".into(),
4434                })?;
4435            let has_next = required_bool(info, "hasNextPage")?;
4436            let next = has_next
4437                .then(|| required_str(info, "endCursor"))
4438                .transpose()?;
4439            board = page.clone();
4440            match next {
4441                Some(next) => {
4442                    validate_cursor_progress(after.as_deref(), next)?;
4443                    after = Some(next.to_owned());
4444                }
4445                None => break,
4446            }
4447        }
4448        Ok(Board {
4449            id: required_str(&board, "id")?.to_owned(),
4450            fields: board.get("fields").cloned().unwrap_or(Value::Null),
4451            items,
4452        })
4453    }
4454
4455    /// The existing items this source has written, for completing a narrowed read that is
4456    /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
4457    fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4458        self.updated.lock().map_err(|_| SourceError::Unavailable {
4459            message: "this source's record of what it wrote in this run was left inconsistent \
4460                      by an earlier failure; next: run the command again"
4461                .into(),
4462        })
4463    }
4464
4465    /// The items this source has created, for completing a board read that is behind.
4466    fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4467        self.created.lock().map_err(|_| SourceError::Unavailable {
4468            message: "this source's record of what it created in this run was left \
4469                      inconsistent by an earlier failure; next: run the command again"
4470                .into(),
4471        })
4472    }
4473
4474    /// One board item as this source reports it, or `None` for content it ignores.
4475    ///
4476    /// A pull request is neither a project nor a task — it is somebody's change, not a
4477    /// unit of plan — and an item whose content the token cannot see has nothing to
4478    /// report at all.
4479    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
4480        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
4481            message: "GitHub project item is missing content".into(),
4482        })?;
4483        if content.is_null() {
4484            return Ok(None);
4485        }
4486        let content_kind = match required_str(content, "__typename")? {
4487            "Issue" => ContentKind::Issue,
4488            "DraftIssue" => ContentKind::DraftIssue,
4489            _ => return Ok(None),
4490        };
4491        let field_values = item
4492            .get("fieldValues")
4493            .ok_or_else(|| SourceError::Malformed {
4494                message: "GitHub project item is missing fieldValues".into(),
4495            })?;
4496        complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
4497        let nodes = field_values
4498            .get("nodes")
4499            .and_then(Value::as_array)
4500            .ok_or_else(|| SourceError::Malformed {
4501                message: "GitHub project item fieldValues.nodes is not an array".into(),
4502            })?;
4503        if let Some(labels) = content.get("labels") {
4504            complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
4505        }
4506        let raw_body = optional_str(content, "body")?.map(str::to_owned);
4507        let (body, slot) = metadata_body(raw_body.clone())?;
4508        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
4509            .map(|id| NativeId(id.to_owned()));
4510        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
4511        // to read one from; it is a task, and never a project.
4512        let sub_issues = match content_kind {
4513            ContentKind::Issue => sub_issue_total(content)?,
4514            ContentKind::DraftIssue => 0,
4515        };
4516        let content_id = required_str(content, "id")?;
4517        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
4518            message: format!("GitHub issue {content_id}: {message}"),
4519        })?;
4520        let raw_title = required_str(content, "title")?;
4521        // The design prefix is read *first*, before either of the two rules that separate
4522        // a project from a task. A document is not work whatever sub-issues it has and
4523        // whatever marker it carries, and reading the prefix later would make a design
4524        // issue with none of either an empty project.
4525        let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
4526            BoardKind::Document
4527        } else if parent.is_some() {
4528            // Being a sub-issue wins outright, and no marker overrides it: an issue filed
4529            // under a project is that project's task even when it has sub-issues of its
4530            // own.
4531            BoardKind::Work(ItemKind::Task)
4532        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
4533            BoardKind::Work(ItemKind::Project)
4534        } else {
4535            BoardKind::Work(ItemKind::Task)
4536        };
4537        // The title a person wrote, which for a document is the one without the prefix —
4538        // the same way `content` above is the body without this source's metadata slot.
4539        let title = match kind {
4540            BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
4541            BoardKind::Work(_) => raw_title.to_owned(),
4542        };
4543        let own_repository = content
4544            .pointer("/repository/nameWithOwner")
4545            .and_then(Value::as_str)
4546            .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
4547            .transpose()
4548            .map_err(|message| SourceError::Malformed { message })?;
4549        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
4550            Repository::from_metadata(&slot)
4551                .map_err(|message| SourceError::Malformed { message })?
4552        } else {
4553            own_repository.clone().into_iter().collect()
4554        };
4555        let id = NativeId(content_id.to_owned());
4556        // Read only for a task, because only a task has either list: a project or a
4557        // document holding one of these keys holds nothing this source reports, and the
4558        // keys are left out of its caller-visible metadata all the same.
4559        let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
4560            let listed = |key: &str| {
4561                TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
4562                    .map_err(|message| SourceError::Malformed { message })
4563            };
4564            (
4565                listed(TaskRef::DELIVERS_KEY)?,
4566                listed(TaskRef::DELIVERED_BY_KEY)?,
4567            )
4568        } else {
4569            (Vec::new(), Vec::new())
4570        };
4571        let (option, closed, reason) = Self::status_parts(nodes, content)?;
4572        let priority = self.held_priority(nodes)?;
4573        Ok(Some(Resolved {
4574            item_id: required_str(item, "id")?.to_owned(),
4575            id,
4576            content_kind,
4577            kind,
4578            title,
4579            body: body.filter(|value| !value.is_empty()),
4580            raw_body,
4581            status: self.statuses.status(option, closed, reason),
4582            option: option.map(str::to_owned),
4583            priority,
4584            closed,
4585            delivers,
4586            delivered_by,
4587            labels: labels(content)?,
4588            parent,
4589            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
4590            number: match content_kind {
4591                ContentKind::Issue => Some(issue_number(content)?),
4592                // A draft is filed in no repository, so nothing ever numbered it:
4593                // `DraftIssue` declares no `number` at all, exactly as it declares no
4594                // `subIssuesSummary` the branch above reads.
4595                ContentKind::DraftIssue => None,
4596            },
4597            url: optional_str(content, "url")?.map(str::to_owned),
4598            created_at: optional_time(content, "createdAt")?,
4599            updated_at: optional_time(content, "updatedAt")?,
4600            own_repository,
4601            repositories,
4602            slot,
4603            // Present when the item was reached through its own issue, whose board entry
4604            // names the board; a read of the board's own items has the board already. An
4605            // empty id names nothing a field write could address, so it is read as absent and
4606            // the write goes back to reading the board.
4607            board_id: item
4608                .pointer("/project/id")
4609                .and_then(Value::as_str)
4610                .filter(|id| !id.is_empty())
4611                .map(str::to_owned),
4612            fields: field_definitions(nodes),
4613        }))
4614    }
4615
4616    /// What one board item's `Priority` field says, through this instance's mapping.
4617    ///
4618    /// An instance with no mapping holds no priority, so every item reads as `none` whatever
4619    /// its board holds. With one, no value is `none`, a mapped option is its level, and an
4620    /// option the mapping does not name is kept as itself — never read as a level or as
4621    /// `none` — for a read of the task to report by name.
4622    fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
4623        let Some(mapping) = &self.priorities else {
4624            return Ok(HeldPriority::Read(Priority::None));
4625        };
4626        // A value of the field that names no option — a text field someone called `Priority` —
4627        // is malformed rather than `none`: reading it as no priority would let the next copy
4628        // clear one a person set.
4629        let Some(option) = field_values
4630            .iter()
4631            .find(|value| {
4632                value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
4633            })
4634            .map(|value| required_str(value, "name"))
4635            .transpose()?
4636        else {
4637            return Ok(HeldPriority::Read(Priority::None));
4638        };
4639        Ok(mapping.priority_of(option).map_or_else(
4640            || HeldPriority::Unmapped(option.to_owned()),
4641            HeldPriority::Read,
4642        ))
4643    }
4644
4645    /// What one board item's status is read from: its `Status` option, whether its issue
4646    /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
4647    /// three into the status it reports.
4648    fn status_parts<'a>(
4649        field_values: &'a [Value],
4650        content: &'a Value,
4651    ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
4652        let option = field_values
4653            .iter()
4654            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
4655            .map(|value| required_str(value, "name"))
4656            .transpose()?;
4657        let closed = optional_str(content, "state")? == Some("CLOSED");
4658        Ok((option, closed, optional_str(content, "stateReason")?))
4659    }
4660
4661    /// The board Status option this write selects, or the refusal that says why not.
4662    ///
4663    /// The mapped option is required for both open and terminal targets. A terminal write
4664    /// validates it before changing either representation, so it can never fall back to
4665    /// closing an issue whose board cannot display the matching status.
4666    ///
4667    /// Answers the field's id, the option's id, and the option's name as the board spells
4668    /// it — which is the name a read of the item reports once it sits there.
4669    fn column_for(
4670        &self,
4671        fields: &Value,
4672        status: &Status,
4673        target: &StatusTarget,
4674    ) -> Result<Option<(String, String, String)>, SourceError> {
4675        let wanted = match target {
4676            StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
4677            StatusTarget::Disabled => return Ok(None),
4678        };
4679        let missing = |detail: &str| SourceError::Refused {
4680            message: format!(
4681                "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",
4682                category_name(status.category),
4683                self.name,
4684                category_name(status.category)
4685            ),
4686        };
4687        let Some(field) = Board::field(fields, "Status")? else {
4688            return Err(missing("this board has no Status field"));
4689        };
4690        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4691            return Err(missing(
4692                "this board's Status field is not a single-select field",
4693            ));
4694        }
4695        let option = field
4696            .get("options")
4697            .and_then(Value::as_array)
4698            .and_then(|options| {
4699                options.iter().find(|option| {
4700                    option
4701                        .get("name")
4702                        .and_then(Value::as_str)
4703                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4704                })
4705            });
4706        match option {
4707            None => Err(missing("this board does not have it")),
4708            Some(option) => Ok(Some((
4709                required_str(field, "id")?.to_owned(),
4710                required_str(option, "id")?.to_owned(),
4711                required_str(option, "name")?.to_owned(),
4712            ))),
4713        }
4714    }
4715
4716    /// The refusal a status that closes an issue is answered with over a board draft.
4717    fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
4718        SourceError::Refused {
4719            message: format!(
4720                "status {} of source {} closes the item's issue, and GitHub draft items have \
4721                 no open or closed state",
4722                category_name(category),
4723                self.name
4724            ),
4725        }
4726    }
4727
4728    /// What a status write to one item needs of the board: the board's id and the
4729    /// definition of its `Status` field, read off the item when the item says both.
4730    ///
4731    /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
4732    /// and its `Status` value carries that field's definition, options and all. An item that
4733    /// does not say — no board id, or no `Status` value to read the field off — takes them
4734    /// from [`Self::board_fields`], which reads no item.
4735    async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
4736        if item.defines("Status")
4737            && let Some(board_id) = item.named_board()
4738        {
4739            return Ok(BoardFields {
4740                id: board_id,
4741                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4742            });
4743        }
4744        self.board_fields().await
4745    }
4746
4747    /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
4748    async fn set_status(
4749        &self,
4750        id: &NativeId,
4751        category: StatusCategory,
4752    ) -> Result<Option<Status>, SourceError> {
4753        // Refused before anything is read, in the words a write of the same status is.
4754        let target = self.resolved_target(category)?;
4755        let Some(mut item) = self
4756            .item_by_id(id)
4757            .await?
4758            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4759        else {
4760            return Ok(None);
4761        };
4762        let board = self.status_board(&item).await?;
4763        let wanted = Status {
4764            category,
4765            name: category_name(category).to_owned(),
4766        };
4767        let (field, option, name) = self
4768            .column_for(&board.fields, &wanted, &target)?
4769            .ok_or_else(|| SourceError::Malformed {
4770                message: format!(
4771                    "status {} of source {} names no board Status option",
4772                    category_name(category),
4773                    self.name
4774                ),
4775            })?;
4776        match &target {
4777            StatusTarget::Terminal(_, reason) => {
4778                if item.content_kind == ContentKind::DraftIssue {
4779                    return Err(self.closes_a_draft(category));
4780                }
4781                self.set_item_field(
4782                    board.id.as_str(),
4783                    &item.item_id,
4784                    &field,
4785                    json!({"singleSelectOptionId": option}),
4786                )
4787                .await?;
4788                self.update_content(
4789                    ContentKind::Issue,
4790                    &item.id,
4791                    json!({"stateInput": state_input(Some(&target))}),
4792                )
4793                .await?;
4794                item.closed = true;
4795                item.status = self
4796                    .statuses
4797                    .status(Some(&name), true, Some(reason.reason()));
4798                item.option = Some(name);
4799            }
4800            StatusTarget::Column(_) => {
4801                // An option is what an open item's status is, so a closed issue is reopened
4802                // first — sitting closed in the column, it would read back as closed. A draft has
4803                // no state to reopen.
4804                if item.content_kind == ContentKind::Issue && item.closed {
4805                    self.update_content(
4806                        ContentKind::Issue,
4807                        &item.id,
4808                        json!({"stateInput": state_input(Some(&target))}),
4809                    )
4810                    .await?;
4811                    item.closed = false;
4812                }
4813                self.set_item_field(
4814                    board.id.as_str(),
4815                    &item.item_id,
4816                    &field,
4817                    json!({"singleSelectOptionId": option}),
4818                )
4819                .await?;
4820                item.status = self.statuses.status(Some(&name), false, None);
4821                item.option = Some(name);
4822            }
4823            StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
4824        }
4825        let status = item.status.clone();
4826        self.remember_written(item, false)?;
4827        Ok(Some(status))
4828    }
4829
4830    /// Replace one task's `delivered_by` and nothing else; see
4831    /// [`TaskSource::set_delivered_by`].
4832    ///
4833    /// One update of the body, which differs from the body GitHub holds only inside the
4834    /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
4835    async fn replace_delivered_by(
4836        &self,
4837        id: &NativeId,
4838        delivered_by: &[TaskRef],
4839    ) -> Result<Option<()>, SourceError> {
4840        let entries = TaskRef::listed(
4841            TaskRef::DELIVERED_BY_KEY,
4842            id,
4843            Some(&self.name),
4844            delivered_by.to_vec(),
4845        )
4846        .map_err(|message| SourceError::Refused { message })?;
4847        let Some(mut item) = self
4848            .item_by_id(id)
4849            .await?
4850            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4851        else {
4852            return Ok(None);
4853        };
4854        let mut slot = item.slot.clone();
4855        set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
4856        self.write_slot(&mut item, &slot).await?;
4857        item.delivered_by = entries;
4858        self.remember_written(item, false)?;
4859        Ok(Some(()))
4860    }
4861
4862    /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
4863    /// see [`TaskSource::set_task_metadata`].
4864    ///
4865    /// `None` when this board holds no item by that id, or holds one of another kind. The
4866    /// answer is the item as this source now reads it, so what a caller is told the key
4867    /// holds is what the slot holds.
4868    ///
4869    /// A key already holding the value is answered without a write, compared as JSON rather
4870    /// than as the body's bytes: a slot a person spelled with other whitespace would
4871    /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
4872    async fn set_slot_key(
4873        &self,
4874        id: &NativeId,
4875        kind: BoardKind,
4876        key: &MetadataKey,
4877        value: &Value,
4878    ) -> Result<Option<Resolved>, SourceError> {
4879        let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
4880            return Ok(None);
4881        };
4882        if item.slot.get(key.as_str()) == Some(value) {
4883            return Ok(Some(item));
4884        }
4885        let mut slot = item.slot.clone();
4886        slot.insert(key.as_str().to_owned(), value.clone());
4887        self.write_slot(&mut item, &slot).await?;
4888        self.remember_written(item.clone(), false)?;
4889        Ok(Some(item))
4890    }
4891
4892    /// Put `slot` in one item's metadata slot with a single update of its body, and bring
4893    /// `item` up to what that write left.
4894    ///
4895    /// The body sent differs from the body GitHub holds only inside the slot — see
4896    /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
4897    /// the mutation the item's content takes, so a board draft's body is written with
4898    /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
4899    async fn write_slot(
4900        &self,
4901        item: &mut Resolved,
4902        slot: &BTreeMap<String, Value>,
4903    ) -> Result<(), SourceError> {
4904        let held = item.raw_body.clone().unwrap_or_default();
4905        let body = with_slot(&held, slot)?;
4906        if body != held {
4907            self.update_content(item.content_kind, &item.id, json!({"body": body}))
4908                .await?;
4909        }
4910        let (visible, slot) = metadata_body(Some(body.clone()))?;
4911        item.body = visible.filter(|value| !value.is_empty());
4912        item.raw_body = Some(body);
4913        item.slot = slot;
4914        Ok(())
4915    }
4916
4917    /// This instance's target for a category, refusing one it has disabled.
4918    ///
4919    /// Nothing here mutates the board's option set to make room for a status. GitHub
4920    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
4921    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
4922    /// field and every item's status.
4923    fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
4924        let target = self.statuses.target(category).clone();
4925        if target != StatusTarget::Disabled {
4926            return Ok(target);
4927        }
4928        Err(SourceError::Refused {
4929            message: if category == StatusCategory::Draft {
4930                format!(
4931                    "status draft is disabled for source {}: draft is incompatible with this \
4932                     integration because GitHub draft issues cannot have sub-issues, and this \
4933                     source stores a project's tasks as its issue's sub-issues",
4934                    self.name
4935                )
4936            } else if category == StatusCategory::Unknown {
4937                format!(
4938                    "status {} is disabled for source {}; set status_mapping.{} of this source \
4939                     to one board Status option name; every word classified unknown is written \
4940                     to that one option",
4941                    category_name(category),
4942                    self.name,
4943                    category_name(category)
4944                )
4945            } else {
4946                format!(
4947                    "status {} is disabled for source {}; set status_mapping.{} of this source \
4948                     to a board Status option name",
4949                    category_name(category),
4950                    self.name,
4951                    category_name(category)
4952                )
4953            },
4954        })
4955    }
4956
4957    /// What writing `priority` does to one item's `Priority` field on this board, or the
4958    /// refusal naming what the board lacks.
4959    ///
4960    /// `none` is no value, so it clears the field — and asks nothing of an item that holds
4961    /// none already, or of an item not created yet. Every other priority selects the option
4962    /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
4963    /// without that option, is refused rather than given one: reads and writes never create
4964    /// a field or an option.
4965    fn priority_write(
4966        &self,
4967        fields: &Value,
4968        existing: Option<&Resolved>,
4969        priority: Priority,
4970    ) -> Result<Option<PriorityWrite>, SourceError> {
4971        let Some(mapping) = &self.priorities else {
4972            return Err(self.holds_no_priority());
4973        };
4974        let Some(wanted) = mapping.option(priority) else {
4975            if !existing.is_some_and(Resolved::holds_priority) {
4976                return Ok(None);
4977            }
4978            let field =
4979                Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
4980                    message: format!(
4981                        "an item holding a {PRIORITY_FIELD} value was read without that field"
4982                    ),
4983                })?;
4984            return Ok(Some(PriorityWrite::Clear {
4985                field: required_str(field, "id")?.to_owned(),
4986            }));
4987        };
4988        let missing = |detail: &str| SourceError::Refused {
4989            message: format!(
4990                "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
4991                 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
4992                 it, or point priority_mapping.{priority} of this source at an option the board \
4993                 has",
4994                self.name, self.name
4995            ),
4996        };
4997        let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
4998            return Err(missing(&format!(
4999                "this board has no {PRIORITY_FIELD} field"
5000            )));
5001        };
5002        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5003            return Err(missing(&format!(
5004                "this board's {PRIORITY_FIELD} field is not a single-select field"
5005            )));
5006        }
5007        // An options list that is absent or not a list is an answer this source cannot read,
5008        // not a board lacking the option: `sources fields --apply` is no remedy for it.
5009        let option = field
5010            .get("options")
5011            .and_then(Value::as_array)
5012            .ok_or_else(|| SourceError::Malformed {
5013                message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5014            })?
5015            .iter()
5016            .find(|option| {
5017                option
5018                    .get("name")
5019                    .and_then(Value::as_str)
5020                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5021            })
5022            .ok_or_else(|| missing("this board does not have it"))?;
5023        Ok(Some(PriorityWrite::Select {
5024            field: required_str(field, "id")?.to_owned(),
5025            option: required_str(option, "id")?.to_owned(),
5026        }))
5027    }
5028
5029    /// Apply one priority write to one board item.
5030    async fn write_priority(
5031        &self,
5032        board_id: &str,
5033        item_id: &str,
5034        write: &PriorityWrite,
5035    ) -> Result<(), SourceError> {
5036        match write {
5037            PriorityWrite::Select { field, option } => {
5038                self.set_item_field(
5039                    board_id,
5040                    item_id,
5041                    field,
5042                    json!({"singleSelectOptionId": option}),
5043                )
5044                .await
5045            }
5046            PriorityWrite::Clear { field } => {
5047                let data = self
5048                    .graphql(
5049                        graphql::CLEAR_FIELD,
5050                        json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field}}),
5051                    )
5052                    .await?;
5053                let returned = data
5054                    .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5055                    .ok_or_else(|| SourceError::Malformed {
5056                        message: "GitHub field clear returned no project item".into(),
5057                    })?;
5058                if required_str(returned, "id")? != item_id {
5059                    return Err(SourceError::Malformed {
5060                        message: "GitHub field clear returned the wrong project item".into(),
5061                    });
5062                }
5063                Ok(())
5064            }
5065        }
5066    }
5067
5068    /// The refusal a priority is answered with by an instance configured with no
5069    /// `priority_mapping`, which holds none.
5070    fn holds_no_priority(&self) -> SourceError {
5071        SourceError::Refused {
5072            message: format!(
5073                "source {} holds no task priority: its configuration sets no priority_mapping; \
5074                 next: set priority_mapping on this source, then run `onetaskgraph sources \
5075                 fields {} --apply` to set its board up",
5076                self.name, self.name
5077            ),
5078        }
5079    }
5080
5081    /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5082    ///
5083    /// One field write — a select, or a clear for `none` — and no title, body, label, state
5084    /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5085    async fn set_priority(
5086        &self,
5087        id: &NativeId,
5088        priority: Priority,
5089    ) -> Result<Option<Priority>, SourceError> {
5090        if self.priorities.is_none() {
5091            return Err(self.holds_no_priority());
5092        }
5093        let Some(item) = self
5094            .item_by_id(id)
5095            .await?
5096            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5097        else {
5098            return Ok(None);
5099        };
5100        if priority == Priority::None && !item.holds_priority() {
5101            return Ok(Some(priority));
5102        }
5103        // The item's own read carries the field's definition whenever it holds a value of
5104        // it, which a clear always does; a select onto an item holding none reads the board.
5105        let board = match item.named_board() {
5106            Some(id) if item.defines(PRIORITY_FIELD) => BoardFields {
5107                id,
5108                fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5109            },
5110            _ => self.board_fields().await?,
5111        };
5112        let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5113            return Ok(Some(priority));
5114        };
5115        self.write_priority(board.id.as_str(), &item.item_id, &write)
5116            .await?;
5117        // Read back rather than echoed: the answer is what the board now holds, read by the
5118        // item's own id — strongly consistent, unlike a search — and past what this run
5119        // remembers writing, so a write the board did not keep is reported as it stands.
5120        let read = match self.reach(id).await? {
5121            Reached::Held(item) => Some(*item),
5122            Reached::Draft => self.draft_by_id(id).await?,
5123            Reached::Nothing => None,
5124        }
5125        .ok_or_else(|| SourceError::Malformed {
5126            message: format!("task {id} was written and then could not be read back"),
5127        })?;
5128        let answer = read.task()?.priority;
5129        self.remember_written(read, false)?;
5130        Ok(Some(answer))
5131    }
5132
5133    /// Replace one task's visible body and nothing else; see
5134    /// [`TaskSource::set_task_content`].
5135    ///
5136    /// One update of the body, which differs from the body GitHub holds only outside the
5137    /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5138    /// this source keeps there reads back as it was. A body that would not change is not
5139    /// sent at all.
5140    async fn replace_content(
5141        &self,
5142        id: &NativeId,
5143        content: &str,
5144    ) -> Result<Option<()>, SourceError> {
5145        let Some(mut item) = self
5146            .item_by_id(id)
5147            .await?
5148            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5149        else {
5150            return Ok(None);
5151        };
5152        let held = item.raw_body.clone().unwrap_or_default();
5153        let body = with_content(&held, content)?;
5154        // Checked before anything is sent: content ending in what this source reads as its own
5155        // metadata slot would read back as metadata rather than as the content it was.
5156        let (visible, slot) = metadata_body(Some(body.clone()))?;
5157        if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5158            return Err(SourceError::Refused {
5159                message: format!(
5160                    "this content ends in what source {} reads as its own metadata slot \
5161                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5162                     as content; next: remove that trailing block from the content",
5163                    self.name
5164                ),
5165            });
5166        }
5167        if body != held {
5168            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5169                .await?;
5170        }
5171        item.body = visible.filter(|value| !value.is_empty());
5172        item.raw_body = Some(body);
5173        item.slot = slot;
5174        self.remember_written(item, false)?;
5175        Ok(Some(()))
5176    }
5177
5178    /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5179    ///
5180    /// One read of the item, and then only what differs from it: at most one `updateIssue`
5181    /// carrying the title, the body — visible content and metadata slot together — and a
5182    /// state change, at most one `Status` option write and one `Priority` field write, and the
5183    /// `blockedBy` additions and removals the named edges differ by. A terminal status selects
5184    /// its option and then closes, as a whole write does; an open one reopens and then selects
5185    /// its option, as [`Self::set_status`] does. The origin field is never written: an update
5186    /// is of an item that already exists, whose origin is what it is.
5187    ///
5188    /// The task answered is the item as those writes left it, built from the read and what was
5189    /// sent rather than read again — the same record a later read in this run answers from.
5190    async fn targeted_update(
5191        &self,
5192        id: &NativeId,
5193        update: &TaskUpdate,
5194    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5195        // Everything this source can refuse without reading the item is refused first, in the
5196        // words a whole write of the same fields is refused with.
5197        update.consistent()?;
5198        if update
5199            .title
5200            .as_deref()
5201            .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5202        {
5203            return Err(SourceError::Refused {
5204                message: format!(
5205                    "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5206                     spells a document, so it would read back as one rather than as a task; \
5207                     retitle it",
5208                    self.name
5209                ),
5210            });
5211        }
5212        if let Some(delivers) = &update.delivers {
5213            TaskRef::listed(
5214                TaskRef::DELIVERS_KEY,
5215                id,
5216                Some(&self.name),
5217                delivers.clone(),
5218            )
5219            .map_err(|message| SourceError::Refused { message })?;
5220        }
5221        if self.priorities.is_none()
5222            && update
5223                .priority
5224                .is_some_and(|priority| priority != Priority::None)
5225        {
5226            return Err(self.holds_no_priority());
5227        }
5228        let target = update
5229            .status
5230            .as_ref()
5231            .map(|status| self.resolved_target(status.category))
5232            .transpose()?;
5233        let Some(mut item) = self
5234            .item_by_id(id)
5235            .await?
5236            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5237        else {
5238            return Ok(None);
5239        };
5240        let before = item.task()?;
5241
5242        let mut status_move = None;
5243        if let (Some(status), Some(target)) = (&update.status, target) {
5244            let board = self.status_board(&item).await?;
5245            let (field, option, name) = self
5246                .column_for(&board.fields, status, &target)?
5247                .ok_or_else(|| SourceError::Malformed {
5248                    message: format!(
5249                        "status {} of source {} names no board Status option",
5250                        category_name(status.category),
5251                        self.name
5252                    ),
5253                })?;
5254            let terminal = matches!(target, StatusTarget::Terminal(_, _));
5255            if terminal && item.content_kind == ContentKind::DraftIssue {
5256                return Err(self.closes_a_draft(status.category));
5257            }
5258            let landed = match &target {
5259                StatusTarget::Terminal(_, reason) => {
5260                    self.statuses
5261                        .status(Some(&name), true, Some(reason.reason()))
5262                }
5263                _ => self.statuses.status(Some(&name), false, None),
5264            };
5265            let option_moves = item
5266                .option
5267                .as_deref()
5268                .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5269            let state_moves = item.content_kind == ContentKind::Issue
5270                && (item.closed != terminal || (terminal && item.status != landed));
5271            if let Some(moves) = Moves::of(option_moves, state_moves) {
5272                status_move = Some(StatusMove {
5273                    board: board.id,
5274                    field,
5275                    option,
5276                    name,
5277                    target,
5278                    landed,
5279                    moves,
5280                });
5281            }
5282        }
5283
5284        let mut priority_move = None;
5285        if let Some(priority) = update.priority
5286            && self.priorities.is_some()
5287            && item.priority != HeldPriority::Read(priority)
5288        {
5289            let board = match item.named_board() {
5290                Some(board) if item.defines(PRIORITY_FIELD) => BoardFields {
5291                    id: board,
5292                    fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5293                },
5294                _ => self.board_fields().await?,
5295            };
5296            if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5297                priority_move = Some((board.id, write, priority));
5298            }
5299        }
5300
5301        // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5302        // recorded in the slot, and the slot travels in the one body update below.
5303        let edges = match &update.depends_on {
5304            Some(edges) => Some(
5305                self.partition_edges(BoardKind::Work(ItemKind::Task), item.content_kind, edges)
5306                    .await?,
5307            ),
5308            None => None,
5309        };
5310
5311        let mut slot = item.slot.clone();
5312        for (key, value) in &update.metadata_set {
5313            slot.insert(key.as_str().to_owned(), value.clone());
5314        }
5315        for key in &update.metadata_remove {
5316            slot.remove(key.as_str());
5317        }
5318        if let Some(delivers) = &update.delivers {
5319            set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
5320        }
5321        if let Some((_, recorded)) = &edges {
5322            record_edges(&mut slot, recorded);
5323        }
5324        let held = item.raw_body.clone().unwrap_or_default();
5325        let content = match &update.content {
5326            Some(content) => with_content(&held, content)?,
5327            None => held.clone(),
5328        };
5329        // A slot holding what it held is kept byte for byte, compared as JSON rather than as
5330        // the body's bytes, as a metadata write compares it: a slot a person spelled with
5331        // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
5332        let body = if slot == item.slot {
5333            content
5334        } else {
5335            with_slot(&content, &slot)?
5336        };
5337        // Checked before anything is sent, as a content write checks it: content ending in
5338        // what this source reads as its own slot would read back as metadata.
5339        let (visible, read) = metadata_body(Some(body.clone()))?;
5340        let wanted = update.content.as_deref().or(item.body.as_deref());
5341        if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
5342            return Err(SourceError::Refused {
5343                message: format!(
5344                    "this content ends in what source {} reads as its own metadata slot \
5345                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5346                     as content; next: remove that trailing block from the content",
5347                    self.name
5348                ),
5349            });
5350        }
5351        let recorded_moves =
5352            slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
5353
5354        // One `updateIssue` carries all three, because every mutation spends the secondary
5355        // limiter and the title, body and state are one mutation's inputs.
5356        let mut fields = serde_json::Map::new();
5357        if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
5358            fields.insert("title".to_owned(), json!(title));
5359        }
5360        if body != held {
5361            fields.insert("body".to_owned(), json!(body));
5362        }
5363        if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
5364            fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
5365        }
5366        let terminal = status_move
5367            .as_ref()
5368            .is_some_and(|moving| matches!(moving.target, StatusTarget::Terminal(_, _)));
5369        // A terminal option is selected before the issue closes, so a close never lands on an
5370        // item whose board cannot show it; an open one after the issue reopens.
5371        if terminal {
5372            self.select_option(&item, status_move.as_ref()).await?;
5373        }
5374        if !fields.is_empty() {
5375            self.update_content(item.content_kind, &item.id, Value::Object(fields))
5376                .await?;
5377        }
5378        if !terminal {
5379            self.select_option(&item, status_move.as_ref()).await?;
5380        }
5381        if let Some((board, write, _)) = &priority_move {
5382            self.write_priority(board.as_str(), &item.item_id, write)
5383                .await?;
5384        }
5385        let mut blocked_by_moved = false;
5386        if let Some((native, _)) = &edges
5387            && item.content_kind == ContentKind::Issue
5388        {
5389            blocked_by_moved = self
5390                .reconcile_blocked_by(&item.id, native, Issue::Existing)
5391                .await?;
5392        }
5393
5394        if let Some(title) = &update.title {
5395            item.title.clone_from(title);
5396        }
5397        item.body = visible.filter(|value| !value.is_empty());
5398        item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
5399        item.slot = slot;
5400        if let Some(delivers) = &update.delivers {
5401            item.delivers.clone_from(delivers);
5402        }
5403        if let Some(moving) = status_move {
5404            item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
5405                && item.content_kind == ContentKind::Issue;
5406            item.status = moving.landed;
5407            item.option = Some(moving.name);
5408        }
5409        if let Some((_, _, priority)) = priority_move {
5410            item.priority = HeldPriority::Read(priority);
5411        }
5412        let task = item.task()?;
5413        let mut written = update.changed(&before, &task);
5414        if blocked_by_moved || recorded_moves {
5415            written.insert(UpdatedField::DependsOn);
5416        }
5417        self.remember_written(item, false)?;
5418        Ok(Some(TaskUpdateOutcome {
5419            task,
5420            written,
5421            delivers_before: before.delivers,
5422        }))
5423    }
5424
5425    /// Select the `Status` option one targeted update moves an item to, when it moves it.
5426    async fn select_option(
5427        &self,
5428        item: &Resolved,
5429        moving: Option<&StatusMove>,
5430    ) -> Result<(), SourceError> {
5431        let Some(moving) = moving.filter(|moving| moving.moves.option()) else {
5432            return Ok(());
5433        };
5434        self.set_item_field(
5435            moving.board.as_str(),
5436            &item.item_id,
5437            &moving.field,
5438            json!({"singleSelectOptionId": moving.option}),
5439        )
5440        .await
5441    }
5442
5443    /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
5444    /// together, and nothing else; see [`TaskSource::set_task_rendering`].
5445    ///
5446    /// One update of the body: the content outside the slot, and inside it that one entry,
5447    /// every other entry kept as it was. This source keeps no template answers — an issue has
5448    /// no room beside itself that is not its body, and answers written there would duplicate
5449    /// what the content already says and count against GitHub's body limit — so `answers`
5450    /// reaches nothing here. A body that would not change is not sent at all.
5451    async fn replace_rendering(
5452        &self,
5453        id: &NativeId,
5454        kind: BoardKind,
5455        content: &str,
5456        provenance: &Value,
5457    ) -> Result<Option<()>, SourceError> {
5458        let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
5459            return Ok(None);
5460        };
5461        let held = item.raw_body.clone().unwrap_or_default();
5462        let mut slot = item.slot.clone();
5463        slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
5464        let body = with_slot(&with_content(&held, content)?, &slot)?;
5465        // Checked before anything is sent, as a content write checks it.
5466        let (visible, read) = metadata_body(Some(body.clone()))?;
5467        if visible.as_deref().unwrap_or_default() != content || read != slot {
5468            return Err(SourceError::Refused {
5469                message: format!(
5470                    "this content ends in what source {} reads as its own metadata slot \
5471                     ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5472                     as content; next: remove that trailing block from the template",
5473                    self.name
5474                ),
5475            });
5476        }
5477        if body != held {
5478            self.update_content(item.content_kind, &item.id, json!({"body": body}))
5479                .await?;
5480        }
5481        item.body = visible.filter(|value| !value.is_empty());
5482        item.raw_body = Some(body);
5483        item.slot = read;
5484        self.remember_written(item, false)?;
5485        Ok(Some(()))
5486    }
5487
5488    async fn set_item_field(
5489        &self,
5490        board_id: &str,
5491        item_id: &str,
5492        field_id: &str,
5493        value: Value,
5494    ) -> Result<(), SourceError> {
5495        let data = self
5496            .graphql(
5497                graphql::UPDATE_FIELD,
5498                json!({"input":{
5499                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
5500                }}),
5501            )
5502            .await?;
5503        let returned = data
5504            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
5505            .ok_or_else(|| SourceError::Malformed {
5506                message: "GitHub field update returned no project item".into(),
5507            })?;
5508        if required_str(returned, "id")? != item_id {
5509            return Err(SourceError::Malformed {
5510                message: "GitHub field update returned the wrong project item".into(),
5511            });
5512        }
5513        Ok(())
5514    }
5515
5516    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
5517        let mut after: Option<String> = None;
5518        let mut ids = Vec::new();
5519        loop {
5520            let data = self
5521                .graphql(
5522                    graphql::ISSUE_DEPENDENCIES,
5523                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
5524                )
5525                .await?;
5526            let connection =
5527                data.pointer("/node/blockedBy")
5528                    .ok_or_else(|| SourceError::Malformed {
5529                        message: "GitHub dependency response has no blockedBy connection".into(),
5530                    })?;
5531            ids.extend(
5532                connection
5533                    .get("nodes")
5534                    .and_then(Value::as_array)
5535                    .ok_or_else(|| SourceError::Malformed {
5536                        message: "GitHub dependency response nodes is not an array".into(),
5537                    })?
5538                    .iter()
5539                    .map(|value| required_str(value, "id").map(str::to_owned))
5540                    .collect::<Result<Vec<_>, _>>()?,
5541            );
5542            let next = next_cursor(connection)?;
5543            if let Some(next) = &next {
5544                validate_cursor_progress(after.as_deref(), &next.0)?;
5545            }
5546            after = next.map(|cursor| cursor.0);
5547            if after.is_none() {
5548                return Ok(ids);
5549            }
5550        }
5551    }
5552
5553    async fn dependencies(
5554        &self,
5555        id: &NativeId,
5556        near_kind: ItemKind,
5557        direction: Direction,
5558        page: &PageRequest,
5559    ) -> Result<Page<DependencyEdge>, SourceError> {
5560        validate_page(page)?;
5561        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5562        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5563        let recorded = recorded_offset(cursor, direction)?;
5564        // Asked for even in the recorded phase, whose page reads nothing from the
5565        // connection: `__typename` is what says whether this item has a native
5566        // relationship at all, and that is what decides which far ends the reserved key is
5567        // allowed to hold.
5568        let data = self
5569            .graphql(
5570                graphql::ISSUE_DEPENDENCIES,
5571                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
5572                       "after":if recorded.is_some() {None} else {cursor}}),
5573            )
5574            .await?;
5575        let node =
5576            data.get("node")
5577                .filter(|v| !v.is_null())
5578                .ok_or_else(|| SourceError::Refused {
5579                    message: format!(
5580                        "GitHub item {} was not found or does not support dependencies",
5581                        id.0
5582                    ),
5583                })?;
5584        let connection_name = match direction {
5585            Direction::DependsOn => "blockedBy",
5586            Direction::DependedOnBy => "blocking",
5587        };
5588        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
5589        // named natively and the reserved key may hold any far end. An issue's connections
5590        // hold issues, and this source reads them at the near item's own level.
5591        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
5592        if let Some(offset) = recorded {
5593            return Ok(recorded_page(
5594                self.recorded_edges(id, near_kind, direction, natively_names, node)
5595                    .await?,
5596                offset,
5597                limit,
5598            ));
5599        }
5600        if natively_names.is_none() {
5601            return Ok(recorded_page(
5602                self.recorded_edges(id, near_kind, direction, natively_names, node)
5603                    .await?,
5604                0,
5605                limit,
5606            ));
5607        }
5608        let connection = node
5609            .get(connection_name)
5610            .ok_or_else(|| SourceError::Malformed {
5611                message: "GitHub dependency response is missing its connection".into(),
5612            })?;
5613        let nodes = connection
5614            .get("nodes")
5615            .and_then(Value::as_array)
5616            .ok_or_else(|| SourceError::Malformed {
5617                message: "GitHub dependency response nodes is not an array".into(),
5618            })?;
5619        // `from` depends on `to`, always. GitHub spells the same relationship from either
5620        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
5621        // it — so the near item is `from` in one direction and `to` in the other.
5622        let items = nodes
5623            .iter()
5624            .map(|value| {
5625                let related = NativeId(required_str(value, "id")?.into());
5626                let related_kind = related_kind(value)?;
5627                let (from, to) = match direction {
5628                    Direction::DependsOn => (
5629                        DependencyEndpoint::from_native(id.clone(), near_kind),
5630                        DependencyEndpoint::from_native(related, related_kind),
5631                    ),
5632                    Direction::DependedOnBy => (
5633                        DependencyEndpoint::from_native(related, related_kind),
5634                        DependencyEndpoint::from_native(id.clone(), near_kind),
5635                    ),
5636                };
5637                Ok(DependencyEdge {
5638                    from,
5639                    to,
5640                    kind: DependencyKind::Blocks,
5641                })
5642            })
5643            .collect::<Result<Vec<_>, SourceError>>()?;
5644        let mut next = next_cursor(connection)?;
5645        if let Some(next) = &next {
5646            validate_cursor_progress(cursor, &next.0)?;
5647        }
5648        if next.is_none()
5649            && !self
5650                .recorded_edges(id, near_kind, direction, natively_names, node)
5651                .await?
5652                .is_empty()
5653        {
5654            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
5655        }
5656        Ok(Page { items, next })
5657    }
5658
5659    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
5660    /// a far end in another source has to live: no GitHub issue relationship can name one.
5661    ///
5662    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
5663    /// source never writes one down.
5664    ///
5665    /// The metadata lives in the item's own body slot, and `node` is the dependency read's
5666    /// own answer, which carries an issue's body — so an issue's recorded edges cost no
5667    /// request beyond the read already made, and reading the board for them would be a
5668    /// walk of every item for one field of one. A draft has no body in that answer, because
5669    /// a draft is not an issue, so a draft's are read off its own read by id — never off a
5670    /// listing of the board, which can be behind on the very item asked about.
5671    async fn recorded_edges(
5672        &self,
5673        id: &NativeId,
5674        near_kind: ItemKind,
5675        direction: Direction,
5676        natively_names: Option<ItemKind>,
5677        node: &Value,
5678    ) -> Result<Vec<DependencyEdge>, SourceError> {
5679        if direction != Direction::DependsOn {
5680            return Ok(Vec::new());
5681        }
5682        let slot = match node.get("body") {
5683            Some(body) if natively_names.is_some() => {
5684                metadata_body(body.as_str().map(str::to_owned))?.1
5685            }
5686            _ => {
5687                let Some(item) = self.item_by_id(id).await? else {
5688                    return Ok(Vec::new());
5689                };
5690                item.slot
5691            }
5692        };
5693        DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
5694            .map_err(|message| SourceError::Malformed { message })
5695    }
5696
5697    fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
5698        self.repository
5699            .as_ref()
5700            .ok_or_else(|| SourceError::Refused {
5701                message: format!(
5702                    "source {} has no repository configured, and a GitHub Projects board has no \
5703                 repository of its own to create an issue in; set repository: owner/name on \
5704                 this source",
5705                    self.name
5706                ),
5707            })
5708    }
5709
5710    /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
5711    /// states.
5712    ///
5713    /// The fallback is demanded first, whichever arm answers: a write without a configured
5714    /// repository is refused naming the field exactly as it was before the rule existed,
5715    /// so a source that could not write before cannot write now, rather than writing for
5716    /// the one item whose own field happens to decide it.
5717    ///
5718    /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
5719    /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
5720    /// entry owned by someone other than the owner of the parent issue's repository —
5721    /// GitHub accepts a sub-issue from another repository of the same owner and from no
5722    /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
5723    /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
5724    /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
5725    /// and is visible to the token is checked where its node id is resolved, still before
5726    /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
5727    /// looked up in a listing of the board, which can be minutes behind an issue its own
5728    /// `projectItems` already places on it — and that read answers first from this process's
5729    /// own record, so a project created moments ago in this command answers though GitHub
5730    /// has not caught up.
5731    async fn creation_target(
5732        &self,
5733        incoming: &Incoming<'_>,
5734    ) -> Result<RepositoryTarget, SourceError> {
5735        let fallback = self.configured_repository()?;
5736        let what = |incoming: &Incoming<'_>| {
5737            format!(
5738                "{} {:?}",
5739                incoming.written.kind().describes(),
5740                incoming.title
5741            )
5742        };
5743        let parent = match incoming.parent {
5744            Some(parent) => Some(self.item_by_id(parent).await?.ok_or_else(|| {
5745                SourceError::Refused {
5746                    message: format!(
5747                        "GitHub project issue {} was not found on the board of source {}, so {} \
5748                         cannot be filed under it",
5749                        parent.0,
5750                        self.name,
5751                        what(incoming)
5752                    ),
5753                }
5754            })?),
5755            None => None,
5756        };
5757        let parents_repository = parent
5758            .as_ref()
5759            .map(|parent| {
5760                // A draft is on the board and so is found, but it has no repository to
5761                // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
5762                // would refuse the task only once `createIssue` had made it.
5763                if parent.content_kind == ContentKind::DraftIssue {
5764                    return Err(SourceError::Refused {
5765                        message: format!(
5766                            "GitHub project item {} on the board of source {} is a draft, \
5767                             which cannot have sub-issues, so {} cannot be filed under it",
5768                            parent.id.0,
5769                            self.name,
5770                            what(incoming)
5771                        ),
5772                    });
5773                }
5774                // An issue's repository is where a sub-issue is placed and whose owner it
5775                // is compared against, so a parent whose repository this source cannot
5776                // spell as `owner/name` — GitHub's login grammar is wider than this
5777                // source's floor — is one nothing can be filed under.
5778                parent
5779                    .own_repository
5780                    .as_ref()
5781                    .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
5782                    .ok_or_else(|| SourceError::Malformed {
5783                        message: format!(
5784                            "GitHub project issue {} on the board of source {} is in {}, which \
5785                             is not a {}/owner/name repository this source can place {} in",
5786                            parent.id.0,
5787                            self.name,
5788                            parent
5789                                .own_repository
5790                                .as_ref()
5791                                .map_or("no repository", Repository::as_str),
5792                            RepositoryTarget::HOST,
5793                            what(incoming)
5794                        ),
5795                    })
5796            })
5797            .transpose()?;
5798        match incoming.repositories {
5799            [named] => {
5800                let target =
5801                    RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
5802                        message: format!(
5803                            "{} names repository {}, which is not a {}/owner/name repository \
5804                             source {} can create an issue in; name one that is, or name none",
5805                            what(incoming),
5806                            named.as_str(),
5807                            RepositoryTarget::HOST,
5808                            self.name
5809                        ),
5810                    })?;
5811                if let Some(parents) = &parents_repository
5812                    && parents.owner != target.owner
5813                {
5814                    return Err(SourceError::Refused {
5815                        message: format!(
5816                            "{} names repository {}, owned by {}, but its project's issue is in \
5817                             {}, owned by {}, and GitHub files a sub-issue only in a repository \
5818                             of the same owner as its parent issue; name a repository of {}, or \
5819                             name none",
5820                            what(incoming),
5821                            target.slug(),
5822                            target.owner,
5823                            parents.slug(),
5824                            parents.owner,
5825                            parents.owner
5826                        ),
5827                    });
5828                }
5829                Ok(target)
5830            }
5831            _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
5832        }
5833    }
5834
5835    /// The node id of the repository `incoming` is being created in, or the refusal naming
5836    /// the item and the repository the token cannot see.
5837    ///
5838    /// Resolved once per command per repository; see [`Self::repository_cache`].
5839    async fn repository_id(
5840        &self,
5841        repository: &RepositoryTarget,
5842        incoming: &Incoming<'_>,
5843    ) -> Result<String, SourceError> {
5844        if let Some(id) = self.repository_cache()?.get(repository).cloned() {
5845            return Ok(id);
5846        }
5847        let data = self
5848            .graphql(
5849                graphql::REPOSITORY,
5850                json!({"owner":repository.owner,"name":repository.name}),
5851            )
5852            .await?;
5853        let node = data
5854            .get("repository")
5855            .filter(|value| !value.is_null())
5856            .ok_or_else(|| SourceError::Refused {
5857                message: format!(
5858                    "GitHub repository {} was not found or is not visible to the token, so {} \
5859                     {:?} cannot be created in it",
5860                    repository.slug(),
5861                    incoming.written.kind().describes(),
5862                    incoming.title
5863                ),
5864            })?;
5865        let id = required_str(node, "id")?.to_owned();
5866        self.repository_cache()?
5867            .insert(repository.clone(), id.clone());
5868        Ok(id)
5869    }
5870
5871    fn repository_cache(
5872        &self,
5873    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
5874        self.repository_cache
5875            .lock()
5876            .map_err(|_| SourceError::Unavailable {
5877                message: "this source's record of the destination repository was left \
5878                          inconsistent by an earlier failure; next: run the command again"
5879                    .into(),
5880            })
5881    }
5882
5883    /// Create or update one board item, whichever kind it is.
5884    async fn write_item(
5885        &self,
5886        incoming: &Incoming<'_>,
5887        target: Option<&NativeId>,
5888        depends_on: &[DependencyEdge],
5889    ) -> Result<NativeId, SourceError> {
5890        // Refused before anything is read or written: a task or a project titled the way
5891        // this board spells a document would land as an issue this same source reads back
5892        // as a document, so the field this destination cannot carry is named rather than
5893        // written and silently reclassified.
5894        if let Written::Work(kind, _) = incoming.written
5895            && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
5896        {
5897            return Err(SourceError::Refused {
5898                message: format!(
5899                    "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5900                     spells a document, so it would read back as one rather than as a {}; \
5901                     retitle it, or copy it as a document",
5902                    kind.marker(),
5903                    self.name,
5904                    kind.marker()
5905                ),
5906            });
5907        }
5908        // The destination is read by its own id, and whether this board holds it is decided
5909        // by that read — its own `projectItems` — rather than by whether a listing of the
5910        // board happens to include it yet. See the module documentation.
5911        let existing = match target {
5912            Some(target) => {
5913                Some(
5914                    self.item_by_id(target)
5915                        .await?
5916                        .ok_or_else(|| SourceError::Refused {
5917                            message: format!("GitHub destination item {} was not found", target.0),
5918                        })?,
5919                )
5920            }
5921            None => None,
5922        };
5923        let existing = existing.as_ref();
5924        let board = self
5925            .fields_for(
5926                existing,
5927                incoming.written.status().is_some(),
5928                incoming
5929                    .priority
5930                    .is_some_and(|priority| priority != Priority::None),
5931            )
5932            .await?;
5933        let status_target = incoming
5934            .written
5935            .status()
5936            .map(|status| self.resolved_target(status.category))
5937            .transpose()?;
5938        let column = match (incoming.written.status(), status_target.as_ref()) {
5939            (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
5940            _ => None,
5941        };
5942        // Resolved before anything is created, for the reason the column above is: a
5943        // priority this board has no option for is refused while nothing has been written.
5944        let priority_write = match incoming.priority {
5945            Some(priority) => self.priority_write(&board.fields, existing, priority)?,
5946            None => None,
5947        };
5948        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
5949        if content_kind == ContentKind::DraftIssue {
5950            if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
5951                (status_target.as_ref(), incoming.written.status())
5952            {
5953                return Err(self.closes_a_draft(status.category));
5954            }
5955            if incoming.parent.is_some() {
5956                return Err(SourceError::Refused {
5957                    message: "GitHub draft items cannot be a project's sub-issue".into(),
5958                });
5959            }
5960        }
5961        match existing {
5962            Some(item) if content_kind == ContentKind::Issue => {
5963                if item.labels != incoming.labels {
5964                    return Err(SourceError::Refused {
5965                        message: "GitHub issue labels differ from the labels being written".into(),
5966                    });
5967                }
5968            }
5969            _ => {
5970                if !incoming.labels.is_empty() {
5971                    return Err(SourceError::Refused {
5972                        message: "GitHub items created by this destination carry no labels".into(),
5973                    });
5974                }
5975            }
5976        }
5977
5978        // An existing issue is never moved; a new one is created where the rule says. The
5979        // repository the issue really lives in is what the slot below is written against,
5980        // so a single entry that is where the issue is created travels as no key at all,
5981        // and the read side derives it back from the issue.
5982        let (own_repository, creation_target) = match existing {
5983            Some(item) => (item.own_repository.clone(), None),
5984            None => {
5985                let target = self.creation_target(incoming).await?;
5986                let origin = Repository::try_from(target.origin())
5987                    .map_err(|message| SourceError::Config { message })?;
5988                (Some(origin), Some(target))
5989            }
5990        };
5991        let (native, fallback) = self
5992            .partition_edges(incoming.written.kind(), content_kind, depends_on)
5993            .await?;
5994        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
5995        let body = compose_body(incoming.content, &slot)?;
5996        // Read before anything is created, for the reason the field below is: a value
5997        // this destination cannot store has to refuse, and refusing after `createIssue`
5998        // would leave an issue behind that nothing asked for. The engine writes a
5999        // qualified id here; a caller handing this key anything else is told so rather
6000        // than having it silently stored as no origin at all.
6001        // 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.
6002        let origin = match incoming.metadata.get(ORIGIN_KEY) {
6003            None => "",
6004            Some(Value::String(origin)) => origin.as_str(),
6005            Some(other) => {
6006                return Err(SourceError::Refused {
6007                    message: format!(
6008                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6009                         is {other}"
6010                    ),
6011                });
6012            }
6013        };
6014        // Resolved before anything is created: a board that cannot carry the copy origin
6015        // has to refuse the write, and refusing it after `createIssue` would leave an
6016        // issue behind that nothing asked for.
6017        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6018            Some(field) => {
6019                if required_str(field, "__typename")? != "ProjectV2Field" {
6020                    return Err(SourceError::Refused {
6021                        message: format!(
6022                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6023                        ),
6024                    });
6025                }
6026                Some(required_str(field, "id")?.to_owned())
6027            }
6028            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6029                return Err(SourceError::Refused {
6030                    message: format!(
6031                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6032                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6033                         the board"
6034                    ),
6035                });
6036            }
6037            None => None,
6038        };
6039
6040        let Landed {
6041            content_id,
6042            item_id,
6043            url,
6044            number,
6045        } = match existing {
6046            Some(item) => {
6047                self.update_existing(item, incoming, &body, status_target.as_ref())
6048                    .await?;
6049                Landed {
6050                    content_id: item.id.clone(),
6051                    item_id: item.item_id.clone(),
6052                    url: item.url.clone(),
6053                    number: item.number,
6054                }
6055            }
6056            None => {
6057                let target = creation_target
6058                    .as_ref()
6059                    .ok_or_else(|| SourceError::Malformed {
6060                        message: "a new item was decided without a repository to create it in"
6061                            .into(),
6062                    })?;
6063                self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6064                    .await?
6065            }
6066        };
6067
6068        let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6069        let column = column.map(|(field, option, _)| (field, option));
6070        // Creating an item here is several calls — `createIssue`, `addProjectV2ItemById`,
6071        // then each board field, the parent and the dependencies — and GitHub can fail at
6072        // any of them. Everything this source can refuse *before* the first of those is
6073        // already checked above, so what is left is GitHub itself failing part way. When it
6074        // does over an item this call created, the issue is taken back: a write that
6075        // refused must not leave an item behind that nobody asked for, and one that does
6076        // makes the retry create a second.
6077        let landed = self
6078            .finish_write(
6079                board.id.as_str(),
6080                incoming,
6081                &content_id,
6082                &item_id,
6083                content_kind,
6084                existing,
6085                origin_field.as_deref(),
6086                origin,
6087                column,
6088                status_target.as_ref(),
6089                priority_write.as_ref(),
6090                &native,
6091            )
6092            .await;
6093        if let Err(error) = landed {
6094            if existing.is_none() {
6095                // Best effort, and the write's own failure is what the caller is told: a
6096                // refusal naming the tidy-up would hide why the write failed at all.
6097                let _ = self.delete_issue(&content_id).await;
6098            }
6099            return Err(error);
6100        }
6101
6102        let written_status = match (incoming.written.status(), status_target.as_ref()) {
6103            (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6104                self.statuses
6105                    .status(written_option.as_deref(), true, Some(reason.reason()))
6106            }
6107            (Some(_), Some(StatusTarget::Column(_))) => {
6108                self.statuses.status(written_option.as_deref(), false, None)
6109            }
6110            (Some(status), _) => status.clone(),
6111            (None, _) => Status {
6112                category: StatusCategory::Unknown,
6113                name: "Open".to_owned(),
6114            },
6115        };
6116
6117        // So the rest of this command reads what it just did rather than what the board
6118        // said before it. See `remember_written` for which half takes it.
6119        let remembered = Resolved {
6120            item_id,
6121            id: content_id.clone(),
6122            content_kind,
6123            kind: incoming.written.kind(),
6124            title: incoming.title.to_owned(),
6125            // The visible half of the body this write composed, split back off it the
6126            // way a read splits it — so what this record reports is what a read of the
6127            // same issue reports, rather than the person's text with the metadata slot
6128            // still on the end of it.
6129            body: metadata_body(body.clone())?.0,
6130            raw_body: body.clone(),
6131            // A document has no status of its own; what it reads back as is whatever
6132            // the issue's own state says, which is what a re-read reports.
6133            status: written_status,
6134            option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6135            priority: match incoming.priority {
6136                Some(priority) => HeldPriority::Read(priority),
6137                None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6138                    item.priority.clone()
6139                }),
6140            },
6141            // What `state_input` asked for: closed for a terminal target, open for any other
6142            // status, and the issue's own state left as it was by a document write.
6143            closed: content_kind == ContentKind::Issue
6144                && match status_target.as_ref() {
6145                    Some(StatusTarget::Terminal(_, _)) => true,
6146                    Some(_) => false,
6147                    None => existing.is_some_and(|item| item.closed),
6148                },
6149            delivers: incoming.delivers.to_vec(),
6150            delivered_by: incoming.delivered_by.to_vec(),
6151            labels: incoming.labels.to_vec(),
6152            parent: incoming.parent.cloned(),
6153            origin: (!origin.is_empty()).then(|| origin.to_owned()),
6154            number,
6155            // In the update path this is the item's own url, read off `existing` where the
6156            // record above was bound, so one expression serves both halves.
6157            url,
6158            created_at: existing.and_then(|item| item.created_at),
6159            updated_at: existing.and_then(|item| item.updated_at),
6160            own_repository,
6161            repositories: incoming.repositories.to_vec(),
6162            slot,
6163            board_id: Some(board.id.as_str().to_owned()),
6164            fields: board
6165                .fields
6166                .get("nodes")
6167                .and_then(Value::as_array)
6168                .cloned()
6169                .unwrap_or_default(),
6170        };
6171        self.remember_written(remembered, existing.is_none())?;
6172        Ok(content_id)
6173    }
6174
6175    /// Everything a write does after the item exists: its board fields, its parent, and
6176    /// its dependencies.
6177    ///
6178    /// Split out of `write_item` so there is one place a failure past the point of no
6179    /// return is caught, rather than a tidy-up repeated at each `?` above.
6180    // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
6181    // so there is one place a failure past the point of no return is caught, and its
6182    // arguments are exactly the values that tail already had in scope. Bundling them into a
6183    // struct would describe no concept — it would be "the arguments of this function" — and
6184    // would put the whole of `write_item`'s locals behind one more indirection.
6185    #[allow(clippy::too_many_arguments)]
6186    async fn finish_write(
6187        &self,
6188        board_id: &str,
6189        incoming: &Incoming<'_>,
6190        content_id: &NativeId,
6191        item_id: &str,
6192        content_kind: ContentKind,
6193        existing: Option<&Resolved>,
6194        origin_field: Option<&str>,
6195        origin: &str,
6196        column: Option<(String, String)>,
6197        status_target: Option<&StatusTarget>,
6198        priority: Option<&PriorityWrite>,
6199        native: &[String],
6200    ) -> Result<(), SourceError> {
6201        if let Some(field_id) = origin_field {
6202            self.set_item_field(board_id, item_id, field_id, json!({"text":origin}))
6203                .await?;
6204        }
6205
6206        if let Some((field_id, option_id)) = column {
6207            self.set_item_field(
6208                board_id,
6209                item_id,
6210                &field_id,
6211                json!({"singleSelectOptionId":option_id}),
6212            )
6213            .await?;
6214        }
6215
6216        if let Some(priority) = priority {
6217            self.write_priority(board_id, item_id, priority).await?;
6218        }
6219
6220        if content_kind == ContentKind::Issue
6221            && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
6222        {
6223            self.update_content(
6224                ContentKind::Issue,
6225                content_id,
6226                json!({"stateInput":state_input(status_target)}),
6227            )
6228            .await?;
6229        }
6230
6231        if content_kind == ContentKind::Issue {
6232            self.reparent(
6233                existing.and_then(|item| item.parent.clone()),
6234                content_id,
6235                incoming.parent,
6236            )
6237            .await?;
6238            // A document takes part in no dependency graph, so writing one neither reads
6239            // nor changes the issue's own `blockedBy` relationships. Reconciling them
6240            // against the empty list a document write carries would *delete* whatever
6241            // relationships a person had made on that issue, which is a write nobody
6242            // asked for.
6243            if incoming.written.kind() != BoardKind::Document {
6244                let issue = match existing {
6245                    Some(_) => Issue::Existing,
6246                    None => Issue::Created,
6247                };
6248                self.reconcile_blocked_by(content_id, native, issue).await?;
6249            }
6250        }
6251        Ok(())
6252    }
6253
6254    /// Delete one issue, which takes its board item with it.
6255    async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
6256        let data = self
6257            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6258            .await?;
6259        data.pointer("/deleteIssue/repository")
6260            .filter(|value| !value.is_null())
6261            .ok_or_else(|| SourceError::Malformed {
6262                message: "GitHub issue deletion returned no repository".into(),
6263            })?;
6264        self.forget(id)?;
6265        Ok(())
6266    }
6267
6268    /// Remove one item this copy created, so a copy that could not finish leaves the board
6269    /// as it found it.
6270    ///
6271    /// Deleting the issue takes its board item with it, so there is no second mutation to
6272    /// keep in step. An id the board does not hold is not an error: the item is already
6273    /// gone, which is the state this asks for. Which that is, is decided by reading the item
6274    /// by its own id — a listing of the board can still be missing an item it holds, and
6275    /// reading that as *already gone* would leave behind the very item this was asked to
6276    /// take back.
6277    async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
6278        let Some(item) = self.item_by_id(id).await? else {
6279            return Ok(());
6280        };
6281        if item.content_kind == ContentKind::DraftIssue {
6282            return Err(SourceError::Refused {
6283                message: format!(
6284                    "GitHub item {} is a draft, and this source removes an item by deleting \
6285                     its issue; next: remove it from the board by hand",
6286                    id.0
6287                ),
6288            });
6289        }
6290        let data = self
6291            .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6292            .await?;
6293        data.pointer("/deleteIssue/repository")
6294            .filter(|value| !value.is_null())
6295            .ok_or_else(|| SourceError::Malformed {
6296                message: "GitHub issue deletion returned no repository".into(),
6297            })?;
6298        self.forget(id)?;
6299        Ok(())
6300    }
6301
6302    /// The issue a comment call on `task` is about, or `None` when this board holds no such
6303    /// task.
6304    ///
6305    /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
6306    /// read of the task cannot disagree about which ids name one: a project or a document of
6307    /// this board is not a task here either.
6308    ///
6309    /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
6310    /// issues and a draft is not one. It is refused rather than answered with an empty page,
6311    /// which would read as a task nobody has commented on yet.
6312    async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
6313        let Some(item) = self
6314            .item_by_id(task)
6315            .await?
6316            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6317        else {
6318            return Ok(None);
6319        };
6320        if item.content_kind == ContentKind::DraftIssue {
6321            return Err(SourceError::Refused {
6322                message: format!(
6323                    "task {} of source {} is a draft item on the board, and GitHub keeps \
6324                     comments on issues alone, so a draft has none to read or write; next: \
6325                     convert the draft to an issue on the board, then comment on the issue it \
6326                     becomes",
6327                    task.0, self.name
6328                ),
6329            });
6330        }
6331        Ok(Some(item.id))
6332    }
6333
6334    /// Whether the comment `comment` is one of `issue`'s own.
6335    ///
6336    /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
6337    /// comment's id and nothing else: a comment id given against the wrong task would
6338    /// otherwise change a comment on some other issue entirely. An id that names nothing, or
6339    /// names something that is not an issue comment, is a comment this task does not have —
6340    /// which is what GitHub refusing to resolve it means too.
6341    async fn comment_is_on(
6342        &self,
6343        issue: &NativeId,
6344        comment: &NativeId,
6345    ) -> Result<bool, SourceError> {
6346        let asked = self
6347            .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
6348            .await;
6349        let data = match asked {
6350            Ok(data) => data,
6351            Err(error) if unresolvable_node(&error) => return Ok(false),
6352            Err(error) => return Err(error),
6353        };
6354        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6355            return Ok(false);
6356        };
6357        if optional_str(node, "__typename")? != Some("IssueComment") {
6358            return Ok(false);
6359        }
6360        let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
6361            message: format!("GitHub issue comment {} names no issue", comment.0),
6362        })?;
6363        Ok(required_str(on, "id")? == issue.0)
6364    }
6365
6366    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
6367    async fn partition_edges(
6368        &self,
6369        near_kind: BoardKind,
6370        near_content: ContentKind,
6371        depends_on: &[DependencyEdge],
6372    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
6373        let mut native = Vec::new();
6374        let mut fallback = Vec::new();
6375        for edge in depends_on {
6376            let same_source = edge
6377                .to
6378                .source()
6379                .is_none_or(|source| source == self.name.as_str());
6380            // A qualified id's source segment runs to its *first* colon — `GlobalId` and
6381            // `DependencyEndpoint::source` both read it that way — and a native id may hold
6382            // colons of its own, so the far end is everything after that one separator.
6383            // Splitting at the last would truncate `work:urn:task:7` to `7`.
6384            let far_id = if edge.to.is_qualified() {
6385                edge.to
6386                    .id()
6387                    .split_once(':')
6388                    .map_or(edge.to.id(), |(_, native)| native)
6389            } else {
6390                edge.to.id()
6391            };
6392            // A same-source far end is read by its own id, exactly as the item it is a far end
6393            // of is: whether this board holds it is that read's answer, never a listing's.
6394            let far = if same_source {
6395                Some(
6396                    self.item_by_id(&NativeId(far_id.to_owned()))
6397                        .await?
6398                        .ok_or_else(|| SourceError::Refused {
6399                            message: format!("GitHub dependency item {far_id} was not found"),
6400                        })?,
6401                )
6402            } else {
6403                None
6404            };
6405            let far = far.as_ref();
6406            // The caller says which kind the far end is, and this board holds the far end
6407            // itself, so a disagreement is settled here rather than stored: recorded, the
6408            // wrong kind would read back as a cross-level edge that never existed; written
6409            // natively, it would name a relationship of a different level than the caller
6410            // asked for.
6411            //
6412            // A far end this board holds as a *document* fails the same comparison and is
6413            // refused by the same sentence: `ItemKind` has no document variant because
6414            // nothing may point at one, so no caller can name it correctly and the refusal
6415            // is the only honest answer.
6416            if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
6417                return Err(SourceError::Refused {
6418                    message: format!(
6419                        "GitHub dependency item {far_id} is a {} of this board, and this item \
6420                         names it as a {}; record the kind it is",
6421                        disagreeing.kind.describes(),
6422                        edge.to.kind.marker()
6423                    ),
6424                });
6425            }
6426            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
6427            // however the far end is spelled — and one classified native here would be
6428            // written nowhere at all, because a draft's native reconciliation never runs.
6429            let native_here = near_content == ContentKind::Issue
6430                && far.is_some_and(|far| {
6431                    far.content_kind == ContentKind::Issue
6432                        && BoardKind::Work(edge.to.kind) == near_kind
6433                });
6434            if native_here {
6435                native.push(far_id.to_owned());
6436            } else {
6437                fallback.push(edge.clone());
6438            }
6439        }
6440        Ok((native, fallback))
6441    }
6442
6443    async fn update_existing(
6444        &self,
6445        item: &Resolved,
6446        incoming: &Incoming<'_>,
6447        body: &Option<String>,
6448        status_target: Option<&StatusTarget>,
6449    ) -> Result<(), SourceError> {
6450        let title = incoming.written_title();
6451        let mut fields = match item.content_kind {
6452            ContentKind::DraftIssue => json!({"title":title,"body":body}),
6453            ContentKind::Issue => json!({"title":title,"body":body,
6454                                         "stateInput":state_input(status_target)}),
6455        };
6456        if matches!(status_target, Some(StatusTarget::Terminal(_, _))) {
6457            fields
6458                .as_object_mut()
6459                .expect("update fields are an object")
6460                .remove("stateInput");
6461        }
6462        self.update_content(item.content_kind, &item.id, fields)
6463            .await
6464    }
6465
6466    /// Update one board item's content with exactly `fields` beside its id, through the
6467    /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
6468    /// a draft.
6469    ///
6470    /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
6471    /// is what lets a narrow write carry the one thing it changes and nothing else.
6472    async fn update_content(
6473        &self,
6474        kind: ContentKind,
6475        id: &NativeId,
6476        fields: Value,
6477    ) -> Result<(), SourceError> {
6478        let (operation, id_key, pointer) = match kind {
6479            ContentKind::DraftIssue => (
6480                graphql::UPDATE_DRAFT,
6481                "draftIssueId",
6482                "/updateProjectV2DraftIssue/draftIssue",
6483            ),
6484            ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
6485        };
6486        let mut input = fields;
6487        input[id_key] = json!(id.0);
6488        let data = self.graphql(operation, json!({"input":input})).await?;
6489        let returned = data
6490            .pointer(pointer)
6491            .ok_or_else(|| SourceError::Malformed {
6492                message: "GitHub item update returned no item".into(),
6493            })?;
6494        if required_str(returned, "id")? != id.0 {
6495            return Err(SourceError::Malformed {
6496                message: "GitHub item update returned the wrong item".into(),
6497            });
6498        }
6499        Ok(())
6500    }
6501
6502    /// Creates one issue, files it on the board, and reports what a read of it would say:
6503    /// its content id, its board item id, and the web address GitHub gave it.
6504    ///
6505    /// Two calls rather than one: `createIssue` needs a repository and answers with an
6506    /// issue that is on no board, and `addProjectV2ItemById` is what puts it there. A
6507    /// terminal status is not written here: `finish_write` selects its option first and
6508    /// closes the issue after, so a close never lands on an item whose board cannot show it.
6509    ///
6510    /// The address and the number come back here because this is the only place either is
6511    /// known before GitHub's own board read catches up — an item this run created answers
6512    /// the reads that follow it out of the record below, and one remembered without them
6513    /// would report no location and no key for the rest of the run.
6514    async fn create_and_file_issue(
6515        &self,
6516        board_id: &str,
6517        repository: &RepositoryTarget,
6518        incoming: &Incoming<'_>,
6519        body: &Option<String>,
6520    ) -> Result<Landed, SourceError> {
6521        let repository_id = self.repository_id(repository, incoming).await?;
6522        let data = self
6523            .graphql(
6524                graphql::CREATE_ISSUE,
6525                json!({"input":{
6526                    "repositoryId":repository_id,"title":incoming.written_title(),"body":body
6527                }}),
6528            )
6529            .await?;
6530        let created = data
6531            .pointer("/createIssue/issue")
6532            .filter(|value| !value.is_null())
6533            .ok_or_else(|| SourceError::Malformed {
6534                message: "GitHub issue creation returned no issue".into(),
6535            })?;
6536        let content_id = NativeId(required_str(created, "id")?.to_owned());
6537        // Optional although GitHub's schema makes it non-null: the issue exists by now, so
6538        // a response without it is not worth failing a landed write over — the item simply
6539        // reports no location until the board read catches up, which is what it did before.
6540        let url = optional_str(created, "url")?.map(str::to_owned);
6541        // The issue exists from here on, so an unreadable number and a refused board
6542        // filing below each try, best effort, to take it back: an issue in the repository
6543        // that is on no board is an item nobody asked for and nothing here would find again.
6544        //
6545        // Its number is optional on the same terms its address is — a landed write is not
6546        // worth failing over a member that came back missing, and such an item reports no
6547        // handle until a board read catches up. A number that is *present* and is not an
6548        // unsigned integer is still a response this source cannot read.
6549        let number = match created_issue_number(created) {
6550            Ok(number) => number,
6551            Err(error) => {
6552                let _ = self.delete_issue(&content_id).await;
6553                return Err(error);
6554            }
6555        };
6556        let added = match self
6557            .graphql(
6558                graphql::ADD_TO_BOARD,
6559                json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
6560            )
6561            .await
6562        {
6563            Ok(added) => added,
6564            Err(error) => {
6565                let _ = self.delete_issue(&content_id).await;
6566                return Err(error);
6567            }
6568        };
6569        let item = added
6570            .pointer("/addProjectV2ItemById/item")
6571            .filter(|value| !value.is_null())
6572            .ok_or_else(|| SourceError::Malformed {
6573                message: "GitHub board addition returned no project item".into(),
6574            })?;
6575        Ok(Landed {
6576            content_id,
6577            item_id: required_str(item, "id")?.to_owned(),
6578            url,
6579            number,
6580        })
6581    }
6582
6583    /// Move one issue under the project it now belongs to, or out of the one it left.
6584    async fn reparent(
6585        &self,
6586        held: Option<NativeId>,
6587        child: &NativeId,
6588        wanted: Option<&NativeId>,
6589    ) -> Result<(), SourceError> {
6590        if held.as_ref() == wanted {
6591            return Ok(());
6592        }
6593        if let Some(held) = &held {
6594            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
6595                .await?;
6596        }
6597        if let Some(wanted) = wanted {
6598            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
6599                .await?;
6600        }
6601        Ok(())
6602    }
6603
6604    async fn sub_issue(
6605        &self,
6606        operation: &str,
6607        parent: &NativeId,
6608        child: &NativeId,
6609        root: &str,
6610    ) -> Result<(), SourceError> {
6611        let data = self
6612            .graphql(
6613                operation,
6614                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
6615            )
6616            .await?;
6617        let issue =
6618            data.pointer(&format!("/{root}/issue"))
6619                .ok_or_else(|| SourceError::Malformed {
6620                    message: "GitHub sub-issue update returned no issue".into(),
6621                })?;
6622        let sub =
6623            data.pointer(&format!("/{root}/subIssue"))
6624                .ok_or_else(|| SourceError::Malformed {
6625                    message: "GitHub sub-issue update returned no sub-issue".into(),
6626                })?;
6627        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
6628            return Err(SourceError::Malformed {
6629                message: "GitHub sub-issue update returned the wrong issues".into(),
6630            });
6631        }
6632        Ok(())
6633    }
6634
6635    /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
6636    /// whether there was one.
6637    ///
6638    /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
6639    /// relationships are not read: there is nothing a read of them could find.
6640    async fn reconcile_blocked_by(
6641        &self,
6642        content_id: &NativeId,
6643        native: &[String],
6644        issue: Issue,
6645    ) -> Result<bool, SourceError> {
6646        let current = match issue {
6647            Issue::Created => Vec::new(),
6648            Issue::Existing => self.native_dependency_ids(content_id).await?,
6649        };
6650        let mut changed = false;
6651        for (operation, far_id) in current
6652            .iter()
6653            .filter(|id| !native.contains(id))
6654            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
6655            .chain(
6656                native
6657                    .iter()
6658                    .filter(|id| !current.contains(id))
6659                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
6660            )
6661        {
6662            let data = self
6663                .graphql(
6664                    operation,
6665                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
6666                )
6667                .await?;
6668            let root = if operation == graphql::ADD_BLOCKED_BY {
6669                "addBlockedBy"
6670            } else {
6671                "removeBlockedBy"
6672            };
6673            let issue =
6674                data.pointer(&format!("/{root}/issue"))
6675                    .ok_or_else(|| SourceError::Malformed {
6676                        message: "GitHub dependency update returned no issue".into(),
6677                    })?;
6678            let blocker = data
6679                .pointer(&format!("/{root}/blockingIssue"))
6680                .ok_or_else(|| SourceError::Malformed {
6681                    message: "GitHub dependency update returned no blocking issue".into(),
6682                })?;
6683            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
6684            {
6685                return Err(SourceError::Malformed {
6686                    message: "GitHub dependency update returned the wrong issues".into(),
6687                });
6688            }
6689            changed = true;
6690        }
6691        Ok(changed)
6692    }
6693}
6694
6695/// Whether the issue one write reconciles was created by that write or was already there.
6696#[derive(Clone, Copy, PartialEq, Eq)]
6697enum Issue {
6698    /// Created by this write, so it holds no relationships yet.
6699    Created,
6700    /// On the board before this write, holding whatever relationships it holds.
6701    Existing,
6702}
6703
6704/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
6705enum Reached {
6706    /// An issue this board holds, resolved into everything this source reports about it.
6707    Held(Box<Resolved>),
6708    /// Nothing this board holds: no such node, or a node on some other board.
6709    Nothing,
6710    /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
6711    /// again by [`GitHubProjectsSource::draft_by_id`].
6712    Draft,
6713}
6714
6715/// What GitHub says when a string is not a node id it can resolve.
6716///
6717/// Matched because it is the ordinary answer to a project selector naming a project by its
6718/// *name*, and reporting that as a failure would make naming one impossible. It is read
6719/// off the refusal GitHub sent, never guessed from the shape of the string: this source
6720/// does not define the syntax of a GitHub node id and would be wrong about it.
6721const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
6722
6723/// Whether this refusal is GitHub saying the id names no node at all.
6724fn unresolvable_node(error: &SourceError) -> bool {
6725    matches!(error, SourceError::Refused { message }
6726        if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
6727}
6728
6729/// One project name, as a search qualifier which filters on it at the server.
6730///
6731/// Quoted so the whole title is one phrase rather than a bag of words, with the two
6732/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
6733/// the way it documents. A title matched here is still compared for equality afterwards:
6734/// the qualifier narrows what the server sends, and this source decides what it names.
6735fn title_qualifier(name: &str) -> String {
6736    format!("in:title {}", quoted(name))
6737}
6738
6739/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
6740/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
6741/// it documents — so a value holding a qualifier's spelling is searched for rather than
6742/// obeyed.
6743fn quoted(value: &str) -> String {
6744    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
6745    format!("\"{escaped}\"")
6746}
6747
6748/// The search qualifier for the issues updated at or after `since`.
6749///
6750/// Written to the second, rounded down, which can only widen what the search returns.
6751fn updated_qualifier(since: DateTime<Utc>) -> String {
6752    format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
6753}
6754
6755/// The search terms that narrow a board-scoped issue search to a task query's text and
6756/// metadata predicates, or `None` when it carries neither.
6757///
6758/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
6759/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
6760/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
6761/// matches each in any field the `in:` qualifier names, so a query naming a title search and
6762/// a metadata value searches both fields for both — wider than asked, never narrower, and
6763/// every candidate is confirmed in process afterwards.
6764///
6765/// **This narrows a text search, and that is this source's declared semantics.** GitHub
6766/// matches whole tokens where a substring rule would match inside a word, so an item holding
6767/// the text only inside a longer word is not returned. A text of nothing but whitespace
6768/// matches every item, so it narrows nothing and is not sent.
6769fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
6770    let text = query
6771        .text
6772        .as_ref()
6773        .filter(|text| !text.terms.trim().is_empty());
6774    if text.is_none() && query.metadata.is_empty() {
6775        return None;
6776    }
6777    let (title, body) = match text.map(|text| text.fields) {
6778        None => (false, true),
6779        Some(TextFields::Title) => (true, !query.metadata.is_empty()),
6780        Some(TextFields::Content) => (false, true),
6781        Some(TextFields::TitleOrContent) => (true, true),
6782    };
6783    let fields = match (title, body) {
6784        (true, true) => "in:title,body",
6785        (true, false) => "in:title",
6786        _ => "in:body",
6787    };
6788    let phrases = text
6789        .map(|text| text.terms.clone())
6790        .into_iter()
6791        .chain(
6792            query
6793                .metadata
6794                .iter()
6795                .map(|wanted| as_stored(wanted.value())),
6796        )
6797        .map(|phrase| quoted(&phrase))
6798        .collect::<Vec<_>>();
6799    Some(format!("{fields} {}", phrases.join(" ")))
6800}
6801
6802/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
6803/// before anything is asked of GitHub.
6804///
6805/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
6806/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
6807/// left out, the search is every issue of the board. So this source says it cannot answer
6808/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
6809/// nothing GitHub could search for, and keeps the board read it always had.
6810fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
6811    const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
6812                       letter or digit with a bounded query";
6813    if let Some(text) = &query.text
6814        && !text.terms.trim().is_empty()
6815        && !has_words(&text.terms)
6816    {
6817        return Err(SourceError::Refused {
6818            message: format!(
6819                "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
6820                text.terms
6821            ),
6822        });
6823    }
6824    if let Some(wanted) = query
6825        .metadata
6826        .iter()
6827        .find(|wanted| !has_words(wanted.value()))
6828    {
6829        return Err(SourceError::Refused {
6830            message: format!(
6831                "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
6832                wanted.value(),
6833                std::iter::once(wanted.key())
6834                    .chain(wanted.path().iter().map(String::as_str))
6835                    .collect::<Vec<_>>()
6836                    .join("/"),
6837            ),
6838        });
6839    }
6840    Ok(())
6841}
6842
6843/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
6844fn has_words(phrase: &str) -> bool {
6845    phrase.chars().any(char::is_alphanumeric)
6846}
6847
6848/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
6849///
6850/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
6851/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
6852/// which GitHub's word match would read as different words.
6853fn as_stored(value: &str) -> String {
6854    let encoded = Value::String(value.to_owned()).to_string();
6855    encoded[1..encoded.len() - 1].to_owned()
6856}
6857
6858/// The one narrower question a task query carrying a text, metadata or origin predicate is
6859/// sent as.
6860enum Narrowing {
6861    /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
6862    Origin(String),
6863    /// The board-scoped issue search narrowed by these qualifiers.
6864    Search(String),
6865}
6866
6867impl Narrowing {
6868    /// What this question is remembered under for the length of one command.
6869    fn key(&self) -> String {
6870        match self {
6871            Self::Origin(origin) => format!("origin {origin}"),
6872            Self::Search(also) => format!("search {also}"),
6873        }
6874    }
6875}
6876
6877/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
6878enum Resumed {
6879    /// It reported another page, which starts after this cursor.
6880    More(String),
6881    /// It has ended. Sending this cursor again — the page's own end when it had one, and
6882    /// otherwise the cursor it was reached from — answers an empty page, so the one document
6883    /// can go on walking the other connection.
6884    Ended(Option<String>),
6885}
6886
6887impl Resumed {
6888    /// Whether the connection has another page.
6889    const fn has_more(&self) -> bool {
6890        matches!(self, Self::More(_))
6891    }
6892
6893    /// The cursor to send this connection next.
6894    fn cursor(self) -> Option<String> {
6895        match self {
6896            Self::More(next) => Some(next),
6897            Self::Ended(last) => last,
6898        }
6899    }
6900}
6901
6902/// Where `connection`, reached from `after`, resumes — refused when it reports another page
6903/// with no cursor to it, or from a cursor that does not advance.
6904fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
6905    let info = connection
6906        .get("pageInfo")
6907        .ok_or_else(|| SourceError::Malformed {
6908            message: "GitHub connection has no pageInfo".into(),
6909        })?;
6910    let end = optional_str(info, "endCursor")?;
6911    if required_bool(info, "hasNextPage")? {
6912        let next = end.ok_or_else(|| SourceError::Malformed {
6913            message: "GitHub connection reports another page and no endCursor".into(),
6914        })?;
6915        validate_cursor_progress(after, next)?;
6916        return Ok(Resumed::More(next.to_owned()));
6917    }
6918    Ok(Resumed::Ended(
6919        end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
6920    ))
6921}
6922
6923/// The board, and every item on it this source reports.
6924#[derive(Clone)]
6925struct Board {
6926    id: String,
6927    fields: Value,
6928    items: Vec<Resolved>,
6929}
6930
6931/// What a write needs of the board and nothing more: its node id and its field
6932/// definitions, in the shape a read of the board's own `fields` gives them.
6933///
6934/// Deliberately no items. A write decides which item it writes, which parent it files
6935/// under and which far ends it names by reading each of them by its own id; this is the
6936/// half of the board those reads cannot carry, and holding no item is what keeps it from
6937/// ever being asked whether an item is there.
6938#[derive(Clone)]
6939struct BoardFields {
6940    id: BoardId,
6941    fields: Value,
6942}
6943
6944/// A board's node id: what a field write and `addProjectV2ItemById` address.
6945///
6946/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
6947/// refused where it is read, and one an item names blank is read as not named at all.
6948#[derive(Clone)]
6949struct BoardId(String);
6950
6951/// Where one write left its item, for the record the rest of the command reads it out of.
6952///
6953/// A named record rather than a tuple because the update arm and the create arm each fill
6954/// all four, and two `Option`s of different meaning side by side in a tuple are two
6955/// positions a reader has to count.
6956struct Landed {
6957    /// The issue's own node id, which is the [`NativeId`] this source reports.
6958    content_id: NativeId,
6959    /// The board item's id, which is what a field write addresses.
6960    // 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.
6961    item_id: String,
6962    /// The web address GitHub gave the issue, when it gave one.
6963    // 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.
6964    url: Option<String>,
6965    /// The issue's number on its repository, when GitHub reported one.
6966    number: Option<u64>,
6967}
6968
6969impl BoardId {
6970    fn parse(id: &str) -> Result<Self, SourceError> {
6971        if id.trim().is_empty() {
6972            return Err(SourceError::Malformed {
6973                message: "GitHub named a board with a blank node id".into(),
6974            });
6975        }
6976        Ok(Self(id.to_owned()))
6977    }
6978
6979    fn as_str(&self) -> &str {
6980        &self.0
6981    }
6982}
6983
6984impl Board {
6985    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
6986        complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
6987        let nodes = fields
6988            .get("nodes")
6989            .and_then(Value::as_array)
6990            .ok_or_else(|| SourceError::Malformed {
6991                message: "GitHub project fields.nodes is not an array".into(),
6992            })?;
6993        Ok(nodes
6994            .iter()
6995            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
6996    }
6997}
6998
6999/// One board item, resolved into everything this source reports about it.
7000#[derive(Clone)]
7001struct Resolved {
7002    item_id: String,
7003    id: NativeId,
7004    content_kind: ContentKind,
7005    kind: BoardKind,
7006    title: String,
7007    body: Option<String>,
7008    /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
7009    /// that changes the slot alone has to keep byte for byte outside it.
7010    raw_body: Option<String>,
7011    status: Status,
7012    /// The name of the board `Status` option this item sits in, as the board spells it.
7013    option: Option<String>,
7014    /// What its `Priority` field says, read through this instance's mapping.
7015    priority: HeldPriority,
7016    /// Whether this item's issue is closed. A draft has no such state and is never closed.
7017    closed: bool,
7018    /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
7019    delivers: Vec<TaskRef>,
7020    /// Every task that delivers this one, read out of its slot. Empty for anything not a
7021    /// task.
7022    delivered_by: Vec<TaskRef>,
7023    labels: Vec<Label>,
7024    parent: Option<NativeId>,
7025    // 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.
7026    origin: Option<String>,
7027    /// The issue's own number on its repository, as GitHub reports it.
7028    ///
7029    /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
7030    /// declares none, and a draft is not filed in a repository to be numbered by one — and
7031    /// an issue this run created whose creating mutation answered without one, which is a
7032    /// response GitHub's own schema says cannot happen and which a landed write is not
7033    /// worth failing over. An `Issue` read off the board always has one.
7034    number: Option<u64>,
7035    url: Option<String>,
7036    created_at: Option<DateTime<Utc>>,
7037    updated_at: Option<DateTime<Utc>>,
7038    own_repository: Option<Repository>,
7039    repositories: Vec<Repository>,
7040    slot: BTreeMap<String, Value>,
7041    /// The node id of the board this item sits on, when the read that reached it said.
7042    board_id: Option<String>,
7043    /// The definition of every board field this item holds a value of, in the shape a read
7044    /// of the board's own `fields` gives one.
7045    ///
7046    /// Only the fields this item has a value in: a field it holds nothing of is not here,
7047    /// which says nothing about whether the board has it.
7048    fields: Vec<Value>,
7049}
7050
7051impl Resolved {
7052    /// The board this item's own read names it on, when that read named one this source can
7053    /// address.
7054    fn named_board(&self) -> Option<BoardId> {
7055        self.board_id
7056            .as_deref()
7057            .and_then(|id| BoardId::parse(id).ok())
7058    }
7059
7060    /// Whether this item holds a value of the board field called `name`, and so carries
7061    /// that field's definition. `false` says nothing about whether the board has the field.
7062    fn defines(&self, name: &str) -> bool {
7063        self.fields
7064            .iter()
7065            .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
7066    }
7067
7068    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
7069    /// in a field of its own, and none of the five keys that are only an encoding.
7070    ///
7071    /// The two delivery keys are left out for every kind, not only for a task: they are
7072    /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
7073    /// document carrying one holds nothing a caller's own metadata could mean by it.
7074    fn metadata(&self) -> BTreeMap<String, Value> {
7075        let mut metadata = self.slot.clone();
7076        metadata.remove(Repository::METADATA_KEY);
7077        metadata.remove(DependencyEdge::RECORDED_KEY);
7078        metadata.remove(ItemKind::METADATA_KEY);
7079        metadata.remove(TaskRef::DELIVERS_KEY);
7080        metadata.remove(TaskRef::DELIVERED_BY_KEY);
7081        // The board field is the origin, and the body's copy of it is only a mirror for the
7082        // issue search to find: an item whose field holds none has none, whatever its body
7083        // says, so no reader ever sees two answers.
7084        metadata.remove(ORIGIN_KEY);
7085        if let Some(origin) = &self.origin {
7086            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
7087        }
7088        metadata
7089    }
7090
7091    /// Where this item is, as a link a reader can open.
7092    ///
7093    /// A board is a hosted place and every issue on it has a web address, so that address
7094    /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
7095    /// of place it is, so a reader knows to open it rather than to read a file out. It
7096    /// does not replace or derive from `url`: the field goes on reporting exactly what it
7097    /// reported before, and this says what that address *is*.
7098    ///
7099    /// An item GitHub gave no `url` for — a draft has none — reports no location at all
7100    /// rather than a third variant, which is the contract's "the source did not say". An
7101    /// issue this run created is not one of those: its address comes back from the
7102    /// creating mutation, so it is somewhere a reader can open from the moment it exists
7103    /// rather than from whenever the board read catches up.
7104    fn location(&self) -> Option<Location> {
7105        self.url.clone().map(Location::Url)
7106    }
7107
7108    /// The short handle this board's backend shows people for a task: the issue's number
7109    /// alone, as a decimal string.
7110    ///
7111    /// The number alone rather than `owner/repo#1043`, because that is the contract's
7112    /// value for this backend. A draft has no number and so no handle, which is the
7113    /// contract's *absent* rather than a handle of some other shape — and the native
7114    /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
7115    /// derives from.
7116    fn key(&self) -> Option<String> {
7117        self.number.map(|number| number.to_string())
7118    }
7119
7120    /// Whether its `Priority` field holds a value at all, mapped or not.
7121    fn holds_priority(&self) -> bool {
7122        self.priority != HeldPriority::Read(Priority::None)
7123    }
7124
7125    /// The task this item is.
7126    ///
7127    /// Fails for an item whose `Priority` field holds an option the mapping does not name:
7128    /// reading that as a level would be a guess, and reading it as `none` would let the next
7129    /// copy clear a priority a person set.
7130    fn task(&self) -> Result<Task, SourceError> {
7131        let priority = match &self.priority {
7132            HeldPriority::Read(priority) => *priority,
7133            HeldPriority::Unmapped(option) => {
7134                return Err(SourceError::Malformed {
7135                    message: format!(
7136                        "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
7137                         this source's priority_mapping does not name, so its priority cannot be \
7138                         read; next: name {option:?} under priority_mapping, or move the item to \
7139                         a mapped option",
7140                        self.id,
7141                        self.number
7142                            .map(|number| format!(" (#{number})"))
7143                            .unwrap_or_default()
7144                    ),
7145                });
7146            }
7147        };
7148        Ok(Task {
7149            id: self.id.clone(),
7150            key: self.key(),
7151            title: self.title.clone(),
7152            content: self.body.clone(),
7153            status: self.status.clone(),
7154            priority,
7155            labels: self.labels.clone(),
7156            project: self.parent.clone(),
7157            url: self.url.clone(),
7158            location: self.location(),
7159            created_at: self.created_at,
7160            updated_at: self.updated_at,
7161            metadata: self.metadata(),
7162            repositories: self.repositories.clone(),
7163            delivers: self.delivers.clone(),
7164            delivered_by: self.delivered_by.clone(),
7165        })
7166    }
7167
7168    fn project(&self) -> Project {
7169        Project {
7170            id: self.id.clone(),
7171            title: self.title.clone(),
7172            content: self.body.clone(),
7173            status: self.status.clone(),
7174            labels: self.labels.clone(),
7175            url: self.url.clone(),
7176            location: self.location(),
7177            created_at: self.created_at,
7178            updated_at: self.updated_at,
7179            metadata: self.metadata(),
7180            repositories: self.repositories.clone(),
7181        }
7182    }
7183
7184    /// The same issue as a document: the project it is filed under, and no status and no
7185    /// dependencies, because a document is not work.
7186    fn document(&self) -> Document {
7187        Document {
7188            id: self.id.clone(),
7189            title: self.title.clone(),
7190            content: self.body.clone(),
7191            project: self.parent.clone(),
7192            labels: self.labels.clone(),
7193            url: self.url.clone(),
7194            location: self.location(),
7195            created_at: self.created_at,
7196            updated_at: self.updated_at,
7197            metadata: self.metadata(),
7198            repositories: self.repositories.clone(),
7199        }
7200    }
7201}
7202
7203/// Where one targeted update moves an item's status, and which of its two halves move.
7204struct StatusMove {
7205    /// The board the item's `Status` field is on.
7206    board: BoardId,
7207    /// The `Status` field's id.
7208    field: String,
7209    /// The option's id.
7210    option: String,
7211    /// The option's name, as the board spells it.
7212    name: String,
7213    /// What the status asks of the issue's state.
7214    target: StatusTarget,
7215    /// The status the item reads as once it is there.
7216    landed: Status,
7217    /// Which of the status's two halves differ from what the item holds.
7218    moves: Moves,
7219}
7220
7221/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
7222/// closed state of its issue, or both. A status neither half of which differs is no move at all,
7223/// and is not a value of this type.
7224#[derive(Clone, Copy, PartialEq, Eq)]
7225enum Moves {
7226    /// The option alone.
7227    Option,
7228    /// The issue's state alone: open, closed, or closed with another reason.
7229    State,
7230    /// Both.
7231    Both,
7232}
7233
7234impl Moves {
7235    /// What differs, or `None` when nothing does.
7236    const fn of(option: bool, state: bool) -> Option<Self> {
7237        match (option, state) {
7238            (true, true) => Some(Self::Both),
7239            (true, false) => Some(Self::Option),
7240            (false, true) => Some(Self::State),
7241            (false, false) => None,
7242        }
7243    }
7244
7245    /// Whether the option moves.
7246    const fn option(self) -> bool {
7247        matches!(self, Self::Option | Self::Both)
7248    }
7249
7250    /// Whether the issue's state moves.
7251    const fn state(self) -> bool {
7252        matches!(self, Self::State | Self::Both)
7253    }
7254}
7255
7256/// What one write is, and the status that comes with being it.
7257///
7258/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
7259/// status and a task or a project always has one, so "a document carrying a status" and
7260/// "a task carrying none" are states a write cannot be in rather than states every use
7261/// site below has to defend against.
7262enum Written<'a> {
7263    /// A document, which is not work and so has no status at all.
7264    Document,
7265    /// A task or a project, and the status it is being written with.
7266    Work(ItemKind, &'a Status),
7267}
7268
7269impl Written<'_> {
7270    /// Which of the board's three kinds this write is.
7271    const fn kind(&self) -> BoardKind {
7272        match self {
7273            Self::Document => BoardKind::Document,
7274            Self::Work(kind, _) => BoardKind::Work(*kind),
7275        }
7276    }
7277
7278    /// The status this write carries. A document carries none, so a write of one says
7279    /// nothing about the issue's open or closed state and selects no board `Status`
7280    /// option.
7281    const fn status(&self) -> Option<&Status> {
7282        match self {
7283            Self::Document => None,
7284            Self::Work(_, status) => Some(status),
7285        }
7286    }
7287}
7288
7289/// The item being written, in the one shape all three write methods reach.
7290struct Incoming<'a> {
7291    written: Written<'a>,
7292    /// The title a person wrote. A document's goes onto the issue with
7293    /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
7294    title: &'a str,
7295    content: Option<&'a str>,
7296    labels: &'a [Label],
7297    metadata: &'a BTreeMap<String, Value>,
7298    repositories: &'a [Repository],
7299    parent: Option<&'a NativeId>,
7300    /// [`Task::delivers`], already checked. Empty for a project or a document, which is
7301    /// what keeps either key out of their slot.
7302    delivers: &'a [TaskRef],
7303    /// [`Task::delivered_by`], already checked. Empty for a project or a document.
7304    delivered_by: &'a [TaskRef],
7305    /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
7306    /// project, a document, and every write to an instance with no `priority_mapping` —
7307    /// which is what keeps such a write's requests exactly what they were before.
7308    priority: Option<Priority>,
7309}
7310
7311/// What one write does to an item's `Priority` field.
7312enum PriorityWrite {
7313    /// Select this option of this field.
7314    Select {
7315        /// The `Priority` field's id.
7316        field: String,
7317        /// The mapped option's id.
7318        option: String,
7319    },
7320    /// Clear the field's value, which is what `none` is.
7321    Clear {
7322        /// The `Priority` field's id.
7323        field: String,
7324    },
7325}
7326
7327impl Incoming<'_> {
7328    /// The title this write puts on the issue.
7329    fn written_title(&self) -> String {
7330        match self.written {
7331            Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
7332            Written::Work(..) => self.title.to_owned(),
7333        }
7334    }
7335}
7336
7337#[derive(Clone, Copy, PartialEq, Eq)]
7338enum ContentKind {
7339    DraftIssue,
7340    Issue,
7341}
7342
7343/// What one board issue is: a document, or the work an [`ItemKind`] names.
7344///
7345/// A type of this source's own rather than an `ItemKind` with a third variant, because
7346/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
7347/// document — the contract keeps a document out of that enum deliberately. Holding the
7348/// board's three answers in one value is what makes every place that asks "which is this?"
7349/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
7350/// two thirds of the board.
7351#[derive(Clone, Copy, PartialEq, Eq)]
7352enum BoardKind {
7353    /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
7354    Document,
7355    /// Every other issue, and every draft.
7356    Work(ItemKind),
7357}
7358
7359impl BoardKind {
7360    /// How a refusal names this kind to the person reading it.
7361    const fn describes(self) -> &'static str {
7362        match self {
7363            Self::Document => "document",
7364            Self::Work(kind) => kind.marker(),
7365        }
7366    }
7367}
7368
7369/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
7370///
7371/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
7372/// the shared cross-source journeys assert one answer to one question, so two sources
7373/// that disagree about what "carries the label bug" means fail them.
7374fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
7375    let holds = |name: &String| {
7376        labels
7377            .iter()
7378            .any(|label| label.name.eq_ignore_ascii_case(name))
7379    };
7380    (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
7381        && filter.all_of.iter().all(holds)
7382        && !filter.none_of.iter().any(holds)
7383}
7384
7385/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
7386/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
7387fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
7388    statuses.is_empty() || statuses.contains(&category)
7389}
7390
7391/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
7392///
7393/// `content` is the item's own prose — the body with this source's trailing metadata
7394/// comment already taken off — so a search never matches an encoding the author of the
7395/// issue never wrote.
7396fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
7397    let terms = query.terms.to_lowercase();
7398    let in_title = title.to_lowercase().contains(&terms);
7399    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
7400    match query.fields {
7401        TextFields::Title => in_title,
7402        TextFields::Content => in_content,
7403        TextFields::TitleOrContent => in_title || in_content,
7404    }
7405}
7406
7407/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
7408///
7409/// The project predicate is passed separately because a read narrowed to one project has
7410/// already answered it by asking *that project* for its own items — and re-applying it
7411/// there would compare the caller's selector, which may be a project's **name**, against
7412/// the id of the project that name resolved to, and keep nothing. Every other read passes
7413/// `query.project` and applies it here, which is what keeps `projects` a predicate this
7414/// source really does apply.
7415fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
7416    labels_match(&task.labels, &query.labels)
7417        && status_matches(task.status.category, &query.statuses)
7418        && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
7419        && match project {
7420            ProjectFilter::Any => true,
7421            ProjectFilter::Orphans => task.project.is_none(),
7422            ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
7423        }
7424        && query
7425            .text
7426            .as_ref()
7427            .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
7428        // Against the parsed metadata slot, and against the origin field, which is where
7429        // `Resolved::metadata` reads each of them from.
7430        && query.metadata_matches(&task.metadata)
7431        && query.origin_matches(&task.metadata)
7432}
7433
7434fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
7435    labels_match(&project.labels, &query.labels)
7436        && status_matches(project.status.category, &query.statuses)
7437        && query
7438            .text
7439            .as_ref()
7440            .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
7441}
7442
7443/// The same three predicates a task query carries, minus the status filter.
7444///
7445/// A document is not work, so it has no status for one to compare against and the query
7446/// type carries none. The project predicate is the same one — a design issue filed under a
7447/// project issue is in that project, and one filed under nothing is in none — so it is
7448/// spelled the same way here rather than answered differently.
7449fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
7450    labels_match(&document.labels, &query.labels)
7451        && match project {
7452            ProjectFilter::Any => true,
7453            ProjectFilter::Orphans => document.project.is_none(),
7454            ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
7455        }
7456        && query
7457            .text
7458            .as_ref()
7459            .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
7460}
7461
7462#[async_trait::async_trait]
7463impl TaskSource for GitHubProjectsSource {
7464    fn kind(&self) -> &'static str {
7465        KIND
7466    }
7467    fn capabilities(&self) -> Capabilities {
7468        Capabilities {
7469            projects: Support::Native,
7470            documents: Support::Native,
7471            comments: Support::Native,
7472            priority: if self.priorities.is_some() {
7473                Support::Native
7474            } else {
7475                Support::Unsupported
7476            },
7477            filter_by_priority: Support::Native,
7478            filter_by_comment_activity: Support::Native,
7479            filter_by_metadata: Support::Native,
7480            filter_by_origin: Support::Native,
7481            orphan_tasks: Support::Native,
7482            filter_by_label: Support::Native,
7483            filter_by_status: Support::Native,
7484            search_title: Support::Native,
7485            search_content: Support::Native,
7486            task_dependencies: DependencySupport::BothDirections,
7487            project_dependencies: DependencySupport::BothDirections,
7488            max_page_size: MAX_PAGE_SIZE,
7489        }
7490    }
7491    async fn health(&self) -> Result<Health, SourceError> {
7492        let board = self.board_page(None, 1).await?;
7493        Ok(Health {
7494            reachable: true,
7495            detail: Some(format!(
7496                "reading GitHub project {}/{} ({})",
7497                self.owner,
7498                self.project_number,
7499                required_str(&board, "title")?
7500            )),
7501        })
7502    }
7503    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
7504        self.item_by_id(id)
7505            .await?
7506            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7507            .map(|item| item.task())
7508            .transpose()
7509    }
7510    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
7511        Ok(self
7512            .item_by_id(id)
7513            .await?
7514            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7515            .map(|item| item.project()))
7516    }
7517    async fn query_tasks(
7518        &self,
7519        query: &TaskQuery,
7520        page: &PageRequest,
7521    ) -> Result<Page<Task>, SourceError> {
7522        validate_page(page)?;
7523        refuse_unsearchable(query)?;
7524        // A read narrowed to one project asks that project for its own tasks, so nothing
7525        // about it costs what the rest of the board holds. A read carrying a text, metadata
7526        // or origin predicate asks GitHub the narrower question those predicates are, and a
7527        // read narrowed to comment activity alone asks the board's own issue search for the
7528        // issues updated since, which is every issue a comment could have been written or
7529        // edited on since. Every other task read is a question about the whole board and is
7530        // answered by reading it.
7531        let (held, membership) = match (&query.project, query.commented_since) {
7532            (ProjectFilter::Is(project), _) => (
7533                self.project_children(project).await?,
7534                // Answered by where these items came from; see `task_matches`.
7535                &ProjectFilter::Any,
7536            ),
7537            (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
7538                match (self.narrowed(query).await?, since) {
7539                    (Some(narrowed), _) => (narrowed, &query.project),
7540                    (None, Some(since)) => (self.updated_since(since).await?, &query.project),
7541                    (None, None) => (self.board().await?.items, &query.project),
7542                }
7543            }
7544        };
7545        // Filtered before paged: a page of a filtered result is a page of the survivors,
7546        // never the survivors of a page.
7547        let mut tasks = Vec::new();
7548        for item in held
7549            .iter()
7550            .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7551        {
7552            let task = item.task()?;
7553            if task_matches(&task, query, membership)
7554                && self.commented_since(item, query.commented_since).await?
7555            {
7556                tasks.push(task);
7557            }
7558        }
7559        Ok(offset_page(
7560            tasks,
7561            numeric_cursor(page.cursor.as_ref())?,
7562            page.limit.min(MAX_PAGE_SIZE) as usize,
7563        ))
7564    }
7565    async fn query_projects(
7566        &self,
7567        query: &ProjectQuery,
7568        page: &PageRequest,
7569    ) -> Result<Page<Project>, SourceError> {
7570        validate_page(page)?;
7571        // The projects a board holds are found by an issue search scoped to that board,
7572        // never by walking the board's own item connection: what tells a project from a
7573        // task is the `parent` each issue carries, which costs nothing to read.
7574        let projects = self
7575            .board_issues()
7576            .await?
7577            .iter()
7578            .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7579            .map(Resolved::project)
7580            .filter(|project| project_matches(project, query))
7581            .collect();
7582        Ok(offset_page(
7583            projects,
7584            numeric_cursor(page.cursor.as_ref())?,
7585            page.limit.min(MAX_PAGE_SIZE) as usize,
7586        ))
7587    }
7588    async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
7589        Ok(self
7590            .item_by_id(id)
7591            .await?
7592            .filter(|item| item.kind == BoardKind::Document)
7593            .map(|item| item.document()))
7594    }
7595    async fn query_documents(
7596        &self,
7597        query: &DocumentQuery,
7598        page: &PageRequest,
7599    ) -> Result<Page<Document>, SourceError> {
7600        validate_page(page)?;
7601        // Narrowed to one project, this is the same sub-issue read a task list scoped to
7602        // that project makes — a document filed under a project is a sub-issue of it too,
7603        // and which of them come back is the kind this caller asked for.
7604        let (held, membership) = match &query.project {
7605            ProjectFilter::Is(project) => (
7606                self.project_children(project).await?,
7607                // Answered by where these items came from; see `task_matches`.
7608                &ProjectFilter::Any,
7609            ),
7610            ProjectFilter::Any | ProjectFilter::Orphans => {
7611                (self.board().await?.items, &query.project)
7612            }
7613        };
7614        // Filtered before paged, exactly as a task read is: a page of a filtered result is
7615        // a page of the survivors, never the survivors of a page.
7616        let documents = held
7617            .iter()
7618            .filter(|item| item.kind == BoardKind::Document)
7619            .map(Resolved::document)
7620            .filter(|document| document_matches(document, query, membership))
7621            .collect();
7622        Ok(offset_page(
7623            documents,
7624            numeric_cursor(page.cursor.as_ref())?,
7625            page.limit.min(MAX_PAGE_SIZE) as usize,
7626        ))
7627    }
7628    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
7629        validate_page(page)?;
7630        let offset = numeric_cursor(page.cursor.as_ref())?;
7631        let mut labels = self
7632            .board()
7633            .await?
7634            .items
7635            .into_iter()
7636            .flat_map(|item| item.labels)
7637            .fold(Vec::new(), |mut all, label| {
7638                if !all.iter().any(|x: &Label| x.id == label.id) {
7639                    all.push(label);
7640                }
7641                all
7642            });
7643        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
7644        Ok(offset_page(
7645            labels,
7646            offset,
7647            page.limit.min(MAX_PAGE_SIZE) as usize,
7648        ))
7649    }
7650    async fn task_dependencies(
7651        &self,
7652        id: &NativeId,
7653        direction: Direction,
7654        page: &PageRequest,
7655    ) -> Result<Page<DependencyEdge>, SourceError> {
7656        self.dependencies(id, ItemKind::Task, direction, page).await
7657    }
7658    async fn project_dependencies(
7659        &self,
7660        id: &NativeId,
7661        direction: Direction,
7662        page: &PageRequest,
7663    ) -> Result<Page<DependencyEdge>, SourceError> {
7664        self.dependencies(id, ItemKind::Project, direction, page)
7665            .await
7666    }
7667
7668    fn writes(&self) -> WriteSupport {
7669        WriteSupport::Supported
7670    }
7671
7672    /// Create or update one task.
7673    ///
7674    /// Its `delivers` and `delivered_by` are checked before anything is read or written —
7675    /// neither may name the task itself or name one task twice — and land in the body's
7676    /// metadata slot under their reserved keys, in place of any caller metadata of those
7677    /// names.
7678    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
7679        let near = write.target.as_ref().unwrap_or(&write.item.id);
7680        for (key, entries) in [
7681            (TaskRef::DELIVERS_KEY, &write.item.delivers),
7682            (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
7683        ] {
7684            TaskRef::listed(key, near, Some(&self.name), entries.clone())
7685                .map_err(|message| SourceError::Refused { message })?;
7686        }
7687        if self.priorities.is_none() && write.item.priority != Priority::None {
7688            return Err(self.holds_no_priority());
7689        }
7690        self.write_item(
7691            &Incoming {
7692                written: Written::Work(ItemKind::Task, &write.item.status),
7693                title: &write.item.title,
7694                content: write.item.content.as_deref(),
7695                labels: &write.item.labels,
7696                metadata: &write.item.metadata,
7697                repositories: &write.item.repositories,
7698                parent: write.item.project.as_ref(),
7699                delivers: &write.item.delivers,
7700                delivered_by: &write.item.delivered_by,
7701                priority: self.priorities.as_ref().map(|_| write.item.priority),
7702            },
7703            write.target.as_ref(),
7704            &write.depends_on,
7705        )
7706        .await
7707    }
7708
7709    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
7710        self.write_item(
7711            &Incoming {
7712                written: Written::Work(ItemKind::Project, &write.item.status),
7713                title: &write.item.title,
7714                content: write.item.content.as_deref(),
7715                labels: &write.item.labels,
7716                metadata: &write.item.metadata,
7717                repositories: &write.item.repositories,
7718                parent: None,
7719                delivers: &[],
7720                delivered_by: &[],
7721                priority: None,
7722            },
7723            write.target.as_ref(),
7724            &write.depends_on,
7725        )
7726        .await
7727    }
7728
7729    /// Create or update one document, which is one issue titled the way this board spells
7730    /// a document.
7731    ///
7732    /// Everything else is exactly a task write: caller metadata goes to the same canonical
7733    /// JSON slot at the end of the body and comes back with its JSON types intact, a key
7734    /// or a field this board cannot carry is refused by name rather than dropped, a target
7735    /// naming an issue this board does not hold is refused rather than created, and an
7736    /// issue this call created is taken back when the rest of the write fails.
7737    async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
7738        // A document takes part in no dependency graph, so there is no far end to write
7739        // natively and none to record: a caller naming one is told so rather than having it
7740        // stored under the reserved key, where a later read would report an edge the
7741        // contract says cannot exist.
7742        if !write.depends_on.is_empty() {
7743            return Err(SourceError::Refused {
7744                message: format!(
7745                    "this write names {} dependencies for a document, and a document takes \
7746                     part in no dependency graph; next: put the dependency on the task or \
7747                     project the document is about",
7748                    write.depends_on.len()
7749                ),
7750            });
7751        }
7752        self.write_item(
7753            &Incoming {
7754                written: Written::Document,
7755                title: &write.item.title,
7756                content: write.item.content.as_deref(),
7757                labels: &write.item.labels,
7758                metadata: &write.item.metadata,
7759                repositories: &write.item.repositories,
7760                parent: write.item.project.as_ref(),
7761                delivers: &[],
7762                delivered_by: &[],
7763                priority: None,
7764            },
7765            write.target.as_ref(),
7766            &[],
7767        )
7768        .await
7769    }
7770
7771    /// Set one task's status alone.
7772    ///
7773    /// An open target reopens a closed issue with an `updateIssue` carrying only its
7774    /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
7775    /// terminal target selects its mapped option, then closes with its fixed reason. No
7776    /// request carries a title, a body or a label. The status
7777    /// answered is what [`StatusMapping::status`] reads off the state just written, which is
7778    /// what a re-read reports.
7779    async fn set_task_status(
7780        &self,
7781        id: &NativeId,
7782        category: StatusCategory,
7783    ) -> Result<Option<Status>, SourceError> {
7784        self.set_status(id, category).await
7785    }
7786
7787    /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
7788    /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
7789    /// for `none`. Refused by an instance with no `priority_mapping`.
7790    async fn set_task_priority(
7791        &self,
7792        id: &NativeId,
7793        priority: Priority,
7794    ) -> Result<Option<Priority>, SourceError> {
7795        self.set_priority(id, priority).await
7796    }
7797
7798    /// Replace one task's content with a single body update that keeps the metadata slot
7799    /// byte for byte.
7800    async fn set_task_content(
7801        &self,
7802        id: &NativeId,
7803        content: &str,
7804    ) -> Result<Option<()>, SourceError> {
7805        self.replace_content(id, content).await
7806    }
7807
7808    /// Replace one task issue's content and its provenance slot entry with a single body
7809    /// update. The answers are not kept: see `replace_rendering`.
7810    async fn set_task_rendering(
7811        &self,
7812        id: &NativeId,
7813        content: &str,
7814        provenance: &Value,
7815        _answers: &BTreeMap<String, Value>,
7816    ) -> Result<Option<()>, SourceError> {
7817        self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
7818            .await
7819    }
7820
7821    /// Replace one design-document issue's content and its provenance slot entry, on exactly
7822    /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
7823    async fn set_document_rendering(
7824        &self,
7825        id: &NativeId,
7826        content: &str,
7827        provenance: &Value,
7828        _answers: &BTreeMap<String, Value>,
7829    ) -> Result<Option<()>, SourceError> {
7830        self.replace_rendering(id, BoardKind::Document, content, provenance)
7831            .await
7832    }
7833
7834    /// Apply a targeted update with one read of the item and a write only for what differs:
7835    /// at most one `updateIssue` for title, body and state, one field write each for `Status`
7836    /// and `Priority`, and the `blockedBy` difference. See `targeted_update`.
7837    async fn update_task(
7838        &self,
7839        id: &NativeId,
7840        update: &TaskUpdate,
7841    ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7842        self.targeted_update(id, update).await
7843    }
7844
7845    /// Replace one task's `delivered_by` with a single body update that changes the
7846    /// metadata slot and nothing outside it.
7847    async fn set_delivered_by(
7848        &self,
7849        id: &NativeId,
7850        delivered_by: &[TaskRef],
7851    ) -> Result<Option<()>, SourceError> {
7852        self.replace_delivered_by(id, delivered_by).await
7853    }
7854
7855    /// Set one key of one task issue's metadata with a single body update that changes the
7856    /// metadata slot and nothing outside it — no title, label, state or board field request —
7857    /// and sends nothing when the task already holds that value under the key.
7858    async fn set_task_metadata(
7859        &self,
7860        id: &NativeId,
7861        key: &MetadataKey,
7862        value: &Value,
7863    ) -> Result<Option<Task>, SourceError> {
7864        Ok(self
7865            .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
7866            .await?
7867            .map(|item| item.task())
7868            .transpose()?)
7869    }
7870
7871    /// Set one key of one project issue's metadata, on exactly the terms of
7872    /// [`set_task_metadata`](TaskSource::set_task_metadata).
7873    async fn set_project_metadata(
7874        &self,
7875        id: &NativeId,
7876        key: &MetadataKey,
7877        value: &Value,
7878    ) -> Result<Option<Project>, SourceError> {
7879        Ok(self
7880            .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
7881            .await?
7882            .map(|item| item.project()))
7883    }
7884
7885    /// Set one key of one design-document issue's metadata, on exactly the terms of
7886    /// [`set_task_metadata`](TaskSource::set_task_metadata).
7887    async fn set_document_metadata(
7888        &self,
7889        id: &NativeId,
7890        key: &MetadataKey,
7891        value: &Value,
7892    ) -> Result<Option<Document>, SourceError> {
7893        Ok(self
7894            .set_slot_key(id, BoardKind::Document, key, value)
7895            .await?
7896            .map(|item| item.document()))
7897    }
7898
7899    async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
7900        self.delete_item(id).await
7901    }
7902
7903    async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
7904        self.delete_item(id).await
7905    }
7906
7907    async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
7908        self.delete_item(id).await
7909    }
7910
7911    /// One page of the task issue's own comments, walked by GitHub's own cursor.
7912    ///
7913    /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
7914    /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
7915    async fn task_comments(
7916        &self,
7917        task: &NativeId,
7918        page: &PageRequest,
7919    ) -> Result<Option<Page<Comment>>, SourceError> {
7920        validate_page(page)?;
7921        let Some(issue) = self.commented_issue(task).await? else {
7922            return Ok(None);
7923        };
7924        let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7925        let data = self
7926            .graphql(
7927                graphql::ISSUE_COMMENTS,
7928                json!({"id":issue.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after}),
7929            )
7930            .await?;
7931        // The issue was there a moment ago; one removed since is no longer a task here.
7932        let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7933            return Ok(None);
7934        };
7935        let connection = node
7936            .get("comments")
7937            .filter(|value| !value.is_null())
7938            .ok_or_else(|| SourceError::Malformed {
7939                message: format!(
7940                    "GitHub issue {} answered with no comments connection",
7941                    issue.0
7942                ),
7943            })?;
7944        let items = optional_nodes(Some(connection), "issue comments")?
7945            .into_iter()
7946            .flatten()
7947            .map(comment_from)
7948            .collect::<Result<Vec<_>, _>>()?;
7949        let next = next_cursor(connection)?;
7950        if let Some(next) = &next {
7951            validate_cursor_progress(after, &next.0)?;
7952        }
7953        Ok(Some(Page { items, next }))
7954    }
7955
7956    /// Add one comment to the task's issue, as the account the token belongs to.
7957    ///
7958    /// The author is refused before anything is sent — not even the task is read — because
7959    /// no answer GitHub could give would make posting under another name than the one asked
7960    /// for the right outcome.
7961    async fn add_comment(
7962        &self,
7963        task: &NativeId,
7964        comment: &NewComment,
7965    ) -> Result<Option<Comment>, SourceError> {
7966        if let Some(author) = &comment.author {
7967            return Err(SourceError::Refused {
7968                message: format!(
7969                    "source {} cannot post a comment as {author:?}: GitHub records the account \
7970                     the token signs in as the author of every comment; next: leave --author \
7971                     out, and the comment is posted as that account",
7972                    self.name
7973                ),
7974            });
7975        }
7976        let Some(issue) = self.commented_issue(task).await? else {
7977            return Ok(None);
7978        };
7979        let data = self
7980            .graphql(
7981                graphql::ADD_COMMENT,
7982                json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
7983            )
7984            .await?;
7985        let subject = data
7986            .pointer("/addComment/subject")
7987            .filter(|value| !value.is_null())
7988            .ok_or_else(|| SourceError::Malformed {
7989                message: "GitHub comment addition returned no subject".into(),
7990            })?;
7991        if required_str(subject, "id")? != issue.0 {
7992            return Err(SourceError::Malformed {
7993                message: "GitHub comment addition answered about another issue".into(),
7994            });
7995        }
7996        let added = data
7997            .pointer("/addComment/commentEdge/node")
7998            .filter(|value| !value.is_null())
7999            .ok_or_else(|| SourceError::Malformed {
8000                message: "GitHub comment addition returned no comment".into(),
8001            })?;
8002        comment_from(added).map(Some)
8003    }
8004
8005    async fn edit_comment(
8006        &self,
8007        task: &NativeId,
8008        comment: &NativeId,
8009        body: &CommentBody,
8010    ) -> Result<Option<Comment>, SourceError> {
8011        let Some(issue) = self.commented_issue(task).await? else {
8012            return Ok(None);
8013        };
8014        if !self.comment_is_on(&issue, comment).await? {
8015            return Ok(None);
8016        }
8017        let data = self
8018            .graphql(
8019                graphql::UPDATE_COMMENT,
8020                json!({"input":{"id":comment.0,"body":body.as_str()}}),
8021            )
8022            .await?;
8023        let edited = data
8024            .pointer("/updateIssueComment/issueComment")
8025            .filter(|value| !value.is_null())
8026            .ok_or_else(|| SourceError::Malformed {
8027                message: "GitHub comment update returned no comment".into(),
8028            })?;
8029        let edited = comment_from(edited)?;
8030        if edited.id != *comment {
8031            return Err(SourceError::Malformed {
8032                message: "GitHub comment update returned the wrong comment".into(),
8033            });
8034        }
8035        Ok(Some(edited))
8036    }
8037
8038    async fn delete_comment(
8039        &self,
8040        task: &NativeId,
8041        comment: &NativeId,
8042    ) -> Result<Option<NativeId>, SourceError> {
8043        let Some(issue) = self.commented_issue(task).await? else {
8044            return Ok(None);
8045        };
8046        if !self.comment_is_on(&issue, comment).await? {
8047            return Ok(None);
8048        }
8049        let data = self
8050            .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
8051            .await?;
8052        // The payload says nothing about the comment it removed, so what is checked is that
8053        // GitHub answered the mutation at all rather than leaving it unanswered.
8054        data.get("deleteIssueComment")
8055            .filter(|value| !value.is_null())
8056            .ok_or_else(|| SourceError::Malformed {
8057                message: "GitHub comment deletion returned no payload".into(),
8058            })?;
8059        Ok(Some(comment.clone()))
8060    }
8061
8062    /// Every request this source has recorded, and what each of GitHub's two budgets was
8063    /// attributed — read off the same accounting the session report is rendered from, so
8064    /// the two cannot count one request two ways.
8065    async fn metering(&self) -> Result<Option<Metering>, SourceError> {
8066        Ok(Some(self.ledger.snapshot().metering()))
8067    }
8068}
8069
8070/// One issue comment as the contract carries it.
8071///
8072/// `author` is absent both when GitHub answers `null` for an account that no longer exists
8073/// and when it answers an actor with no login, because either way the source did not say who
8074/// wrote it — which is what an absent author means, rather than an author called nothing.
8075fn comment_from(value: &Value) -> Result<Comment, SourceError> {
8076    Ok(Comment {
8077        id: NativeId(required_str(value, "id")?.to_owned()),
8078        author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
8079            .map(str::to_owned),
8080        created_at: optional_time(value, "createdAt")?,
8081        updated_at: optional_time(value, "updatedAt")?,
8082        body: required_str(value, "body")?.to_owned(),
8083        url: optional_str(value, "url")?.map(str::to_owned),
8084    })
8085}
8086
8087/// Where the recorded tail of a dependency walk resumes; see
8088/// [`GitHubProjectsSource::recorded_edges`].
8089const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
8090
8091/// The board text field this source keeps a copy's origin in.
8092///
8093/// Named after the key it holds, and held to that name by the guard below rather than by
8094/// a reader noticing.
8095const ORIGIN_FIELD: &str = "onetaskgraph.origin";
8096
8097/// The metadata key that field holds.
8098///
8099/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
8100/// constructs or interprets the qualified id it carries. This source names it only to
8101/// route it — a short, typed value belongs in a typed field rather than in the body slot
8102/// a caller's own prose shares.
8103///
8104/// Restated rather than imported, because no plugin crate may depend on the engine. What
8105/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
8106/// target in `check`: it reads the engine's own literal and fails naming the file and the
8107/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
8108/// that creates a second item every run instead of finding the one it wrote — and that is
8109/// too late to learn it.
8110const ORIGIN_KEY: &str = "onetaskgraph.origin";
8111
8112/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
8113///
8114/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
8115/// is derived from the far end, never written down on the near item — so only a forward
8116/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
8117/// it did not come from, and it is told so rather than answered with an empty page that
8118/// reads as a walk which ended.
8119fn recorded_offset(
8120    cursor: Option<&str>,
8121    direction: Direction,
8122) -> Result<Option<usize>, SourceError> {
8123    cursor
8124        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
8125        .map(|offset| {
8126            if direction != Direction::DependsOn {
8127                return Err(SourceError::Config {
8128                    message: format!(
8129                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
8130                         reverse dependency read never issues; resume it in the direction \
8131                         that reported it"
8132                    ),
8133                });
8134            }
8135            offset.parse().map_err(|_| SourceError::Config {
8136                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
8137            })
8138        })
8139        .transpose()
8140}
8141
8142fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
8143    let mut page = offset_page(edges, offset, limit.max(1));
8144    page.next = page
8145        .next
8146        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
8147    page
8148}
8149
8150/// The kind of one issue reached through a dependency connection.
8151///
8152/// The same questions the board scan asks, over the fields the dependency document
8153/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
8154/// then anything with sub-issues or the marker is a project.
8155///
8156/// # Errors
8157///
8158/// A far end this board holds as a document is refused rather than reported. The two
8159/// answers that are not refusals would both be wrong: reporting it as a task names an id
8160/// no task read of this source can find, and reporting it as a project names one no
8161/// project read can. There is no third value to return — `ItemKind` has no document
8162/// variant, because nothing may point at a document — so the relationship itself is what
8163/// the person is told about.
8164fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
8165    let id = required_str(value, "id")?;
8166    if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
8167        return Err(SourceError::Refused {
8168            message: format!(
8169                "GitHub issue {id} is a document of this board — its title begins \
8170                 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
8171                 on by one; next: remove that issue's blocking relationship on this board"
8172            ),
8173        });
8174    }
8175    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
8176    if parent.is_some() {
8177        return Ok(ItemKind::Task);
8178    }
8179    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
8180    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8181        message: format!("GitHub issue {id}: {message}"),
8182    })?;
8183    let sub_issues = sub_issue_total(value)?;
8184    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
8185        ItemKind::Project
8186    } else {
8187        ItemKind::Task
8188    })
8189}
8190
8191/// The `IssueStateUpdateInput` one status target asks for.
8192///
8193/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
8194/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
8195/// a currently-closed issue: without that the item would read back `Unknown` and a copy
8196/// would report a change forever. A document has no status at all, and asks for neither.
8197fn state_input(target: Option<&StatusTarget>) -> Value {
8198    match target {
8199        Some(StatusTarget::Terminal(_, reason)) => {
8200            json!({"value":"CLOSED","stateReason":reason.reason()})
8201        }
8202        Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
8203        // A document has no status, so a write of one says nothing about the issue's open
8204        // or closed state rather than forcing it open: `stateInput` is what carries that
8205        // instruction, and an explicit null asks for no change to it.
8206        None => Value::Null,
8207    }
8208}
8209
8210/// The metadata one write stores in the item's body slot.
8211///
8212/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
8213/// rather than carried: the kind marker so an empty project stays readable, the
8214/// repository list only when it is not exactly the issue's own repository, and the far
8215/// ends no relationship here can name.
8216///
8217/// The copy origin is the one typed field that is also mirrored here, and only as a
8218/// mirror: it lands in the board's origin field as well, which stays the one every reader
8219/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
8220/// and catches up with a write in seconds rather than minutes — can find the item by it.
8221/// A reader of the release before this one drops the slot's copy and reads the field, so an
8222/// item written here still reads with exactly one origin there.
8223fn slot_metadata(
8224    incoming: &Incoming<'_>,
8225    own_repository: Option<&Repository>,
8226    fallback: &[DependencyEdge],
8227) -> BTreeMap<String, Value> {
8228    let mut metadata = incoming.metadata.clone();
8229    match metadata.remove(ORIGIN_KEY) {
8230        Some(Value::String(origin)) if !origin.is_empty() => {
8231            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
8232        }
8233        _ => {}
8234    }
8235    match incoming.written.kind() {
8236        BoardKind::Work(kind) => metadata.insert(
8237            ItemKind::METADATA_KEY.to_owned(),
8238            Value::String(kind.marker().to_owned()),
8239        ),
8240        // A document is told by its title, so it carries no kind marker: that key names
8241        // what a dependency endpoint points at, and nothing may point at a document.
8242        BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
8243    };
8244    let derivable = own_repository
8245        .map(|own| incoming.repositories == [own.clone()])
8246        .unwrap_or(incoming.repositories.is_empty());
8247    if derivable {
8248        metadata.remove(Repository::METADATA_KEY);
8249    } else {
8250        metadata.insert(
8251            Repository::METADATA_KEY.to_owned(),
8252            Value::Array(
8253                incoming
8254                    .repositories
8255                    .iter()
8256                    .map(|repository| Value::String(repository.as_str().to_owned()))
8257                    .collect(),
8258            ),
8259        );
8260    }
8261    // The typed lists are what land, whatever the caller's own metadata held under their
8262    // keys: a key of either name travelling beside the field would otherwise be a second
8263    // answer to the same question, and the field is the one the contract names.
8264    for (key, entries) in [
8265        (TaskRef::DELIVERS_KEY, incoming.delivers),
8266        (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
8267    ] {
8268        set_task_list(&mut metadata, key, entries);
8269    }
8270    record_edges(&mut metadata, fallback);
8271    metadata
8272}
8273
8274/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
8275/// one slot's metadata, or no such key when there are none.
8276fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
8277    if fallback.is_empty() {
8278        metadata.remove(DependencyEdge::RECORDED_KEY);
8279    } else {
8280        metadata.insert(
8281            DependencyEdge::RECORDED_KEY.to_owned(),
8282            Value::Array(
8283                fallback
8284                    .iter()
8285                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
8286                    .collect(),
8287            ),
8288        );
8289    }
8290}
8291
8292/// Every label one item carries, from its content's own connection and nowhere else.
8293///
8294/// There is no second place to read one from: no document this source sends selects the
8295/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
8296/// cannot carry one at all. The module documentation records the three schema facts that
8297/// settle it.
8298fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
8299    optional_nodes(content.get("labels"), "content labels")?
8300        .into_iter()
8301        .flatten()
8302        .map(|v| {
8303            Ok(Label {
8304                id: NativeId(required_str(v, "id")?.to_owned()),
8305                name: required_str(v, "name")?.to_owned(),
8306                color: optional_str(v, "color")?.map(str::to_owned),
8307            })
8308        })
8309        .collect()
8310}
8311
8312/// The definition of each board field one item's values are values of, in the shape a read
8313/// of the board's own `fields` gives one.
8314///
8315/// A value names its field through a fragment on that field's own type, so the type is
8316/// known from which kind of value it is: a single-select value's field is a
8317/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
8318/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
8319fn field_definitions(field_values: &[Value]) -> Vec<Value> {
8320    field_values
8321        .iter()
8322        .filter_map(|value| {
8323            let field = value.get("field")?.as_object()?;
8324            field.get("id")?.as_str().filter(|id| !id.is_empty())?;
8325            let typename = if value.get("text").is_some() {
8326                "ProjectV2Field"
8327            } else if value.get("name").is_some() {
8328                "ProjectV2SingleSelectField"
8329            } else {
8330                return None;
8331            };
8332            let mut defined = field.clone();
8333            defined.insert("__typename".to_owned(), json!(typename));
8334            Some(Value::Object(defined))
8335        })
8336        .collect()
8337}
8338
8339fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
8340    let Some(node) = field_values
8341        .iter()
8342        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
8343    else {
8344        return Ok(None);
8345    };
8346    Ok(optional_str(node, "text")?.map(str::to_owned))
8347}
8348
8349fn valid_github_owner(owner: &str) -> bool {
8350    !owner.is_empty()
8351        && owner.len() <= 39
8352        && !owner.starts_with('-')
8353        && !owner.ends_with('-')
8354        && !owner.contains("--")
8355        && owner
8356            .bytes()
8357            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
8358}
8359
8360/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
8361/// neither of the two names a path segment already means.
8362fn valid_github_repository_name(name: &str) -> bool {
8363    !name.is_empty()
8364        && name.len() <= 100
8365        && name != "."
8366        && name != ".."
8367        && name
8368            .bytes()
8369            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
8370}
8371
8372fn valid_environment_name(name: &str) -> bool {
8373    let mut bytes = name.bytes();
8374    bytes
8375        .next()
8376        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
8377        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
8378}
8379
8380/// How many sub-issues one issue has.
8381///
8382/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
8383/// absent or non-integer one is a response this source cannot read — and reading it as
8384/// zero would classify a project as a task, which is exactly the mistake the marker
8385/// exists to keep from happening quietly.
8386fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
8387    let summary = issue
8388        .get("subIssuesSummary")
8389        .ok_or_else(|| SourceError::Malformed {
8390            message: "GitHub issue is missing subIssuesSummary".into(),
8391        })?;
8392    summary
8393        .get("total")
8394        .and_then(Value::as_u64)
8395        .ok_or_else(|| SourceError::Malformed {
8396            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
8397        })
8398}
8399
8400/// One issue's own `number`.
8401///
8402/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
8403/// an issue in this module asks for it. So a read of one that comes back without it, or
8404/// with something that is not an unsigned integer, is a response this source cannot read —
8405/// absence here is **not** "this issue has no number". A draft is the content that has
8406/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
8407/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
8408fn issue_number(issue: &Value) -> Result<u64, SourceError> {
8409    issue
8410        .get("number")
8411        .and_then(Value::as_u64)
8412        .ok_or_else(|| SourceError::Malformed {
8413            message: "GitHub issue number is missing or is not an unsigned integer".into(),
8414        })
8415}
8416
8417/// The `number` a creating mutation answered with, and `None` when it answered without one;
8418/// why a missing one is tolerated is at the call in `create_and_file_issue`.
8419fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
8420    match created.get("number") {
8421        None | Some(Value::Null) => Ok(None),
8422        Some(value) => value
8423            .as_u64()
8424            .map(Some)
8425            .ok_or_else(|| SourceError::Malformed {
8426                message: "GitHub created issue number is not an unsigned integer".into(),
8427            }),
8428    }
8429}
8430
8431fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8432    value
8433        .get(field)
8434        .and_then(Value::as_str)
8435        .ok_or_else(|| SourceError::Malformed {
8436            message: format!("GitHub response is missing string field {field}"),
8437        })
8438}
8439
8440fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8441    let found = required_str(value, field)?;
8442    if found.trim().is_empty() {
8443        return Err(SourceError::Malformed {
8444            message: format!("GitHub response has blank string field {field}"),
8445        });
8446    }
8447    Ok(found)
8448}
8449
8450/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
8451/// needs one — Linear spells them too, in its own description field.
8452///
8453/// Restated rather than shared, because a plugin crate depends on the contract crate and
8454/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
8455/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
8456/// source round-trips its own writes perfectly well under its own spelling.
8457const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
8458const METADATA_CLOSE: &str = "\n-->";
8459
8460/// What the composer puts between a non-empty visible body and the slot, and the one thing
8461/// the parser takes off the visible body when it takes the slot off — exactly once, so every
8462/// other trailing byte of the body comes back as it was written.
8463// 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.
8464const METADATA_SEPARATOR: &str = "\n\n";
8465
8466/// The visible body and the metadata slot at the end of it.
8467///
8468/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
8469/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
8470/// own content and is left alone. The visible body is everything before the slot less the
8471/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
8472fn metadata_body(
8473    body: Option<String>,
8474) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
8475    let Some(body) = body else {
8476        return Ok((None, BTreeMap::new()));
8477    };
8478    let Some(slot) = slot_span(&body)? else {
8479        return Ok((Some(body), BTreeMap::new()));
8480    };
8481    let metadata =
8482        serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
8483            SourceError::Malformed {
8484                message: format!(
8485                    "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
8486                ),
8487            }
8488        })?;
8489    let before = &body[..slot.start];
8490    let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
8491    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
8492}
8493
8494/// Where the metadata slot sits in one body, as byte offsets into it.
8495struct SlotSpan {
8496    /// Where [`METADATA_OPEN`] begins.
8497    start: usize,
8498    /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
8499    encoded_start: usize,
8500    /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
8501    encoded_end: usize,
8502    /// Just past [`METADATA_CLOSE`].
8503    end: usize,
8504}
8505
8506/// The slot at the very end of `body`, or `None` when it has none.
8507///
8508/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
8509/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
8510/// slot.
8511fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
8512    let Some(start) = body.rfind(METADATA_OPEN) else {
8513        return Ok(None);
8514    };
8515    let encoded_start = start + METADATA_OPEN.len();
8516    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
8517        return Err(SourceError::Malformed {
8518            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
8519        });
8520    };
8521    let encoded_end = encoded_start + relative_end;
8522    let end = encoded_end + METADATA_CLOSE.len();
8523    if !body[end..].trim().is_empty() {
8524        return Ok(None);
8525    }
8526    Ok(Some(SlotSpan {
8527        start,
8528        encoded_start,
8529        encoded_end,
8530        end,
8531    }))
8532}
8533
8534/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
8535/// slot as it was.
8536///
8537/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
8538/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
8539/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
8540/// or alone in an empty body — and a body with no slot that is given no metadata is
8541/// returned as it is.
8542fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
8543    let encoded = if metadata.is_empty() {
8544        None
8545    } else {
8546        Some(
8547            serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8548                message: error.to_string(),
8549            })?,
8550        )
8551    };
8552    Ok(match (slot_span(body)?, encoded) {
8553        (Some(slot), Some(encoded)) => format!(
8554            "{}{encoded}{}",
8555            &body[..slot.encoded_start],
8556            &body[slot.encoded_end..]
8557        ),
8558        (Some(slot), None) => {
8559            let before = &body[..slot.start];
8560            format!(
8561                "{}{}",
8562                before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
8563                &body[slot.end..]
8564            )
8565        }
8566        (None, None) => body.to_owned(),
8567        (None, Some(encoded)) if body.is_empty() => {
8568            format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8569        }
8570        (None, Some(encoded)) => {
8571            format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8572        }
8573    })
8574}
8575
8576/// `body` with everything before its metadata slot replaced by `content`, and the slot
8577/// itself kept byte for byte.
8578///
8579/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
8580/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
8581/// `content` is empty — so a read of the result reports `content` as the visible body and
8582/// the slot's metadata exactly as it was.
8583fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
8584    let Some(slot) = slot_span(body)? else {
8585        return Ok(content.to_owned());
8586    };
8587    let kept = &body[slot.start..];
8588    Ok(if content.is_empty() {
8589        kept.to_owned()
8590    } else {
8591        format!("{content}{METADATA_SEPARATOR}{kept}")
8592    })
8593}
8594
8595/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
8596fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
8597    if entries.is_empty() {
8598        metadata.remove(key);
8599    } else {
8600        metadata.insert(
8601            key.to_owned(),
8602            Value::Array(
8603                entries
8604                    .iter()
8605                    .map(|entry| Value::String(entry.as_str().to_owned()))
8606                    .collect(),
8607            ),
8608        );
8609    }
8610}
8611
8612fn compose_body(
8613    content: Option<&str>,
8614    metadata: &BTreeMap<String, Value>,
8615) -> Result<Option<String>, SourceError> {
8616    let visible = content.unwrap_or_default();
8617    if metadata.is_empty() {
8618        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
8619    }
8620    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8621        message: error.to_string(),
8622    })?;
8623    Ok(Some(if visible.is_empty() {
8624        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8625    } else {
8626        format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8627    }))
8628}
8629
8630fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
8631    value
8632        .get(field)
8633        .and_then(Value::as_bool)
8634        .ok_or_else(|| SourceError::Malformed {
8635            message: format!("GitHub response is missing boolean field {field}"),
8636        })
8637}
8638fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
8639    match value.get(field) {
8640        None | Some(Value::Null) => Ok(None),
8641        Some(value) => value
8642            .as_str()
8643            .map(Some)
8644            .ok_or_else(|| SourceError::Malformed {
8645                message: format!("GitHub response field {field} is not a string or null"),
8646            }),
8647    }
8648}
8649fn optional_nodes<'a>(
8650    connection: Option<&'a Value>,
8651    name: &str,
8652) -> Result<Option<&'a Vec<Value>>, SourceError> {
8653    match connection {
8654        None | Some(Value::Null) => Ok(None),
8655        Some(value) => value
8656            .get("nodes")
8657            .and_then(Value::as_array)
8658            .map(Some)
8659            .ok_or_else(|| SourceError::Malformed {
8660                message: format!("GitHub {name}.nodes is not an array"),
8661            }),
8662    }
8663}
8664fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
8665    let page_info = connection
8666        .get("pageInfo")
8667        .ok_or_else(|| SourceError::Malformed {
8668            message: format!("GitHub {name} has no pageInfo"),
8669        })?;
8670    if required_bool(page_info, "hasNextPage")? {
8671        return Err(SourceError::Malformed {
8672            message: format!(
8673                "GitHub {name} exceeds the supported nested connection size of {size}"
8674            ),
8675        });
8676    }
8677    Ok(())
8678}
8679fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
8680    optional_str(value, field)?
8681        .map(|timestamp| {
8682            timestamp.parse().map_err(|error| SourceError::Malformed {
8683                message: format!("GitHub response field {field} is not a timestamp: {error}"),
8684            })
8685        })
8686        .transpose()
8687}
8688fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
8689    if page.limit == 0 {
8690        Err(SourceError::Config {
8691            message: "page limit must be at least 1".into(),
8692        })
8693    } else {
8694        Ok(())
8695    }
8696}
8697fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
8698    let page = connection
8699        .get("pageInfo")
8700        .filter(|value| value.is_object())
8701        .ok_or_else(|| SourceError::Malformed {
8702            message: "GitHub connection is missing pageInfo".into(),
8703        })?;
8704    if required_bool(page, "hasNextPage")? {
8705        let cursor = required_str(page, "endCursor")?;
8706        validate_cursor_progress(None, cursor)?;
8707        Ok(Some(Cursor(cursor.into())))
8708    } else {
8709        Ok(None)
8710    }
8711}
8712fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
8713    if next.is_empty() || previous == Some(next) {
8714        Err(SourceError::Malformed {
8715            message: "GitHub pagination cursor is empty or did not advance".into(),
8716        })
8717    } else {
8718        Ok(())
8719    }
8720}
8721fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
8722    cursor.map_or(Ok(0), |c| {
8723        c.0.parse().map_err(|_| SourceError::Config {
8724            message: "page cursor is invalid".into(),
8725        })
8726    })
8727}
8728fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
8729    if offset > items.len() {
8730        return Page::last(vec![]);
8731    }
8732    let tail = items.split_off(offset);
8733    let mut selected = tail;
8734    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
8735    selected.truncate(limit);
8736    Page {
8737        items: selected,
8738        next,
8739    }
8740}