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}