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