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