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