onetaskgraph_github_projects/lib.rs
1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration from a status category to
85//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
86//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
87//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
88//! A missing mapped option refuses the write before either representation changes. Reads
89//! give a closed issue's reason precedence over its option, while an open issue's option
90//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
91//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
92//! list, so it preserves every existing option id and verifies the field and item
93//! assignments immediately afterwards. It counts a terminal category's mapped option as
94//! configured, because a terminal write refuses without it. No ordinary source read or
95//! write calls that mutation, whose
96//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
97//! and a mistake destroys every item's status. A status this board cannot represent is a
98//! refusal naming the status and the instance instead.
99//!
100//! `unknown` is disabled by default because this source cannot preserve an open-ended
101//! status word: it writes an existing board option and never
102//! creates an option. An operator may map `unknown` to one existing option, in which case
103//! every unknown word lands on that option and reads back as `unknown` under the option's
104//! name. This differs from `local-md`, which writes and reads the original word itself.
105//!
106//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
107//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
108//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
109//! finished tasks were only moved to a "Done" column would read 0% complete forever.
110// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
111//!
112//! # What this source declares, field by field
113//!
114//! One verdict per field of [`Capabilities`], and what `Native` means when this source
115//! says it. *Proven* means a shared journey drives it against the real
116//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
117//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
118//! [`capabilities`](TaskSource::capabilities) from parting.
119//!
120//! | Field | Verdict |
121//! | --- | --- |
122//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
123//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
124//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
125//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
126//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
127//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so neither `ProjectV2.items` nor any issue the search did not name is read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
128//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
129//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
130//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping`. |
131//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
132//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
133//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project or document query's text is applied by that same substring rule over the issues its read already holds, and narrows nothing. |
134//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
135//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
136//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
137//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
138//!
139//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
140//! source has documents and that its tasks have comments, both of which hold — and the three
141//! facts behind the uniform `Native` on the
142//! predicates beside it are recorded below rather than re-derived, because a reader who
143//! takes `Native` to mean *the remote service filters* will read that uniformity as a
144//! lie.
145//!
146//! First, the plugin contract defines `Support::Native` as *the source applies this
147//! predicate itself*, and says nothing about where it applies it. What the declaration
148//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
149//! — so that the engine may push it down and apply nothing of its own.
150//!
151//! Second, this source can keep that promise for every predicate at no additional API
152//! cost, because whichever of the reads below answers a query has already read every
153//! candidate that query will return before it filters anything. Filtering those items is
154//! in-process work over data already in hand.
155//!
156//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
157//! applied in process over what that question returned. A project filter has a relationship — a
158//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
159//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
160//! a search for metadata values, is the board-scoped issue search carrying the text and each
161//! value as quoted phrases; an origin is the board's own field filter over its origin field
162//! beside the same search for the id. **The text search narrows, and that is this source's
163//! declared semantics:** GitHub matches whole words where the substring rule this source and
164//! the local Markdown source confirm with would match inside one, so an item holding the text
165//! only inside a longer word is never a candidate. Every item returned does contain the text.
166//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
167//! so those three are applied in process over the candidates, and a query carrying none of
168//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
169//! engine compensate for work this source has already done, and declaring `projects` native
170//! while ignoring the filter (which this source once did) silently returns another project's
171//! tasks, because the engine trusts the declaration and applies nothing locally.
172//!
173//! # The three ways this source reaches an item, and what each costs
174//!
175//! A board read is charged for what its *nested* connections could return rather than for
176//! what was asked, so one whole-board read costs the same whether the question was about
177//! one project or about all of them. That is why a question about one project is never
178//! answered by reading the board:
179//!
180//! | The question | What is sent | What it costs |
181//! | --- | --- | --- |
182//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)` — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
183//! | the board's own id and field definitions, for a write whose item does not carry them | [`graphql::BOARD_FIELDS`] — the board's `id` and `fields`, and no `items` | the board's fields |
184//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
185//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
186//! | which tasks were commented on since an instant | [`graphql::SEARCH_ISSUES`] — the same board-scoped search with an `updated:>=` qualifier — then [`graphql::ISSUE_COMMENTS`] for each candidate it names | the issues updated since, and their comments |
187//! | which tasks hold a text, or a metadata value | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text and each value as quoted phrases, `in:title`, `in:body` or both, and an `updated:>=` qualifier too when comment activity is asked for — paged at [`MAX_PAGE_SIZE`] | the issues that match |
188//! | which tasks were copied from one origin | [`graphql::ORIGIN_LOOKUP`] — the board's own `items` under its field filter on the origin field, and the same board-scoped search for the id `in:body`, in one request, each paged at three | the carriers of that origin, which is one item |
189//! | every task, every document, every label, when nothing above narrows the question | [`graphql::BOARD`] — the board's own `items` — **and** [`graphql::SEARCH_ISSUES`], because neither enumeration of a board is complete alone; see [`GitHubProjectsSource::board`] | the board, twice over |
190//! | which board item one issue is, past the page that came with it | [`graphql::ISSUE_BOARD_ITEMS`] — that issue's own `projectItems` | one issue's memberships |
191//!
192//! The board half of an issue — its board item's id, its `Status` option and this
193//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
194//! an item reached any of those ways resolves through the same
195//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
196//! same status, the same labels and the same qualified id. That connection comes back a
197//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
198//! on the page in hand and — only if that page reports more of the connection — in the
199//! last row's read of that one issue's memberships, resumed from the page's own cursor and
200//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
201//! report, which is what keeps an id naming another repository's issue from being answered
202//! as an item of this board; and because the page is where the search starts rather than
203//! where it ends, that answer is one about a connection read to exhaustion and never about
204//! an unread page. Nothing costs the extra read but an issue on more boards than a page
205//! holds: an issue this board really does not hold reports no next page, so its
206//! memberships are already exhausted where they arrived.
207//!
208//! **No document here selects the board's own `Labels` field, and nothing is lost by
209//! that.** An item's labels are read from its content alone, wherever that content is
210//! reached: the three documents above select `Issue.labels` on the fragment, and
211//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
212//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
213//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
214//! create one, and `ProjectV2FieldValue` — the whole of what
215//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
216//! it from the content, for every content type it exists on, and there is nothing it can
217//! hold that the content does not already say: for an `Issue` it *is* that issue's own
218//! labels, so selecting it beside them unions a set with itself.
219//!
220//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
221//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
222//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
223//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
224//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
225//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
226//! label set, and that set is the fixture issue's own, by
227//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
228//! item whose content is a draft reports an empty set, by
229//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
230//! selection is held over [`graphql::DOCUMENTS`] by
231//! `no_document_selects_the_boards_own_labels_field`.
232//!
233//! The whole-board row is still the board's own item connection, and deliberately: a
234//! **draft** board item is not an issue, so no search can list one, and the reads that have
235//! to answer for the whole board are the ones whose cost is the board's size anyway.
236//!
237//! **A question about one item this source already names by id never lists the board.**
238//! Whether that item is on this board, and what its board fields are, is answered by reading
239//! that item — its own `Issue.projectItems`, walked to exhaustion by
240//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
241//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
242//! holds. That covers a write's destination, the project a new item is filed under, a
243//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
244//! and the delete that takes back an item a copy made. What such a write needs of the board
245//! and the item does not carry — the board's id, the `Status` and origin field definitions —
246//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
247//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
248//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
249//! minutes. Scanning this host's 842-item board has refused a document copy and an update
250//! even though the items' own reads named that board. A scan there gives the wrong answer
251//! as well as paying for every page. So a `board.items` lookup does not belong on any of
252//! those paths.
253//!
254//! **What a read may return is capped too, and that cap is on the document rather than on
255//! the board.** GitHub limits the number of nodes **one query may return** to
256//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
257//! an error naming the connection the count crossed at, not a slow or a partial result.
258//! Every board this source reads is refused the same way, so no board is too big for these
259//! documents and none is small enough to save one that is over.
260//!
261//! The count is arithmetic over the document's own text: each connection contributes the
262//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
263//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
264//! restate them — `github-graphql-node-count` implements them, and
265//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
266//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
267//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
268//! same text on every run and fails naming any that reaches the limit — so a connection
269//! added to a shared fragment is caught there rather than by GitHub.
270//!
271//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
272//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
273//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
274//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
275//! effectively squared there, which is why it is the one the limit is most sensitive to.
276//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
277//! page of memberships misses is recovered by one further read rather than refused, so it
278//! buys a bound every read pays for at the price of a request only a multi-board issue
279//! pays.
280//!
281//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
282//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
283//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
284//! `cost` is rate-limit points, metered per hour across everything one credential does; it
285//! is what the two limiters [`Limiter`] tells apart meter, and a document under
286//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
287//! that second number, and `tests/point_cost.rs` pins every document in
288//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
289//! one under, the pin itself is the check. The credentialed lane reconciles both figures
290//! against GitHub's own, off a probe it already sends.
291//!
292//! **What is pinned that way is a per-document price and never a session's.** The record in
293//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
294//! **requests** and **worst-case nodes** — and neither is points. What one whole session
295//! consumes of the hourly point allowance is observable only from a credentialed run's own
296//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
297//! and what `tests/live.rs` prints at the end of every run.
298//!
299//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
300//!
301//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
302//! enumerations of a board can supply one alone.** Resolving a node id is strongly
303//! consistent, so a read by id and a project's own sub-issues are already current. The
304//! other two are not, and they are behind by different amounts and in different directions:
305//!
306//! - GitHub's **issue search** is an index and answers a write made moments ago with the
307//! value from before it — usually for a second or two.
308//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
309//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
310//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
311//! for *minutes*, while `Issue.projectItems` names the same membership at once.
312//!
313//! That second one is a measurement rather than a caution. This repository's own
314//! credentialed journey writes a project and waits for the board to report it, then writes a
315//! task and waits for the same thing seconds later on the same board: the project wait is
316//! answered through the search and converged in two or three attempts in each of three runs,
317//! and the task wait is answered through `ProjectV2.items` and converged in none of them
318//! inside thirty. Separately, an item added to a second and larger board was read back by
319//! `Issue.projectItems` on that board's own id while every one of that connection's nine
320//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
321//! through the lagging one alone is what had a board read deny an issue that had certainly
322//! landed on it.
323//!
324//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
325//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
326//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
327//! search reports what the projection is behind on. What closes the last
328//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
329//! source answers is completed with what this process itself wrote, so an item created
330//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
331//! nothing is written down, and the record dies with the process. **A wait that has to
332//! observe GitHub's own data cannot be answered from that record** — which is why the
333//! credentialed journey asks through a source built afresh, and why the union above rather
334//! than a longer wait is what makes such a wait converge.
335//!
336//! **A narrowed read is the same bargain, stated for each of the three predicates it
337//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
338//! than walking the board, and every such answer is completed with what this process wrote —
339//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
340//! each filtered by the same predicates as the rest — so an item this command wrote a moment
341//! ago is returned by a query that matches it whether or not the index has caught up. An item
342//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
343//! consistent. What is left is stated rather than papered over:
344//!
345//! | Read | Finds | Behind by |
346//! | --- | --- | --- |
347//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
348//! | origin, first read | the board's field filter over the origin field — every carrier, whichever release wrote it | what `ProjectV2.items` is behind on, which the measurements above put in minutes |
349//! | origin, second read | the issue search for the id in the body, where a write of this release mirrors it | a second or two, as any search |
350//! | origin, third read | this process's own writes | nothing |
351//!
352//! So an origin carrier another process added within the last second or two, before either
353//! index has it, can be missing from an origin query, and one written by the release before
354//! this one — its origin in the field alone — can be missing for as long as the board's own
355//! item connection is behind on it. A copy that must not duplicate its own earlier write
356//! relies on the link it records, not on either index. **A board draft is not an issue**, so
357//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
358//! lists one, the origin lookup drops any the board's own field filter names, and one this
359//! process wrote is not added back either.
360//!
361//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
362//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
363//! body's metadata slot, so the issue search can find it in seconds. The field is
364//! authoritative: this source reads an item's origin from the field alone, so a slot that
365//! disagrees with it, or holds one where the field holds none, is never read as a second
366//! origin — and the release before this one reads the slot, drops that key's copy for the
367//! field's, and sees the same one origin.
368//!
369//! Filtering happens before paging, so a page of a filtered result is a page of the
370//! survivors rather than the survivors of a page. Label matching and the substring rule a
371//! text candidate is confirmed by answer the same question the same way the local Markdown
372//! source's do; which candidates a text search has to confirm is GitHub's word match, which
373//! is the one place the two sources can answer the same text differently.
374//!
375//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
376//! has one source, `capabilities`, and the note above is the reasoning behind it rather
377//! than a second copy of it: without the three facts recorded here a reader takes the
378//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
379//! crate's own capabilities test, which pins every field of it against a fully spelled-out
380//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
381//! fails to compile there rather than going unasserted. -->
382//! The fixture-server tests above run wherever this crate is selected; the credentialed
383//! lane runs in the same required check, beside them, and can fail it — it verifies the
384//! current schema, then drives every field of the table above against the real board. It builds its own fixture there — two projects, one task filed under each,
385//! one filed under neither, a label on one of the three and a closed status on another —
386//! because that shape is what tells an honoured predicate from an ignored one: a board
387//! holding a single project answers a project filter the same way whether or not this
388//! source applies it, which is exactly how the defect above went unseen.
389//!
390//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
391//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
392//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
393//! nominated is what keeps a credentialed write lane off a board and a repository nobody
394//! nominated; it never asks GitHub which project was updated most recently. Before it
395//! starts, the lane also clears any item titled — and any repository label named — the way
396//! it titles and names its own artifacts, which is self-healing after an interrupted run:
397//! a process killed between its writes and its cleanup leaves artifacts the next run
398//! removes.
399//!
400//! # What a session of requests costs, and where the report is
401//!
402//! This source records **every** request it sends into [`accounting::Accounting`], at
403//! `send_once` — the one place a request leaves this crate, which is why a read path added
404//! later is counted without anybody remembering to count it. That is the whole of what this
405//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
406//! session's spend is arrived at, and what it deliberately does not know are set out.
407//!
408//! What one whole session of the live journey costs, counted that way against this crate's
409//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
410//! reduction it came out of, and with what it does and does not say about rate-limit points.
411//!
412//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
413//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
414//! code path — no environment variable, no feature, no build configuration — because an
415//! instrument nobody switches on measures nothing, and
416//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
417//! source's counts the whole session rather than this source's share. The credentialed lane
418//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
419//! passed or failed.
420//!
421//! **A live session refuses to start unless the account can afford it.** Before it does any
422//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
423//! GitHub documents as not counting against the REST rate limit and which answers both of
424//! its budgets at once — and starts only if, for each of them, what remains minus this
425//! session's estimated cost is still at least
426//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
427//! allowance. A session that cannot **declines**: it did not run, so it is
428//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
429//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
430//! waiting for the budget to come back. The estimate is derived offline from
431//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
432//! which is also where the published rule that model rests on is cited; the accounting
433//! above records the gate's own read like any other request, and
434//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
435//!
436//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
437//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
438//! text, which is what lets it run on every platform and on a pull request from a fork with
439//! no credential — and that is what actually stops a regression merging. But an offline
440//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
441//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
442//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
443//! number of nodes this query may return"* and whose `cost` is what that document would
444//! spend, both for a document **without executing it**, and the lane asks it for every query
445//! document this source sends, under the largest bindings this source sends, and fails when
446//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
447//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
448//! one; the offline pins still cover it. It records what those calls reported about the
449//! account's own allowance, because whether asking is free is a thing to observe rather than
450//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
451//! and `cost` is metered against an hourly allowance the accounting above reads off a
452//! credentialed run's own response headers.
453//!
454//! **GitHub has two rate limiters and this source is refused by both, so nothing here
455//! treats them as one thing.** The primary budget is the hourly allowance `gh api
456//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
457//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
458//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
459//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
460//! one part of.
461#![deny(missing_docs)]
462
463use std::collections::BTreeMap;
464use std::sync::{Arc, Mutex};
465use std::time::{Duration, Instant};
466
467use chrono::{DateTime, Utc};
468use onetaskgraph_plugin_api::{
469 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
470 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
471 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
472 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
473 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskRef,
474 TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
475};
476use reqwest::{Client, StatusCode, Url};
477use schemars::{Schema, schema_for};
478use secrecy::{ExposeSecret, SecretString};
479use serde::{Deserialize, Serialize};
480use serde_json::{Value, json};
481
482pub mod accounting;
483
484use accounting::Accounting;
485
486/// The registry name for this plugin.
487pub const KIND: &str = "github-projects";
488/// GitHub's maximum connection page size.
489pub const MAX_PAGE_SIZE: u32 = 100;
490
491/// The most nodes any one document this source sends may be asked to return.
492///
493/// GitHub's own published per-query ceiling, taken from
494/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
495/// workspace cannot hold a stale copy of somebody else's number. A query above it is
496/// **refused before it is executed**, whoever is asking and whatever board they are
497/// asking about — so this is a bound on the documents rather than a budget that runs out.
498///
499/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
500/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
501/// everything the credential does — two numbers against two limits, and this constant
502/// bounds only the first. The second is computed offline too, per document:
503/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
504/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
505/// lane. There is no constant like this one to hold a price under, because points are an
506/// hourly allowance rather than a per-call bound.
507///
508/// Neither is a session's price. What `session-cost.md` records of a whole session is its
509/// **requests** and its **worst-case nodes**; what a whole session spends in points is
510/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
511/// [`accounting`]. The module section on the three ways this source reaches an item says how
512/// the count is arrived at, and which of the page sizes below decide it.
513pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
514
515/// Nested connection size for the connections that hang off one item.
516///
517/// It multiplies through every document that reaches an item under a page — the count
518/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
519/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
520/// every document under these constants and fails naming any that reaches the limit, so
521/// raising this is caught there rather than by GitHub.
522const NESTED_PAGE_SIZE: u32 = 50;
523/// How many of one issue's board memberships are read when an issue is reached directly.
524///
525/// An issue reached through a search or through its own node id carries its board half in
526/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
527/// under a page of issues, so every point of it multiplies through the whole document and
528/// is paid for whether or not any issue is on a second board — which is why it is
529/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
530///
531/// **Three, because what a page misses is now recovered rather than refused**, and the
532/// recovery is what the value is chosen against. An issue whose entry for this board sits
533/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
534/// that page's own cursor — so the value trades a bound every read pays for a request only
535/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
536/// boards would pay that request *per issue*, which is order N against the one page per
537/// hundred issues a read costs today. At three it is only reached by an issue on four or
538/// more boards at once, which keeps the recovery path exceptional rather than routine for
539/// a plausible deployment.
540const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
541/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
542/// its two connections for.
543///
544/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
545/// second is a duplicate a copy already takes the first of. Both connections are walked to
546/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
547/// never what the answer is. It is small because every point of it is paid on every lookup,
548/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
549/// worst-case nodes than the one whole-board read they replaced.
550const ORIGIN_PAGE_SIZE: u32 = 3;
551
552pub use github_graphql_node_count::{NodeCountError, Variables};
553
554/// The largest value this source can bind to each page-size variable its documents name.
555///
556/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
557/// constant above it wherever a caller's own limit could reach it — `$first` at
558/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
559/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
560/// not one configuration of it, which is what makes a bound computed under it a bound on
561/// every read.
562pub fn largest_page_sizes() -> Variables {
563 Variables::from([
564 ("first".to_owned(), MAX_PAGE_SIZE),
565 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
566 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
567 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
568 ])
569}
570
571/// The most nodes `document` could be asked to return, by GitHub's published rules.
572///
573/// Computed offline from the document's own text under [`largest_page_sizes`] — no
574/// network, no credential and no schema — by
575/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
576/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
577/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
578///
579/// # Errors
580///
581/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
582/// no single operation, or binds a page size this source does not name — each of which is
583/// a defect in the document rather than a number.
584pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
585 node_count(document, &largest_page_sizes())
586}
587
588/// The most rate-limit points one call of `document` could spend, by GitHub's published
589/// rules.
590///
591/// Computed offline from the document's own text under [`largest_page_sizes`] — no
592/// network, no credential and no schema — by
593/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
594/// This is `cost`, metered **per hour** against the allowance one credential shares across
595/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
596/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
597/// under, so what `tests/point_cost.rs` does with it is pin every document in
598/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
599/// figures against GitHub's own reported `cost`.
600///
601/// # Errors
602///
603/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
604/// no single operation, or binds a page size this source does not name — each of which is
605/// a defect in the document rather than a number.
606pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
607 github_graphql_node_count::point_cost(document, &largest_page_sizes())
608}
609
610/// The most nodes `document` could be asked to return under `variables`.
611///
612/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
613/// [`accounting`] is this under the bindings one request really sent — one spelling of the
614/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
615/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
616///
617/// # Errors
618///
619/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
620/// no single operation, or binds a page size `variables` does not name.
621pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
622 github_graphql_node_count::node_count(document, variables)
623}
624
625/// The issue-title prefix that makes a board issue a document.
626///
627/// A GitHub Projects board has no document type — it holds issues — so the discriminator
628/// is the title, and this is the whole of it: an issue whose title begins with these bytes
629/// is a document and every other issue is the task or project the sub-issue rule makes it.
630///
631/// It is spelled **once**, here, and read rather than restated everywhere else — including
632/// by the shared journeys, which take it from this constant so a board fixture cannot
633/// drift from what this source reads. `docs/metadata.md` records the two consequences that
634/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
635/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
636/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
637pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
638
639/// Exact GraphQL query documents issued by this plugin.
640///
641/// Keeping the production documents here lets the pinned-schema test validate the same
642/// bytes that are sent to GitHub, rather than a test-only copy which could drift
643/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
644/// field, and its guarded caller always supplies the complete existing option set with ids.
645pub mod graphql {
646 /// The board half of one item: the field values every document here reads it from.
647 ///
648 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
649 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
650 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
651 /// *the same value*, because
652 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
653 /// one path. Three spellings of it is what would drift, so there is one.
654 ///
655 /// The `Status` option and this source's own origin text field are the whole of it. It
656 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
657 /// content, so it holds nothing the content's own `labels` do not already say, and it
658 /// would sit a label connection two page sizes deep.
659 macro_rules! board_item_values {
660 () => {
661 r#"fieldValues(first:$nestedFirst){nodes{
662 ... on ProjectV2ItemFieldSingleSelectValue{name field{
663 ... on ProjectV2SingleSelectField{id name options{id name}}
664 }}
665 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
666 }pageInfo{hasNextPage}}"#
667 };
668 }
669
670 /// Everything this source reads about one issue, wherever it reaches that issue.
671 ///
672 /// A macro rather than a constant so the three documents below can `concat!` it: one
673 /// spelling of these fields is what makes an issue read through the board-scoped
674 /// search, through its own node id, and through its project's sub-issue relationship
675 /// resolve to *the same* item, which is the whole of what
676 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
677 ///
678 /// `projectItems` is what carries the board half of an issue: the board item's own id
679 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
680 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
681 /// issue rather than on the board, which is what makes the cost of a read proportional
682 /// to what was asked for instead of to the board's size.
683 ///
684 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
685 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
686 /// not on that page: a page here is where the search for the entry starts rather than
687 /// where it ends.
688 ///
689 /// It does **not** select the board's `Labels` field value, and that is the whole of
690 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
691 /// a label connection there sits under `fieldValues` under `projectItems` under a page
692 /// of issues, spending `$nestedFirst` twice down one path, and took
693 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
694 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
695 /// above, and that connection is where every label this source reports comes from. No
696 /// document in this module selects the board field any longer, [`BOARD`] included; the
697 /// module documentation records why nothing it could have held is lost.
698 macro_rules! board_issue {
699 () => {
700 concat!(
701 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
702 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
703 projectItems(first:$boardItems){nodes{id project{id number}
704 "#,
705 board_item_values!(),
706 r#"}pageInfo{hasNextPage endCursor}}}"#
707 )
708 };
709 }
710
711 /// Every issue of one board, found by a search scoped to that board.
712 ///
713 /// This is how the projects a board holds are listed, and it selects no `items`
714 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
715 /// container walked page by page, so nothing nested inside a board item is paid for.
716 /// Which of the issues it returns is a project is then read off `parent` — GitHub
717 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
718 /// discriminator has to be applied to the field, which is a scalar on the issue and
719 /// costs nothing.
720 pub const SEARCH_ISSUES: &str = concat!(
721 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
722 search(query:$search,type:$type,first:$first,after:$after){
723 pageInfo{hasNextPage endCursor}
724 nodes{__typename ...BoardIssue}
725 }
726 }"#,
727 board_issue!()
728 );
729
730 /// One issue by its own node id, which is what a qualified id names here.
731 ///
732 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
733 /// answers a write made moments ago with the value from before it, and resolving a node
734 /// id does not.
735 pub const ISSUE: &str = concat!(
736 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
737 node(id:$id){__typename ...BoardIssue}
738 }"#,
739 board_issue!()
740 );
741
742 /// One project's tasks: the sub-issues of the issue that project is.
743 ///
744 /// The work this costs is the project's own size. Nothing about it grows as the board
745 /// gains projects, or as those projects gain tasks.
746 pub const SUB_ISSUES: &str = concat!(
747 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
748 node(id:$id){__typename
749 ... on Issue{subIssues(first:$first,after:$after){
750 pageInfo{hasNextPage endCursor}
751 nodes{__typename ...BoardIssue}
752 }}}
753 }"#,
754 board_issue!()
755 );
756
757 /// What a read of the board's own `items` selects of each item's content.
758 ///
759 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
760 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
761 /// content by one spelling.
762 macro_rules! board_item_content {
763 () => {
764 r#" content{
765 ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
766 ... on PullRequest{__typename id}
767 ... on DraftIssue{__typename id title body createdAt updatedAt}
768 }"#
769 };
770 }
771
772 /// Reads the board's fields and one page of its items.
773 pub const BOARD: &str = concat!(
774 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
775 owner:repositoryOwner(login:$owner){
776 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
777 }
778 } fragment Board on ProjectV2 { id title
779 fields(first:$nestedFirst){nodes{
780 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
781 ... on ProjectV2Field{__typename id name}
782 }pageInfo{hasNextPage}}
783 items(first:$first,after:$after){nodes{id "#,
784 board_item_values!(),
785 board_item_content!(),
786 r#"} pageInfo{hasNextPage endCursor}}
787 }"#
788 );
789
790 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
791 /// the board.
792 ///
793 /// **`originItems`** is the board's own items narrowed by its own field filter —
794 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
795 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
796 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
797 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
798 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
799 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
800 /// fresh `addProjectV2ItemById` the way that connection does.
801 ///
802 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
803 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
804 /// indexes that comment, and the index catches up with a write in a second or two rather
805 /// than in minutes, so it finds a carrier another process wrote that the first read is
806 /// still behind on.
807 ///
808 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
809 /// — and resumes from its own cursor; a connection already walked to its end is resumed
810 /// from its last cursor, which answers an empty page. Every candidate either read returns
811 /// is confirmed against its own origin field before it is reported, so a token match of
812 /// the search or anything else the filter admits never is.
813 ///
814 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
815 /// own whole reads counts this one among them.
816 pub const ORIGIN_LOOKUP: &str = concat!(
817 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
818 originItems:repositoryOwner(login:$owner){
819 ... on ProjectV2Owner{projectV2(number:$number){
820 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
821 board_item_values!(),
822 board_item_content!(),
823 r#"} pageInfo{hasNextPage endCursor}}
824 }}
825 }
826 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
827 pageInfo{hasNextPage endCursor}
828 nodes{__typename ...BoardIssue}
829 }
830 }"#,
831 board_issue!()
832 );
833
834 /// The board's own id and field definitions, and not one of its items.
835 ///
836 /// What a write needs of the board when the item it writes does not say: the id a field
837 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
838 /// origin fields. It selects no `items`, so what it costs is the board's field list
839 /// however many items the board holds — and it decides nothing about which items those
840 /// are, which is the question a read of one item by its own id answers instead.
841 ///
842 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
843 /// board's item reads by their root counts this one among them.
844 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
845 boardFields:repositoryOwner(login:$owner){
846 ... on ProjectV2Owner{projectV2(number:$number){id
847 fields(first:$nestedFirst){nodes{
848 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
849 ... on ProjectV2Field{__typename id name}
850 }pageInfo{hasNextPage}}
851 }}
852 }
853 }"#;
854
855 /// One board draft by its own node id, with the board item it sits in.
856 ///
857 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
858 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
859 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
860 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
861 /// board listing hands it to, and nothing has to list the board to find one.
862 pub const DRAFT: &str = concat!(
863 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
864 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
865 projectV2Items(first:$boardItems){nodes{id project{id number}
866 "#,
867 board_item_values!(),
868 r#"}pageInfo{hasNextPage endCursor}}}}
869 }"#
870 );
871
872 /// One issue's board memberships alone, walked past the page a read of it carried.
873 ///
874 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
875 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
876 /// boards than that page holds may have this board's entry past its end. This asks that
877 /// one issue for its memberships and nothing else — the caller already holds the issue —
878 /// so an answer of "this board does not hold it" is only ever given about a connection
879 /// read to exhaustion.
880 ///
881 /// It selects the board item's id, its project number and the same
882 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
883 /// very same resolver: an issue recovered this way reports the same title, the same
884 /// status, the same labels and the same qualified id as one whose entry was on the
885 /// page.
886 ///
887 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
888 /// multiplies through it and the membership connection can be walked at
889 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
890 /// further request for any issue a person really keeps.
891 pub const ISSUE_BOARD_ITEMS: &str = concat!(
892 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
893 node(id:$id){
894 ... on Issue{projectItems(first:$first,after:$after){
895 nodes{id project{id number}
896 "#,
897 board_item_values!(),
898 r#"}
899 pageInfo{hasNextPage endCursor}}}
900 }
901 }"#
902 );
903 /// Resolves the configured repository's node id, which creating an issue requires.
904 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
905 /// Reads both dependency directions for one issue, with each far end's own kind — and
906 /// the issue's own body, which is where an edge to another source is recorded, so that
907 /// half of a dependency read needs no second read of the issue or of the board.
908 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
909 ... on Issue{body
910 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
911 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
912 }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
913 /// Creates one issue in the configured repository.
914 pub const CREATE_ISSUE: &str =
915 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
916 /// Puts an existing issue on the configured board.
917 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
918 /// Updates an issue's visible fields and its open or closed state in one call.
919 pub const UPDATE_ISSUE: &str =
920 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
921 /// Updates an existing draft's user-visible fields.
922 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
923 /// Updates a text or single-select value on one project item.
924 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
925 /// Clears one project item's value of one field, which is what a `none` priority is.
926 pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
927 /// Creates one single-select field with its options. Only the guarded field setup may use
928 /// this document, and only for a field the board lacks.
929 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
930 /// Replaces a single-select field's options. Only the guarded field setup — the
931 /// `status-options` and `fields` operations — may use this document, because GitHub
932 /// treats the input as the complete option list.
933 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
934 /// A fresh snapshot of the Status field and every board item's assignment.
935 pub const STATUS_OPTIONS_SNAPSHOT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){owner:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id fields(first:$nestedFirst){nodes{... on ProjectV2SingleSelectField{id name options{id name color description}}}pageInfo{hasNextPage}} items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{... on ProjectV2ItemFieldSingleSelectValue{name optionId field{... on ProjectV2SingleSelectField{id name}}}}pageInfo{hasNextPage}}}pageInfo{hasNextPage endCursor}}}}}}"#;
936 /// Files one issue under another as a sub-issue, which is what project membership is.
937 pub const ADD_SUB_ISSUE: &str =
938 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
939 /// Takes one issue back out of its parent.
940 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
941 /// Adds GitHub's native issue blocked-by relationship.
942 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
943 /// Removes one native issue blocked-by relationship.
944 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
945 /// Deletes one issue, which takes its board item with it.
946 ///
947 /// The engine sends this in one situation only: undoing a copy that could not finish,
948 /// over the items that same copy created. Deleting the issue removes the board item
949 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
950 pub const DELETE_ISSUE: &str =
951 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
952
953 /// Everything this source reads about one issue comment, wherever it reaches one.
954 ///
955 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
956 /// and a comment just edited are handed to one mapper, so they are selected by one
957 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
958 /// longer exists, and `login` is the one member every kind of actor carries.
959 macro_rules! issue_comment {
960 () => {
961 "id author{login} createdAt updatedAt body url"
962 };
963 }
964
965 /// One task's comments: a page of its issue's own `comments` connection.
966 ///
967 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
968 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
969 /// list every time somebody edited it; left unordered the connection answers in the order
970 /// the comments were written, which is the order GitHub documents for the same collection
971 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
972 /// node count and the caller's own page size is pushed straight down.
973 pub const ISSUE_COMMENTS: &str = concat!(
974 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
975 issue_comment!(),
976 r#"}pageInfo{hasNextPage endCursor}}}}}"#
977 );
978 /// Which issue one comment is on, read before that comment is edited or removed.
979 ///
980 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
981 /// comment id given against the wrong task would change a comment on another issue.
982 pub const COMMENT_ISSUE: &str =
983 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
984 /// Adds one comment to an issue, signed as the account the token belongs to.
985 pub const ADD_COMMENT: &str = concat!(
986 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
987 issue_comment!(),
988 r#"}}}}"#
989 );
990 /// Replaces the body of one issue comment.
991 pub const UPDATE_COMMENT: &str = concat!(
992 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
993 issue_comment!(),
994 r#"}}}"#
995 );
996 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
997 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
998
999 /// Every document above, with what this source is doing when it sends one.
1000 ///
1001 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1002 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1003 /// document added later with "talking to GitHub" and never say so.
1004 ///
1005 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1006 /// const` here that this list omits, so the two cannot part — which is the same guard
1007 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1008 pub const DOCUMENTS: [(&str, &str); 29] = [
1009 (SEARCH_ISSUES, "searching this board's issues"),
1010 (ISSUE, "reading one issue"),
1011 (
1012 ISSUE_BOARD_ITEMS,
1013 "reading one issue's board memberships past the page it came with",
1014 ),
1015 (SUB_ISSUES, "reading a project's tasks"),
1016 (BOARD, "reading the board"),
1017 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1018 (BOARD_FIELDS, "reading the board's fields"),
1019 (DRAFT, "reading one draft"),
1020 (REPOSITORY, "reading the destination repository"),
1021 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1022 (CREATE_ISSUE, "creating an issue"),
1023 (ADD_TO_BOARD, "adding an issue to the board"),
1024 (UPDATE_ISSUE, "updating an issue"),
1025 (UPDATE_DRAFT, "updating a draft item"),
1026 (UPDATE_FIELD, "writing a board field"),
1027 (CLEAR_FIELD, "clearing a board field"),
1028 (
1029 CREATE_FIELD,
1030 "creating a board single-select field with its options",
1031 ),
1032 (
1033 STATUS_OPTIONS_SNAPSHOT,
1034 "snapshotting board Status options and assignments",
1035 ),
1036 (
1037 STATUS_OPTIONS_UPDATE,
1038 "safely replacing the board Status option list",
1039 ),
1040 (ADD_SUB_ISSUE, "filing an issue under its project"),
1041 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1042 (ADD_BLOCKED_BY, "recording a dependency"),
1043 (REMOVE_BLOCKED_BY, "removing a dependency"),
1044 (DELETE_ISSUE, "deleting an issue"),
1045 (ISSUE_COMMENTS, "reading a task's comments"),
1046 (COMMENT_ISSUE, "reading which issue a comment is on"),
1047 (ADD_COMMENT, "adding a comment"),
1048 (UPDATE_COMMENT, "editing a comment"),
1049 (DELETE_COMMENT, "deleting a comment"),
1050 ];
1051}
1052
1053/// Which of GitHub's two rate limiters refused a request.
1054///
1055/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1056/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1057/// the whole reason this is carried rather than collapsed into "rate limited".
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059enum Limiter {
1060 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1061 Primary,
1062 /// The burst limiter over content-generating requests, which nothing reports.
1063 Secondary,
1064}
1065
1066/// The wordings GitHub answers a secondary rate limit with.
1067///
1068/// It sends them under a forbidden status, under a too-many-requests status, and inside
1069/// the `errors` of a *successful* response, which is why the text is what this matches on
1070/// rather than the status. `abuse detection` is the wording GitHub used before the
1071/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1072/// what a burst of content creation is refused with.
1073///
1074/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1075/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1076/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1077/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1078pub const SECONDARY_WORDINGS: [&str; 5] = [
1079 "secondary rate limit",
1080 "temporarily blocked from content creation",
1081 "abuse detection",
1082 "submitted too quickly",
1083 "exceeded a secondary",
1084];
1085
1086/// The wordings GitHub answers an exhausted primary budget with.
1087///
1088/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1089/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1090/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1091/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1092/// two phrases is a substring of it, so without it that answer read as a refusal that will
1093/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1094/// one reason.
1095pub const PRIMARY_WORDINGS: [&str; 4] = [
1096 "api rate limit exceeded",
1097 "api rate limit already exceeded",
1098 "rate limit exceeded",
1099 "rate_limited",
1100];
1101
1102/// What a response *says about itself*, which is the only place a refusal can be read.
1103///
1104/// Deliberately not the whole response body. A board is a place people write about their
1105/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1106/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1107/// reported. So the item data is never read: what is read is GitHub's own REST-style
1108/// `message` envelope, which is what a forbidden status carries, and the `message` and
1109/// `type` of each GraphQL error, which is where a *successful* response says it.
1110///
1111/// A body that is not JSON at all has nothing structured to read, so only a failing
1112/// response's own text is taken — a successful response that is not JSON is malformed
1113/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1114fn refusal_wording(status: StatusCode, body: &str) -> String {
1115 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1116 return if status.is_success() {
1117 String::new()
1118 } else {
1119 body.to_owned()
1120 };
1121 };
1122 let mut said: Vec<&str> = parsed
1123 .get("message")
1124 .and_then(Value::as_str)
1125 .into_iter()
1126 .collect();
1127 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1128 for error in errors {
1129 said.extend(
1130 ["message", "type"]
1131 .into_iter()
1132 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1133 );
1134 }
1135 }
1136 said.join("; ")
1137}
1138
1139impl Limiter {
1140 /// Which limiter refused this response, or `None` when none of them did.
1141 ///
1142 /// The wording is read first and the status only decides what carries none of it,
1143 /// because GitHub answers a secondary limit with a forbidden status far more often
1144 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1145 /// really is a credential this token lacks.
1146 ///
1147 /// A response is a refusal because of its status or its own wording. A spent budget
1148 /// only ever explains one; it never turns an answer into a refusal.
1149 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1150 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1151 if SECONDARY_WORDINGS
1152 .iter()
1153 .any(|wording| normalized.contains(wording))
1154 {
1155 return Some(Self::Secondary);
1156 }
1157 if status == StatusCode::TOO_MANY_REQUESTS {
1158 return Some(Self::Primary);
1159 }
1160 // An exhausted budget *explains* a response that failed; it does not make one that
1161 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1162 // request the budget allowed as well as on the ones it then refuses, so reading
1163 // the header alone threw away a good answer — and, once refusals were retried,
1164 // replayed a request that had already taken effect.
1165 if !status.is_success() && budget_exhausted {
1166 return Some(Self::Primary);
1167 }
1168 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1169 // `errors` of an HTTP 200, where nothing about the status says so at all.
1170 if status.is_success()
1171 && PRIMARY_WORDINGS
1172 .iter()
1173 .any(|wording| normalized.contains(wording))
1174 {
1175 return Some(Self::Primary);
1176 }
1177 None
1178 }
1179
1180 /// What this limiter is called where an operator can look it up.
1181 const fn name(self) -> &'static str {
1182 match self {
1183 Self::Primary => "GitHub's primary API rate limit",
1184 Self::Secondary => "GitHub's secondary rate limit",
1185 }
1186 }
1187
1188 /// What the endpoint an operator would go and check says about this limiter.
1189 const fn where_to_look(self) -> &'static str {
1190 match self {
1191 Self::Primary => {
1192 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1193 comes back."
1194 }
1195 Self::Secondary => {
1196 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1197 primary budget and does not report this one, so budget showing there says \
1198 nothing about this refusal, and every further attempt extends it."
1199 }
1200 }
1201 }
1202
1203 /// The next step this limiter actually calls for.
1204 const fn what_to_do(self) -> &'static str {
1205 match self {
1206 Self::Primary => {
1207 "wait for the reset `gh api rate_limit` reports, then run the command again."
1208 }
1209 Self::Secondary => {
1210 "leave this board alone for a few minutes, then run the command again — or \
1211 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1212 }
1213 }
1214 }
1215}
1216
1217/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1218#[derive(Debug, Clone, Copy)]
1219struct Limited {
1220 limiter: Limiter,
1221 hint: Option<u64>,
1222}
1223
1224impl Limited {
1225 /// What the caller is told once this source has waited as long as it may.
1226 ///
1227 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1228 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1229 /// about *which* limiter it was makes it a different kind of failure. What differs is
1230 /// the operator's next step, and that is what the message carries — a secondary
1231 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1232 /// budget looks fine, and then back to retry the very burst that was refused.
1233 fn exhausted(
1234 self,
1235 doing: &str,
1236 waits: u32,
1237 waited: Duration,
1238 needed: Duration,
1239 budget: Duration,
1240 ) -> SourceError {
1241 SourceError::RateLimited {
1242 retry_after_seconds: self.hint,
1243 message: Some(format!(
1244 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1245 again, and the next wait of {} would take it past the {} one call may spend \
1246 waiting. {} next: {}",
1247 self.limiter.name(),
1248 plural(waits, "refusal"),
1249 seconds(waited),
1250 seconds(needed),
1251 seconds(budget),
1252 self.limiter.where_to_look(),
1253 self.limiter.what_to_do(),
1254 )),
1255 }
1256 }
1257}
1258
1259/// One HTTP attempt's result, with what its response said about the rate limit.
1260///
1261/// The two travel together so the record and the outcome are written from the same place:
1262/// what a response said about the budget is only readable while that response is in hand,
1263/// and what the attempt *meant* is only decidable once its body has been read.
1264struct Attempted {
1265 result: Result<Value, Attempt>,
1266 limits: accounting::RateLimit,
1267 /// GitHub's own reported cost for this call, for a document that asked for it.
1268 reported_cost: Option<u64>,
1269}
1270
1271/// One attempt's outcome: an error to report, or a rate limit to wait out.
1272enum Attempt {
1273 Failed(SourceError),
1274 Limited(Limited),
1275}
1276
1277fn plural(count: u32, thing: &str) -> String {
1278 if count == 1 {
1279 format!("{count} {thing}")
1280 } else {
1281 format!("{count} {thing}s")
1282 }
1283}
1284
1285fn seconds(duration: Duration) -> String {
1286 format!("{:.1}s", duration.as_secs_f64())
1287}
1288
1289/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1290///
1291/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1292/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1293/// header, and neither is what makes a response a refusal — so the whole cost of one this
1294/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1295/// instead. Refusing the response over the header would turn a readable refusal into an
1296/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1297fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1298 value
1299 .and_then(|value| value.to_str().ok())
1300 .and_then(|value| value.trim().parse::<u64>().ok())
1301}
1302
1303/// Every mutation this source sends creates content — an issue, a board item, a field of
1304/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1305/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1306/// and what the keyword says are the same set. That is what makes the keyword a sound test
1307/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1308/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1309fn is_mutation(query: &str) -> bool {
1310 query.trim_start().starts_with("mutation")
1311}
1312
1313/// What this source was doing, for a diagnostic that has to say so.
1314///
1315/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1316/// a document added without a description is caught by that list's own gate instead of
1317/// falling through to the vague arm below.
1318fn operation_description(query: &str) -> &'static str {
1319 graphql::DOCUMENTS
1320 .iter()
1321 .find(|(document, _)| *document == query)
1322 .map_or("talking to GitHub", |(_, doing)| *doing)
1323}
1324
1325/// GitHub's published ceiling on content-generating requests, per minute.
1326///
1327/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1328/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1329/// from it, so a pacing value checked only against itself cannot go stale here.
1330pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1331/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1332/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1333/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1334pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1335/// Shortest interval between two content-creating mutations, in milliseconds.
1336///
1337/// GitHub documents two secondary limits on content-generating requests:
1338/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1339/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1340/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1341/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1342/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1343/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1344/// deliberately *not* what this paces at. An installation that wants the hourly bound
1345/// honoured for a long sequence of copies says so through
1346/// `pacing.min_mutation_interval_ms`.
1347pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1348/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1349///
1350/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1351/// own advice for a secondary limit — wait, and wait longer each time — without spending
1352/// the first minute of a transient refusal doing nothing.
1353pub const RETRY_BACKOFF_MS: u64 = 1_000;
1354/// Total time one call may spend waiting out rate limits before it reports a failure.
1355///
1356/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1357/// short enough that a command an operator is watching returns. The bound is what makes
1358/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1359/// the limiter, not in a process nobody can tell from a wedged one.
1360pub const RETRY_BUDGET_MS: u64 = 120_000;
1361
1362fn default_token_env() -> String {
1363 "GH_PROJECTS_TOKEN".to_owned()
1364}
1365fn default_endpoint() -> String {
1366 "https://api.github.com/graphql".to_owned()
1367}
1368
1369/// Where one status category lands on this board.
1370///
1371/// `null` — an absent value — disables the category for this instance, and using a
1372/// disabled status is a refusal naming the status and the instance.
1373#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1374#[serde(untagged)]
1375pub enum StatusTargetConfig {
1376 /// The name of a `Status` single-select option already on the board.
1377 Column(ColumnName),
1378}
1379
1380/// The name of a `Status` single-select option on the board.
1381///
1382/// Validated on the way in rather than checked later, so a blank option name — which
1383/// nothing on a board can be — is a state this type cannot hold.
1384#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1385#[serde(try_from = "String")]
1386#[schemars(extend("minLength" = 1))]
1387pub struct ColumnName(String);
1388
1389impl ColumnName {
1390 /// The option name, as the board spells it.
1391 fn as_str(&self) -> &str {
1392 &self.0
1393 }
1394}
1395
1396impl TryFrom<String> for ColumnName {
1397 type Error = String;
1398
1399 fn try_from(name: String) -> Result<Self, Self::Error> {
1400 if name.trim().is_empty() {
1401 return Err("a status_mapping option name cannot be blank".to_owned());
1402 }
1403 Ok(Self(name))
1404 }
1405}
1406
1407/// The two closed states this product can mean.
1408///
1409/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1410/// work nor abandoned work, so nothing here ever writes it.
1411#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1412#[serde(rename_all = "kebab-case")]
1413pub enum ClosedState {
1414 /// `COMPLETED` — precisely done.
1415 Completed,
1416 /// `NOT_PLANNED` — precisely cancelled.
1417 NotPlanned,
1418}
1419
1420impl ClosedState {
1421 const fn reason(self) -> &'static str {
1422 match self {
1423 Self::Completed => "COMPLETED",
1424 Self::NotPlanned => "NOT_PLANNED",
1425 }
1426 }
1427}
1428
1429/// Configuration for one GitHub Projects v2 board.
1430#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1431#[serde(default, deny_unknown_fields)]
1432pub struct GitHubProjectsConfig {
1433 /// Login of the user or organization which owns the board.
1434 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1435 /// The project number shown in the board's GitHub URL.
1436 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1437 // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This doc is the field's schema description, which is what a person configuring the source reads, so it has to say when the field decides an issue's repository and when the item's own field does; the rule's one executable source is `GitHubProjectsSource::creation_target`, and `tests/plugin.rs` drives each case named here against the loopback board.
1438 /// `owner/name` of the repository this source creates an issue in when the item's own
1439 /// `repositories` field does not decide it.
1440 ///
1441 /// An item naming exactly one repository is created there; a task or a document naming
1442 /// none or several is created in its parent project's repository; and a project, or a
1443 /// task or document with no parent, naming none or several is created here. A board
1444 /// has no repository of its own and `createIssue` requires one, so a write without
1445 /// this is refused naming the field. Reads never need it.
1446 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1447 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1448 /// Environment variable containing a fine-grained token with Projects and Issues
1449 /// read/write plus Pull requests read-only access for every repository represented on
1450 /// the board.
1451 #[serde(default = "default_token_env")]
1452 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1453 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1454 #[serde(default = "default_endpoint")]
1455 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1456 /// Per-instance mapping from a status category to where it lands on this board.
1457 ///
1458 /// A category this does not mention keeps its shipped default: `backlog` to
1459 /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1460 /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1461 /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1462 /// board option; every unknown word then lands on that option and reads back as
1463 /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1464 /// word because it never creates board options.
1465 #[serde(default)]
1466 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` parses each key into a `StatusCategory` and reports an unknown one against this instance.
1467 /// Per-instance mapping from a task's priority to an option of this board's
1468 /// single-select field named `Priority`.
1469 ///
1470 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1471 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1472 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1473 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1474 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1475 /// no two levels may name one option. Reads and writes never create the field or an
1476 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1477 /// the board lacks is refused pointing there.
1478 #[serde(default)]
1479 pub priority_mapping: Option<PriorityMappingConfig>,
1480 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1481 ///
1482 /// Every field keeps its shipped default when it is absent, and the defaults are
1483 /// GitHub's own published limits rather than taste. See [`Pacing`].
1484 #[serde(default)]
1485 pub pacing: PacingConfig,
1486}
1487
1488/// Which option of the board's `Priority` field each priority lands on.
1489///
1490/// One member per level rather than a map, so a key that is not a level is refused where
1491/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1492/// value in the field, not an option of it.
1493#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1494#[serde(default, deny_unknown_fields)]
1495pub struct PriorityMappingConfig {
1496 /// The option `urgent` lands on; `Urgent` when absent.
1497 pub urgent: Option<PriorityOptionName>,
1498 /// The option `high` lands on; `High` when absent.
1499 pub high: Option<PriorityOptionName>,
1500 /// The option `medium` lands on; `Medium` when absent.
1501 pub medium: Option<PriorityOptionName>,
1502 /// The option `low` lands on; `Low` when absent.
1503 pub low: Option<PriorityOptionName>,
1504}
1505
1506/// The name of an option of the board's `Priority` single-select field.
1507///
1508/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1509/// blank name.
1510#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1511#[serde(try_from = "String")]
1512#[schemars(extend("minLength" = 1))]
1513pub struct PriorityOptionName(String);
1514
1515impl PriorityOptionName {
1516 /// The option name, as the board spells it.
1517 fn as_str(&self) -> &str {
1518 &self.0
1519 }
1520}
1521
1522impl TryFrom<String> for PriorityOptionName {
1523 type Error = String;
1524
1525 fn try_from(name: String) -> Result<Self, Self::Error> {
1526 if name.trim().is_empty() {
1527 return Err("a priority_mapping option name cannot be blank".to_owned());
1528 }
1529 Ok(Self(name))
1530 }
1531}
1532
1533/// The name of the board field a priority is held in.
1534pub const PRIORITY_FIELD: &str = "Priority";
1535
1536/// The four priorities a board option can hold, in the order a new `Priority` field lists
1537/// them. `none` is not among them: it is the field holding no value.
1538///
1539/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1540/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1541/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1542/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1543pub const PRIORITY_LEVELS: [Priority; 4] = [
1544 Priority::Urgent,
1545 Priority::High,
1546 Priority::Medium,
1547 Priority::Low,
1548];
1549
1550/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1551/// see that list for what this pins.
1552#[must_use]
1553pub const fn level_position(priority: Priority) -> Option<usize> {
1554 match priority {
1555 Priority::None => None,
1556 Priority::Urgent => Some(0),
1557 Priority::High => Some(1),
1558 Priority::Medium => Some(2),
1559 Priority::Low => Some(3),
1560 }
1561}
1562
1563/// This instance's complete priority-to-option mapping, read in both directions.
1564///
1565/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1566/// two levels name one option.
1567#[derive(Debug, Clone)]
1568struct PriorityMapping {
1569 options: [PriorityOptionName; 4],
1570}
1571
1572impl PriorityMapping {
1573 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1574 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1575 let mapping = Self {
1576 options: [
1577 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1578 config.high.unwrap_or_else(|| shipped("High")),
1579 config.medium.unwrap_or_else(|| shipped("Medium")),
1580 config.low.unwrap_or_else(|| shipped("Low")),
1581 ],
1582 };
1583 for (index, option) in mapping.options.iter().enumerate() {
1584 if let Some(earlier) = mapping.options[..index]
1585 .iter()
1586 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1587 {
1588 return Err(SourceError::Config {
1589 message: format!(
1590 "priority_mapping of source {instance} sends both {} and {} to the board \
1591 option {:?}; one option cannot read back as two priorities",
1592 PRIORITY_LEVELS[earlier],
1593 PRIORITY_LEVELS[index],
1594 option.as_str()
1595 ),
1596 });
1597 }
1598 }
1599 Ok(mapping)
1600 }
1601
1602 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1603 fn option(&self, priority: Priority) -> Option<&str> {
1604 level_position(priority).map(|index| self.options[index].as_str())
1605 }
1606
1607 /// The priority a board option name reports, or `None` when nothing maps to it.
1608 fn priority_of(&self, option: &str) -> Option<Priority> {
1609 self.options
1610 .iter()
1611 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1612 .map(|index| PRIORITY_LEVELS[index])
1613 }
1614
1615 /// Every mapped option name, in the order a new `Priority` field lists them.
1616 fn names(&self) -> impl Iterator<Item = &str> {
1617 self.options.iter().map(PriorityOptionName::as_str)
1618 }
1619}
1620
1621/// What one item's `Priority` field says, read through this instance's mapping.
1622#[derive(Debug, Clone, PartialEq, Eq)]
1623enum HeldPriority {
1624 /// A priority this source reports: an option the mapping names, or no value (`none`).
1625 Read(Priority),
1626 /// An option the mapping does not name, which is never read as a level or as `none`.
1627 Unmapped(String),
1628}
1629
1630/// How fast this source writes, and how long it waits out a rate-limit refusal.
1631///
1632/// Configurable because a GitHub Enterprise installation sets its own limits and an
1633/// operator who has already been refused may want to go slower still — not because the
1634/// defaults are guesses.
1635#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1636#[serde(default, deny_unknown_fields)]
1637pub struct PacingConfig {
1638 /// Shortest interval between two content-creating mutations, in milliseconds.
1639 ///
1640 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1641 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1642 pub min_mutation_interval_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
1643 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1644 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1645 /// zero while there is a budget to spend, because a schedule of zero-length waits
1646 /// consumes none of it and so never ends.
1647 pub retry_backoff_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` refuses a non-progressing zero and bounds the rest before the private validated `Pacing` is built.
1648 /// Total time one call may spend waiting out rate limits, in milliseconds.
1649 ///
1650 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1651 /// the bound is what makes this a wait rather than a hang.
1652 pub retry_budget_ms: Option<u64>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `Pacing::resolve` bounds it to `MAX_PACING_MS` before the private validated `Pacing` is built.
1653}
1654
1655/// The largest any pacing setting may be, in milliseconds.
1656///
1657/// One hour. GitHub's own harshest published bound on content-generating requests works
1658/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1659/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1660/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1661/// and an interval beyond it is a command that never sends its second mutation. It also
1662/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1663/// what an `Instant` can hold on every platform.
1664pub const MAX_PACING_MS: u64 = 3_600_000;
1665
1666/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1667/// source holds.
1668#[derive(Debug, Clone, Copy)]
1669struct Pacing {
1670 min_mutation_interval: Duration,
1671 retry_backoff: Duration,
1672 retry_budget: Duration,
1673}
1674
1675impl Pacing {
1676 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1677 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1678 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1679 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1680 message: format!(
1681 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1682 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1683 GitHub's own harshest published limit"
1684 ),
1685 }),
1686 Some(value) => Ok(Duration::from_millis(value)),
1687 None => Ok(Duration::from_millis(default)),
1688 };
1689 let retry_backoff = bounded(
1690 config.retry_backoff_ms,
1691 RETRY_BACKOFF_MS,
1692 "retry_backoff_ms",
1693 )?;
1694 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1695 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1696 return Err(SourceError::Config {
1697 message: format!(
1698 "pacing.retry_backoff_ms of source {instance} is 0 while \
1699 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1700 none of that budget, so it would retry a refusal forever. Set a backoff of \
1701 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1702 waiting at all",
1703 retry_budget.as_millis()
1704 ),
1705 });
1706 }
1707 Ok(Self {
1708 min_mutation_interval: bounded(
1709 config.min_mutation_interval_ms,
1710 MIN_MUTATION_INTERVAL_MS,
1711 "min_mutation_interval_ms",
1712 )?,
1713 retry_backoff,
1714 retry_budget,
1715 })
1716 }
1717}
1718
1719/// Factory for [`GitHubProjectsSource`].
1720#[derive(Debug, Clone, Copy, Default)]
1721pub struct Plugin;
1722
1723impl SourcePlugin for Plugin {
1724 fn kind(&self) -> &'static str {
1725 KIND
1726 }
1727 fn config_schema(&self) -> Schema {
1728 schema_for!(GitHubProjectsConfig)
1729 }
1730 fn build(
1731 &self,
1732 name: &SourceName,
1733 config: &Value,
1734 secrets: &dyn SecretResolver,
1735 ) -> Result<Box<dyn TaskSource>, SourceError> {
1736 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1737 }
1738}
1739
1740impl Plugin {
1741 /// Build a source recording every request it sends into an accounting the caller holds.
1742 ///
1743 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
1744 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
1745 /// session total rather than two — see [`accounting`] and
1746 /// [`GitHubProjectsSource::recording_into`].
1747 ///
1748 /// # Errors
1749 ///
1750 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
1751 /// [`SourceError::Config`] for configuration this plugin cannot use and
1752 /// [`SourceError::Auth`] for a credential it cannot find.
1753 pub fn build_recording_into(
1754 &self,
1755 name: &SourceName,
1756 config: &Value,
1757 secrets: &dyn SecretResolver,
1758 ledger: Arc<Accounting>,
1759 ) -> Result<Box<dyn TaskSource>, SourceError> {
1760 let config: GitHubProjectsConfig =
1761 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1762 message: format!("source {name}: {e}"),
1763 })?;
1764 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
1765 |error| match error {
1766 SourceError::Config { message } => SourceError::Config {
1767 message: format!("source {name}: {message}"),
1768 },
1769 SourceError::Auth { message } => SourceError::Auth {
1770 message: format!("source {name}: {message}"),
1771 },
1772 other => other,
1773 },
1774 )?;
1775 Ok(Box::new(source))
1776 }
1777}
1778
1779/// Where a status category lands on this board, once configuration is resolved.
1780#[derive(Debug, Clone, PartialEq, Eq)]
1781enum StatusTarget {
1782 /// Not usable against this instance.
1783 Disabled,
1784 /// The board's `Status` option of this name.
1785 Column(ColumnName),
1786 /// A closed issue, with both its board option and the reason that says which closed it means.
1787 // llmlint: ignore[invalid_states_unrepresentable] The reason is fixed by the category — `done` closes as completed, `cancelled` as not planned — and this private enum is built in one place, `StatusMapping::new`, which pairs each from the category's own slot. Carrying the reason on the target is what lets every write site that holds only a target derive its `stateInput` from that one resolved model rather than re-deriving it from a category and risking a disagreement with the mapping.
1788 Terminal(ColumnName, ClosedState),
1789}
1790
1791/// Every status category, in the order the vocabulary declares them.
1792///
1793/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
1794/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
1795/// added to the shared vocabulary fails to compile until it is named there, and this
1796/// crate's suite reconciles this list against that enum's own derived schema, which is
1797/// generated from the variants rather than written beside them. The schema is what
1798/// catches a list left one short — a list checking only the positions it already holds
1799/// would pass while every mapping indexed by the new position panicked.
1800pub const CATEGORIES: [StatusCategory; 8] = [
1801 StatusCategory::Draft,
1802 StatusCategory::Backlog,
1803 StatusCategory::Todo,
1804 StatusCategory::Queued,
1805 StatusCategory::InProgress,
1806 StatusCategory::Done,
1807 StatusCategory::Cancelled,
1808 StatusCategory::Unknown,
1809];
1810
1811/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
1812#[must_use]
1813pub const fn category_position(category: StatusCategory) -> usize {
1814 match category {
1815 StatusCategory::Draft => 0,
1816 StatusCategory::Backlog => 1,
1817 StatusCategory::Todo => 2,
1818 StatusCategory::Queued => 3,
1819 StatusCategory::InProgress => 4,
1820 StatusCategory::Done => 5,
1821 StatusCategory::Cancelled => 6,
1822 StatusCategory::Unknown => 7,
1823 }
1824}
1825
1826/// The spelling a status category is configured and reported under.
1827fn category_name(category: StatusCategory) -> &'static str {
1828 match category {
1829 StatusCategory::Draft => "draft",
1830 StatusCategory::Backlog => "backlog",
1831 StatusCategory::Todo => "todo",
1832 StatusCategory::Queued => "queued",
1833 StatusCategory::InProgress => "in-progress",
1834 StatusCategory::Done => "done",
1835 StatusCategory::Cancelled => "cancelled",
1836 StatusCategory::Unknown => "unknown",
1837 }
1838}
1839
1840/// A shipped default's option name.
1841///
1842/// The literals below are this file's own and non-blank, and they are validated by the
1843/// one constructor a configured name goes through rather than beside it.
1844fn shipped_column(name: &'static str) -> ColumnName {
1845 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
1846}
1847
1848/// The shipped default for one category, before this instance's configuration.
1849fn shipped_default(category: StatusCategory) -> StatusTarget {
1850 match category {
1851 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
1852 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
1853 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
1854 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
1855 StatusCategory::Done => {
1856 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
1857 }
1858 StatusCategory::Cancelled => {
1859 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
1860 }
1861 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
1862 }
1863}
1864
1865/// This instance's complete category-to-target mapping, read in both directions.
1866///
1867/// One target per category, held at that category's own [`category_position`], so a
1868/// category missing from the mapping, named twice in it, or filed out of order is a
1869/// state this type cannot hold rather than one [`Self::target`] has to defend against.
1870#[derive(Debug, Clone)]
1871struct StatusMapping {
1872 targets: [StatusTarget; CATEGORIES.len()],
1873}
1874
1875impl StatusMapping {
1876 fn resolve(
1877 configured: BTreeMap<String, Option<StatusTargetConfig>>,
1878 instance: &SourceName,
1879 ) -> Result<Self, SourceError> {
1880 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
1881 for (key, value) in configured {
1882 let category = CATEGORIES
1883 .iter()
1884 .find(|category| category_name(**category) == key)
1885 .ok_or_else(|| SourceError::Config {
1886 message: format!(
1887 "status_mapping names {key:?}, which is not a status category of source \
1888 {instance}; the categories are {}",
1889 CATEGORIES
1890 .iter()
1891 .map(|category| category_name(*category))
1892 .collect::<Vec<_>>()
1893 .join(", ")
1894 ),
1895 })?;
1896 overrides.insert(category_name(*category), value);
1897 }
1898 // `CATEGORIES[position] == category` for every category — the crate's suite
1899 // asserts it — so mapping the list in order fills each category's own slot.
1900 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
1901 None => shipped_default(category),
1902 Some(None) => StatusTarget::Disabled,
1903 Some(Some(StatusTargetConfig::Column(option))) => match category {
1904 StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
1905 StatusCategory::Cancelled => {
1906 StatusTarget::Terminal(option, ClosedState::NotPlanned)
1907 }
1908 _ => StatusTarget::Column(option),
1909 },
1910 });
1911 let mapping = Self { targets };
1912 for (index, category) in CATEGORIES.into_iter().enumerate() {
1913 let option = match mapping.target(category) {
1914 StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
1915 StatusTarget::Disabled => continue,
1916 };
1917 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
1918 matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1919 if name.as_str().eq_ignore_ascii_case(option.as_str()))
1920 }) {
1921 return Err(SourceError::Config {
1922 message: format!(
1923 "status_mapping of source {instance} sends both {} and {} to the board \
1924 option {:?}; one option cannot read back as two categories",
1925 category_name(*other),
1926 category_name(category),
1927 option.as_str()
1928 ),
1929 });
1930 }
1931 }
1932 Ok(mapping)
1933 }
1934
1935 fn target(&self, category: StatusCategory) -> &StatusTarget {
1936 &self.targets[category_position(category)]
1937 }
1938
1939 /// The category a board option name reports, or `None` when nothing maps to it.
1940 fn category_of(&self, option: &str) -> Option<StatusCategory> {
1941 CATEGORIES.into_iter().find(|category| {
1942 matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1943 if name.as_str().eq_ignore_ascii_case(option))
1944 })
1945 }
1946
1947 /// The status an item reports, from the three things a read of it says: its board
1948 /// `Status` option, whether its issue is closed, and the reason it was closed with.
1949 ///
1950 /// The closed state decides the category and the `Status` option decides the name, so
1951 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
1952 /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
1953 /// duplicate is not finished work, and calling it done is a lie the next copy would
1954 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
1955 /// it is read permissively rather than refused — reads are faithful, and refusals
1956 /// belong on writes.
1957 ///
1958 /// One function of those three rather than of a response, so a narrow status write can
1959 /// answer what a re-read would report by applying it to the state it has just written.
1960 fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
1961 if closed {
1962 let category = match reason {
1963 None | Some("COMPLETED") => StatusCategory::Done,
1964 Some("NOT_PLANNED") => StatusCategory::Cancelled,
1965 Some(_) => StatusCategory::Unknown,
1966 };
1967 let fallback = match category {
1968 StatusCategory::Done => "Done",
1969 StatusCategory::Cancelled => "Cancelled",
1970 _ => "Closed",
1971 };
1972 return Status {
1973 category,
1974 name: option.unwrap_or(fallback).to_owned(),
1975 };
1976 }
1977 let name = option.unwrap_or("Open").to_owned();
1978 Status {
1979 category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
1980 name,
1981 }
1982 }
1983}
1984
1985// llmlint: ignore-block[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate] Every `createIssue` names one of these, and which one is the rule — a reader who reaches the type from `create_and_file_issue` gets the rule in one sentence here without the method's refusals, which stay on `creation_target`, the rule's one executable source; `tests/plugin.rs` drives every arm of it against the loopback board.
1986/// One repository this source can create an issue in, as `owner/name`.
1987///
1988/// Every `createIssue` this source sends names one of these: the item's own single
1989/// `repositories` entry, else its parent project issue's repository, else the configured
1990/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
1991/// that choice and says what it refuses before `createIssue`.
1992// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
1993#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1994struct RepositoryTarget {
1995 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1996 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1997}
1998
1999impl RepositoryTarget {
2000 fn parse(value: &str) -> Result<Self, SourceError> {
2001 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2002 message: format!(
2003 "repository must be spelled owner/name; {value:?} names no repository"
2004 ),
2005 })?;
2006 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2007 return Err(SourceError::Config {
2008 message: format!(
2009 "repository must be spelled owner/name with a GitHub login and one \
2010 repository name; {value:?} is not"
2011 ),
2012 });
2013 }
2014 Ok(Self {
2015 owner: owner.to_owned(),
2016 name: name.to_owned(),
2017 })
2018 }
2019
2020 /// The one host whose repositories this source creates issues in, spelled once: it is
2021 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2022 const HOST: &str = "github.com";
2023
2024 fn origin(&self) -> String {
2025 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2026 }
2027
2028 /// The repository a normalized origin names, or why it is none this source can create
2029 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2030 fn from_origin(origin: &Repository) -> Result<Self, String> {
2031 let not_here = || {
2032 format!(
2033 "{} is not a {}/owner/name repository",
2034 origin.as_str(),
2035 Self::HOST
2036 )
2037 };
2038 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2039 if host != Self::HOST {
2040 return Err(not_here());
2041 }
2042 Self::parse(rest).map_err(|_| not_here())
2043 }
2044
2045 fn slug(&self) -> String {
2046 format!("{}/{}", self.owner, self.name)
2047 }
2048}
2049
2050/// A source which reads GitHub afresh for every operation.
2051pub struct GitHubProjectsSource {
2052 /// This source's configured name, used both to tell a far end naming this source
2053 /// from one naming a system it knows nothing about, and to name the instance a
2054 /// status refusal is about.
2055 name: SourceName,
2056 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2057 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2058 repository: Option<RepositoryTarget>,
2059 endpoint: Url,
2060 token: SecretString,
2061 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2062 statuses: StatusMapping,
2063 /// Where each priority lands on this board, or `None` when this instance holds none.
2064 priorities: Option<PriorityMapping>,
2065 client: Client,
2066 /// Every item this source has created since it was built, in the order it created
2067 /// them.
2068 ///
2069 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2070 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2071 /// a copy resolving a dependency on an item it had just created refused it as not
2072 /// found. A board read is completed from this — an item remembered here and absent from
2073 /// the read is added back, because the board really does hold it and only the read is
2074 /// behind.
2075 ///
2076 /// It is not a cache of a user's work: nothing is remembered that this process did not
2077 /// itself just write, it lives and dies with the process, and it is never consulted for
2078 /// an item this source did not create.
2079 created: Mutex<Vec<Resolved>>,
2080 /// Every item that already existed and that this source has written since it was built,
2081 /// as it wrote it.
2082 ///
2083 /// The other half of [`Self::created`], held on the same terms and for the reason a
2084 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2085 /// filter is an index behind a write this process made moments ago, so a query matching
2086 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2087 /// is remembered that this process did not itself just write.
2088 updated: Mutex<Vec<Resolved>>,
2089 /// How fast this source writes, and how long it waits out a refusal.
2090 pacing: Pacing,
2091 /// When the last content-creating mutation finished, or the moment the furthest-out
2092 /// reserved slot releases the next one, whichever is later — so the one after it can be
2093 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2094 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2095 /// what it is measured from.
2096 last_mutation: Mutex<Option<Instant>>,
2097 /// The board as this process last read it, for the length of one command.
2098 ///
2099 /// A copy of a project used to re-read the whole board, paged, before writing each of
2100 /// its items, which is by far the largest part of a copy's request count and none of
2101 /// its work. Nothing else changes this board while a command runs — this source's own
2102 /// writes are the only writer — so one read answers them all.
2103 ///
2104 /// It is not a store of a user's work and it is not the cache the no-persistence
2105 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2106 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2107 /// an item this command created and then depends on resolves whether or not GitHub's
2108 /// own eventually-consistent read has caught up. A write to an item already on the
2109 /// board updates the entry here too, so what this holds is the last read plus this
2110 /// process's own writes rather than a snapshot taken before them.
2111 board_cache: Mutex<Option<Board>>,
2112 /// Every issue this board's own search reported, for the length of one command.
2113 ///
2114 /// The second half of a board read, and cached for the same reason and on the same
2115 /// terms as the first: it lives and dies with the process, nothing is written down, and
2116 /// a write this process makes updates the entry here exactly as it updates the one in
2117 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2118 /// that lists this board's projects and its tasks pays for one search rather than two.
2119 search_cache: Mutex<Option<Vec<Resolved>>>,
2120 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2121 /// the length of one command.
2122 ///
2123 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2124 /// and dies with the process, nothing is written down, a write this process makes
2125 /// updates the entry here as it updates the other two, and every answer is completed
2126 /// with this process's own writes each time it is given. A command that asks the same
2127 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2128 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2129 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2130 /// The board's own id and field definitions as this process last read them on their
2131 /// own, for the length of one command.
2132 ///
2133 /// What a write needs of the board and its item does not say, read once per command
2134 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2135 /// lives and dies with the process and nothing is written down. It holds no item and so
2136 /// can answer no question about one — see [`Self::board_fields`].
2137 fields_cache: Mutex<Option<BoardFields>>,
2138 /// Each destination repository's node id, resolved once per repository
2139 /// rather than per issue created.
2140 ///
2141 /// A repository's node id does not change, and re-reading it for every issue of a copy
2142 /// spent one request per item on an answer this source already had. It is a map rather
2143 /// than one entry because a copy files each item in the repository its own
2144 /// `repositories` field names, so a plan across five repositories asks GitHub five
2145 /// times and not once per item.
2146 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2147 /// What every request this source sends is recorded into.
2148 ///
2149 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2150 /// a request leaves this crate, so nothing has to be switched on for a session to be
2151 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2152 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2153 /// source's reads and writes — adds up one accounting instead of two. See
2154 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2155 ledger: Arc<Accounting>,
2156}
2157
2158/// GitHub's closed single-select color vocabulary.
2159#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2160#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2161pub enum StatusOptionColor {
2162 /// Gray.
2163 Gray,
2164 /// Blue.
2165 Blue,
2166 /// Green.
2167 Green,
2168 /// Yellow.
2169 Yellow,
2170 /// Purple.
2171 Purple,
2172 /// Red.
2173 Red,
2174 /// Orange.
2175 Orange,
2176 /// Pink.
2177 Pink,
2178}
2179
2180/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2181/// applies its additions.
2182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2183pub enum SetupMode {
2184 /// Read without mutation.
2185 Plan,
2186 /// Apply and verify.
2187 Apply,
2188}
2189
2190/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2191/// against it goes on compiling.
2192pub type StatusOptionsMode = SetupMode;
2193
2194/// The explicit result of the requested operation.
2195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2196#[serde(rename_all = "kebab-case")]
2197pub enum StatusOptionsOutcome {
2198 /// A read-only plan.
2199 Planned,
2200 /// Apply found nothing missing.
2201 Unchanged,
2202 /// Additions were applied and verified.
2203 Applied,
2204}
2205
2206/// A GitHub single-select option's opaque GraphQL node identifier.
2207#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2208#[serde(transparent)]
2209pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2210
2211impl TryFrom<String> for StatusOptionId {
2212 type Error = String;
2213
2214 fn try_from(id: String) -> Result<Self, Self::Error> {
2215 if id.trim().is_empty() {
2216 return Err("a GitHub Status option id cannot be blank".to_owned());
2217 }
2218 Ok(Self(id))
2219 }
2220}
2221
2222/// One existing or proposed option in a guarded Status-field update.
2223#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2224pub struct StatusOption {
2225 /// GitHub's stable id.
2226 pub id: StatusOptionId,
2227 /// The visible option name.
2228 pub name: ColumnName,
2229 /// GitHub's single-select color token.
2230 pub color: StatusOptionColor,
2231 /// The option description, including an empty one.
2232 pub description: String,
2233}
2234
2235/// One board item's Status assignment, retained as recovery data.
2236#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2237pub struct StatusAssignment {
2238 /// The project item id whose assignment this is.
2239 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2240 // carried verbatim as operator recovery data; introducing a semantic type would claim
2241 // validation rules GitHub does not publish and no operation here interprets.
2242 pub item_id: String,
2243 /// The selected option, absent when the item has no status.
2244 #[serde(skip_serializing_if = "Option::is_none")]
2245 pub option: Option<AssignedStatusOption>,
2246}
2247
2248/// The inseparable id and name of an assigned option.
2249#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2250pub struct AssignedStatusOption {
2251 /// GitHub's stable id.
2252 pub id: StatusOptionId,
2253 /// The visible name.
2254 pub name: ColumnName,
2255}
2256
2257/// The plan and verified outcome of reconciling configured Status options.
2258#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2259pub struct StatusOptionsReport {
2260 /// The configured source name.
2261 pub source: SourceName,
2262 /// Configured option names absent before the operation.
2263 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2264 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2265 // serialized string here preserves the report's intentionally simple public contract.
2266 pub missing: Vec<String>,
2267 /// What the requested operation did.
2268 pub outcome: StatusOptionsOutcome,
2269 /// The complete option list observed before any mutation.
2270 pub existing: Vec<StatusOption>,
2271}
2272
2273#[derive(Debug, Clone, PartialEq, Eq)]
2274struct StatusSnapshot {
2275 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2276 // passed back as the mutation's project identity; a newtype could enforce no stronger
2277 // invariant because GitHub publishes no grammar for it.
2278 board_id: String,
2279 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2280 // passed back as the mutation's field identity; a newtype could enforce no stronger
2281 // invariant because GitHub publishes no grammar for it.
2282 field_id: String,
2283 options: Vec<StatusOption>,
2284 assignments: Vec<StatusAssignment>,
2285}
2286
2287/// The name of the board field a status is held in.
2288const STATUS_FIELD: &str = "Status";
2289
2290/// Every item's value of each field `report` names, as it stood before the setup wrote
2291/// anything — what a person puts back when the setup is refused part way.
2292fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2293 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2294 .fields
2295 .iter()
2296 .map(|field| (field.field.name(), before.assignments(field.field)))
2297 .collect();
2298 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2299 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2300 })
2301}
2302
2303/// One board field the guarded setup reads and writes — every one it reads, and the only
2304/// ones it writes.
2305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2306pub enum BoardField {
2307 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2308 Status,
2309 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2310 Priority,
2311}
2312
2313impl BoardField {
2314 /// The field's name on the board.
2315 #[must_use]
2316 pub const fn name(self) -> &'static str {
2317 match self {
2318 Self::Status => STATUS_FIELD,
2319 Self::Priority => PRIORITY_FIELD,
2320 }
2321 }
2322
2323 /// The field a board calls `name`, or `None` for one this setup does not own.
2324 fn named(name: &str) -> Option<Self> {
2325 [Self::Status, Self::Priority]
2326 .into_iter()
2327 .find(|field| field.name() == name)
2328 }
2329}
2330
2331/// What the guarded setup did to one field.
2332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2333#[serde(rename_all = "kebab-case")]
2334pub enum FieldOutcome {
2335 /// A read-only plan.
2336 Planned,
2337 /// Apply found the field there with every configured option.
2338 Unchanged,
2339 /// Missing options were added to the field that was there, and verified.
2340 Applied,
2341 /// The field was not there; it was created holding the configured options, and verified.
2342 Created,
2343}
2344
2345/// One field's plan, or its verified outcome.
2346#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2347pub struct FieldReport {
2348 /// Which field.
2349 pub field: BoardField,
2350 /// Whether the board had the field before the operation.
2351 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2352 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2353 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2354 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2355 // one constructor, and it derives `outcome` from `exists` in one match.
2356 pub exists: bool,
2357 /// Configured option names the field lacked before the operation — every one of them,
2358 /// in the order a new field lists them, when the field was not there at all.
2359 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2360 // mapping name and has therefore already passed its nonblank validation; the serialized
2361 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2362 pub missing: Vec<String>,
2363 /// What the requested operation did.
2364 pub outcome: FieldOutcome,
2365 /// The field's complete option list observed before any mutation; empty when the field
2366 /// was not there.
2367 pub existing: Vec<StatusOption>,
2368}
2369
2370/// The plan and verified outcome of setting up every field a source's configuration names.
2371#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2372pub struct FieldsReport {
2373 /// The configured source name.
2374 pub source: SourceName,
2375 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2376 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2377 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2378 // per field would change a published JSON shape. The states the list could hold and the
2379 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2380 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2381 pub fields: Vec<FieldReport>,
2382}
2383
2384/// Which options one field is configured with, in the order a new field would list them.
2385struct FieldPlan {
2386 field: BoardField,
2387 wanted: Vec<String>,
2388}
2389
2390/// One single-select field as the guarded setup snapshots it.
2391#[derive(Debug, Clone, PartialEq, Eq)]
2392struct SnapshotField {
2393 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2394 // passed back as the mutation's field identity; a newtype could enforce no stronger
2395 // invariant because GitHub publishes no grammar for it.
2396 field_id: String,
2397 options: Vec<StatusOption>,
2398}
2399
2400/// Every single-select field of a board and every item's value of each.
2401#[derive(Debug, Clone, PartialEq, Eq)]
2402struct BoardSnapshot {
2403 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2404 // passed back as the mutation's project identity; a newtype could enforce no stronger
2405 // invariant because GitHub publishes no grammar for it.
2406 board_id: String,
2407 fields: BTreeMap<BoardField, SnapshotField>,
2408 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2409 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2410}
2411
2412impl BoardSnapshot {
2413 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2414 /// carries.
2415 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2416 self.items
2417 .iter()
2418 .map(|(item_id, values)| StatusAssignment {
2419 item_id: item_id.clone(),
2420 option: values.get(&field).cloned(),
2421 })
2422 .collect()
2423 }
2424}
2425
2426impl GitHubProjectsSource {
2427 /// Report missing configured Status options and, when `apply` is true, add them with
2428 /// a whole-list mutation that preserves every existing id and verifies the result.
2429 ///
2430 /// # Errors
2431 ///
2432 /// Refuses a board without a single-select `Status` field. A post-write difference in
2433 /// any pre-existing option id or item assignment is refused with the complete pre-write
2434 /// assignment snapshot in the diagnostic for recovery.
2435 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2436 // successful mutation, both drift refusals, source selection, missing Status, casing,
2437 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2438 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2439 // responses from entering the defensive malformed-response branches below.
2440 pub async fn status_options(
2441 &self,
2442 mode: StatusOptionsMode,
2443 ) -> Result<StatusOptionsReport, SourceError> {
2444 let before = self.status_snapshot().await?;
2445 let configured = self
2446 .statuses
2447 .targets
2448 .iter()
2449 // A terminal category's option is as configured as an open one's: a terminal
2450 // write validates it before closing and refuses when the board lacks it.
2451 .filter_map(|target| match target {
2452 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2453 Some(name.as_str().to_owned())
2454 }
2455 StatusTarget::Disabled => None,
2456 });
2457 let missing = configured
2458 .filter(|wanted| {
2459 !before
2460 .options
2461 .iter()
2462 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2463 })
2464 .collect::<Vec<_>>();
2465 let report = StatusOptionsReport {
2466 source: self.name.clone(),
2467 missing: missing.clone(),
2468 outcome: match (mode, missing.is_empty()) {
2469 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2470 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2471 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2472 },
2473 existing: before.options.clone(),
2474 };
2475 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2476 return Ok(report);
2477 }
2478 let mut options = before
2479 .options
2480 .iter()
2481 .map(|option| {
2482 json!({
2483 "id": option.id, "name": option.name, "color": option.color,
2484 "description": option.description,
2485 })
2486 })
2487 .collect::<Vec<_>>();
2488 options.extend(missing.iter().map(|name| {
2489 json!({
2490 "name": name, "color": "GRAY", "description": ""
2491 })
2492 }));
2493 self.graphql(
2494 graphql::STATUS_OPTIONS_UPDATE,
2495 json!({"input": {
2496 "projectId": before.board_id, "fieldId": before.field_id,
2497 "singleSelectOptions": options,
2498 }}),
2499 )
2500 .await?;
2501 let after = self.status_snapshot().await?;
2502 let options_preserved = before
2503 .options
2504 .iter()
2505 .all(|old| after.options.iter().any(|new| new == old));
2506 let additions_present = missing.iter().all(|wanted| {
2507 after
2508 .options
2509 .iter()
2510 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2511 });
2512 if !options_preserved || !additions_present || after.assignments != before.assignments {
2513 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2514 SourceError::Malformed {
2515 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2516 }
2517 })?;
2518 return Err(SourceError::Refused {
2519 message: format!(
2520 "GitHub changed a pre-existing Status option id, name, color or description, or an item assignment after the guarded update; the pre-write item assignment snapshot is:\n{recovery}"
2521 ),
2522 });
2523 }
2524 Ok(report)
2525 }
2526
2527 /// A fresh snapshot of the Status field and every board item's assignment of it.
2528 ///
2529 /// # Errors
2530 ///
2531 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2532 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2533 // Status alone, as this operation has always read it: a `Priority` field is another
2534 // operation's, so nothing about it can refuse this one.
2535 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2536 let field = board
2537 .fields
2538 .remove(&BoardField::Status)
2539 .ok_or_else(|| self.no_status_field())?;
2540 Ok(StatusSnapshot {
2541 assignments: board.assignments(BoardField::Status),
2542 board_id: board.board_id,
2543 field_id: field.field_id,
2544 options: field.options,
2545 })
2546 }
2547
2548 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2549 fn no_status_field(&self) -> SourceError {
2550 SourceError::Refused {
2551 message: format!("source {} board has no Status field", self.name),
2552 }
2553 }
2554
2555 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2556 // the real CLI loopback journey, including pagination. The individual malformed guards
2557 // are defensive validation of a schema-pinned third-party response, not separate user
2558 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2559 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2560 /// every board item's value of each, walked to the end of the board's items. A field not
2561 /// in `owned` is read past whatever it holds.
2562 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2563 let mut after: Option<String> = None;
2564 let mut snapshot: Option<BoardSnapshot> = None;
2565 loop {
2566 let data = self
2567 .graphql(
2568 graphql::STATUS_OPTIONS_SNAPSHOT,
2569 json!({
2570 "owner": self.owner, "number": self.project_number,
2571 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2572 }),
2573 )
2574 .await?;
2575 let board = data
2576 .pointer("/owner/projectV2")
2577 .filter(|board| board.is_object())
2578 .ok_or_else(|| SourceError::Refused {
2579 message: format!(
2580 "source {} has no accessible GitHub Projects board",
2581 self.name
2582 ),
2583 })?;
2584 if board
2585 .pointer("/fields/pageInfo/hasNextPage")
2586 .and_then(Value::as_bool)
2587 != Some(false)
2588 {
2589 return Err(SourceError::Malformed {
2590 message:
2591 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2592 .into(),
2593 });
2594 }
2595 let mut fields = BTreeMap::new();
2596 // Only the fields this setup owns, by name: a node the single-select fragment did not
2597 // match carries no name, and a person's own single-select field — a `Size`, a
2598 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2599 // `Status` or `Priority` field without its options is malformed, not absent.
2600 // llmlint: ignore[boundary_inputs_validated] The field page this loop reads is validated as complete immediately above: any `fields.pageInfo.hasNextPage` other than `false` is refused as malformed before a node is read, so an incomplete page is never taken for the board's whole field set.
2601 for (owned, field) in board
2602 .pointer("/fields/nodes")
2603 .and_then(Value::as_array)
2604 .ok_or_else(|| SourceError::Malformed {
2605 message: "GitHub project fields.nodes is not an array".into(),
2606 })?
2607 .iter()
2608 .filter_map(|field| {
2609 let named = BoardField::named(field.get("name")?.as_str()?)?;
2610 owned.contains(&named).then_some((named, field))
2611 })
2612 {
2613 let options = field
2614 .get("options")
2615 .and_then(Value::as_array)
2616 .ok_or_else(|| SourceError::Malformed {
2617 message: "GitHub single-select field options is not an array".into(),
2618 })?
2619 .iter()
2620 .map(|option| {
2621 Ok(StatusOption {
2622 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2623 .map_err(|message| SourceError::Malformed { message })?,
2624 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2625 .map_err(|message| SourceError::Malformed {
2626 message: format!(
2627 "GitHub single-select option name is invalid: {message}"
2628 ),
2629 })?,
2630 color: serde_json::from_value(
2631 option.get("color").cloned().unwrap_or(Value::Null),
2632 )
2633 .map_err(|error| {
2634 SourceError::Malformed {
2635 message: format!(
2636 "GitHub single-select option color is invalid: {error}"
2637 ),
2638 }
2639 })?,
2640 description: optional_str(option, "description")?
2641 .unwrap_or_default()
2642 .to_owned(),
2643 })
2644 })
2645 .collect::<Result<Vec<_>, SourceError>>()?;
2646 let snapshot = SnapshotField {
2647 field_id: required_nonblank_str(field, "id")?.to_owned(),
2648 options,
2649 };
2650 // A board's field names are unique, so a second one is an answer that cannot
2651 // say which field the setup would act on — refused rather than one chosen.
2652 if fields.insert(owned, snapshot).is_some() {
2653 return Err(SourceError::Malformed {
2654 message: format!(
2655 "GitHub answered two {} fields for this board",
2656 owned.name()
2657 ),
2658 });
2659 }
2660 }
2661 let board_id = required_nonblank_str(board, "id")?.to_owned();
2662 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2663 board_id,
2664 fields,
2665 items: Vec::new(),
2666 });
2667 let items = board
2668 .pointer("/items/nodes")
2669 .and_then(Value::as_array)
2670 .ok_or_else(|| SourceError::Malformed {
2671 message: "GitHub project items.nodes is not an array".into(),
2672 })?;
2673 for item in items {
2674 let field_values =
2675 item.get("fieldValues")
2676 .ok_or_else(|| SourceError::Malformed {
2677 message: "GitHub project item is missing fieldValues".into(),
2678 })?;
2679 if field_values
2680 .pointer("/pageInfo/hasNextPage")
2681 .and_then(Value::as_bool)
2682 != Some(false)
2683 {
2684 return Err(SourceError::Malformed {
2685 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2686 });
2687 }
2688 let values = item
2689 .pointer("/fieldValues/nodes")
2690 .and_then(Value::as_array)
2691 .ok_or_else(|| SourceError::Malformed {
2692 message: "GitHub project item fieldValues.nodes is not an array".into(),
2693 })?;
2694 let item_id = required_nonblank_str(item, "id")?;
2695 let mut assigned = BTreeMap::new();
2696 for value in values {
2697 let Some(field) = value
2698 .pointer("/field/name")
2699 .and_then(Value::as_str)
2700 .and_then(BoardField::named)
2701 .filter(|field| owned.contains(field))
2702 else {
2703 continue;
2704 };
2705 let held = assigned.insert(
2706 field,
2707 AssignedStatusOption {
2708 id: StatusOptionId::try_from(
2709 required_str(value, "optionId")?.to_owned(),
2710 )
2711 .map_err(|message| SourceError::Malformed { message })?,
2712 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2713 .map_err(|message| SourceError::Malformed {
2714 message: format!(
2715 "GitHub assigned {} name is invalid: {message}",
2716 field.name()
2717 ),
2718 })?,
2719 },
2720 );
2721 // An item holds one value of a field, so a second one leaves no way to
2722 // tell which it holds — and a verification or recovery built on either
2723 // could restore the wrong one.
2724 if held.is_some() {
2725 return Err(SourceError::Malformed {
2726 message: format!(
2727 "GitHub answered two {} values for board item {item_id}",
2728 field.name()
2729 ),
2730 });
2731 }
2732 }
2733 current.items.push((item_id.to_owned(), assigned));
2734 }
2735 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2736 message: "GitHub project is missing items".into(),
2737 })?;
2738 let has_next = page
2739 .pointer("/pageInfo/hasNextPage")
2740 .and_then(Value::as_bool)
2741 .ok_or_else(|| SourceError::Malformed {
2742 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
2743 })?;
2744 if !has_next {
2745 break;
2746 }
2747 let next =
2748 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
2749 validate_cursor_progress(after.as_deref(), next)?;
2750 after = Some(next.to_owned());
2751 }
2752 snapshot.ok_or_else(|| SourceError::Malformed {
2753 message: "GitHub returned no board field snapshot".into(),
2754 })
2755 }
2756 // llmlint: ignore-end[changed_behavior_has_e2e]
2757
2758 /// Report every board field this source's configuration names and, with
2759 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
2760 /// the `Priority` field when the board has none.
2761 ///
2762 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
2763 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
2764 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
2765 /// color and description: the whole option list goes back with every existing id, because
2766 /// a re-minted id clears every item's value.
2767 ///
2768 /// # Errors
2769 ///
2770 /// Refuses a board without a single-select `Status` field. After an apply the board is
2771 /// read again, and a pre-existing option or any item's value of either field that moved is
2772 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
2773 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
2774 // unchanged apply, a created field, an added option to each field, drift refusal, a board
2775 // with no Status field and a non-github-projects source through the compiled CLI against
2776 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
2777 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
2778 let owned: Vec<BoardField> = if self.priorities.is_some() {
2779 vec![BoardField::Status, BoardField::Priority]
2780 } else {
2781 vec![BoardField::Status]
2782 };
2783 let before = self.board_snapshot(&owned).await?;
2784 let mut plans = vec![FieldPlan {
2785 field: BoardField::Status,
2786 wanted: self
2787 .statuses
2788 .targets
2789 .iter()
2790 .filter_map(|target| match target {
2791 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2792 Some(name.as_str().to_owned())
2793 }
2794 StatusTarget::Disabled => None,
2795 })
2796 .collect(),
2797 }];
2798 if !before.fields.contains_key(&BoardField::Status) {
2799 return Err(self.no_status_field());
2800 }
2801 if let Some(mapping) = &self.priorities {
2802 plans.push(FieldPlan {
2803 field: BoardField::Priority,
2804 wanted: mapping.names().map(str::to_owned).collect(),
2805 });
2806 }
2807 // The snapshot reads single-select fields alone, so a field it did not find may still
2808 // be on the board under the name, of another type: creating one beside it would fail
2809 // part way, or leave two fields of one name. Asked of the board's own field list, and
2810 // only when a field is missing.
2811 if plans
2812 .iter()
2813 .any(|plan| !before.fields.contains_key(&plan.field))
2814 {
2815 let board = self.board_fields().await?;
2816 for plan in plans
2817 .iter()
2818 .filter(|plan| !before.fields.contains_key(&plan.field))
2819 {
2820 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
2821 return Err(SourceError::Refused {
2822 message: format!(
2823 "source {}'s board has a {} field that is not a single-select field \
2824 (it is a {}), so it cannot hold this source's options; next: rename \
2825 or remove that field, then run this again",
2826 self.name,
2827 plan.field.name(),
2828 optional_str(field, "__typename")?.unwrap_or("field of another type")
2829 ),
2830 });
2831 }
2832 }
2833 }
2834 let mut reports = Vec::new();
2835 for plan in &plans {
2836 let held = before.fields.get(&plan.field);
2837 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
2838 let mut missing: Vec<String> = Vec::new();
2839 for wanted in &plan.wanted {
2840 let present = existing
2841 .iter()
2842 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2843 || missing
2844 .iter()
2845 .any(|named| named.eq_ignore_ascii_case(wanted));
2846 if !present {
2847 missing.push(wanted.clone());
2848 }
2849 }
2850 reports.push(FieldReport {
2851 field: plan.field,
2852 exists: held.is_some(),
2853 outcome: match (mode, held.is_some(), missing.is_empty()) {
2854 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
2855 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
2856 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
2857 (SetupMode::Apply, false, _) => FieldOutcome::Created,
2858 },
2859 missing,
2860 existing,
2861 });
2862 }
2863 let report = FieldsReport {
2864 source: self.name.clone(),
2865 fields: reports,
2866 };
2867 let writes: Vec<&FieldReport> = report
2868 .fields
2869 .iter()
2870 .filter(|field| !field.missing.is_empty() || !field.exists)
2871 .collect();
2872 if mode == SetupMode::Plan || writes.is_empty() {
2873 return Ok(report);
2874 }
2875 let mut landed: Vec<&str> = Vec::new();
2876 for field in &writes {
2877 let added = field
2878 .missing
2879 .iter()
2880 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
2881 let sent = match before.fields.get(&field.field) {
2882 Some(held) => {
2883 let mut options = held
2884 .options
2885 .iter()
2886 .map(|option| {
2887 json!({
2888 "id": option.id, "name": option.name, "color": option.color,
2889 "description": option.description,
2890 })
2891 })
2892 .collect::<Vec<_>>();
2893 options.extend(added);
2894 self.graphql(
2895 graphql::STATUS_OPTIONS_UPDATE,
2896 json!({"input": {
2897 "projectId": before.board_id, "fieldId": held.field_id,
2898 "singleSelectOptions": options,
2899 }}),
2900 )
2901 .await
2902 }
2903 None => {
2904 self.graphql(
2905 graphql::CREATE_FIELD,
2906 json!({"input": {
2907 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
2908 "name": field.field.name(),
2909 "singleSelectOptions": added.collect::<Vec<_>>(),
2910 }}),
2911 )
2912 .await
2913 }
2914 };
2915 // A mutation that failed does not establish that GitHub left its field as it was,
2916 // so every failure from here on carries the recovery data a drift refusal does.
2917 match sent {
2918 Ok(_) => landed.push(field.field.name()),
2919 Err(error) => {
2920 let changed = if landed.is_empty() {
2921 String::new()
2922 } else {
2923 format!("changed the {} field and then ", landed.join(" and "))
2924 };
2925 return Err(SourceError::Refused {
2926 message: format!(
2927 "the guarded field setup {changed}failed on the {} field, which it may \
2928 have changed part way: {error}; the pre-write item assignments \
2929 are:\n{}",
2930 field.field.name(),
2931 recovery(&report, &before)?
2932 ),
2933 });
2934 }
2935 }
2936 }
2937 // The board has been written, so a verification read that fails leaves it unverified
2938 // rather than unchanged, and says what to put back.
2939 let after = match self.board_snapshot(&owned).await {
2940 Ok(after) => after,
2941 Err(error) => {
2942 return Err(SourceError::Refused {
2943 message: format!(
2944 "the guarded field setup changed the {} field and then could not read the \
2945 board back to verify it: {error}; the pre-write item assignments are:\n{}",
2946 landed.join(" and "),
2947 recovery(&report, &before)?
2948 ),
2949 });
2950 }
2951 };
2952 let mut moved = Vec::new();
2953 for field in &report.fields {
2954 let name = field.field.name();
2955 let now = after
2956 .fields
2957 .get(&field.field)
2958 .map(|held| held.options.as_slice())
2959 .unwrap_or_default();
2960 if !field.existing.iter().all(|old| now.contains(old)) {
2961 moved.push(format!(
2962 "a pre-existing {name} option id, name, color or description"
2963 ));
2964 }
2965 if !field.missing.iter().all(|wanted| {
2966 now.iter()
2967 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2968 }) {
2969 moved.push(format!("an added {name} option"));
2970 }
2971 if after.assignments(field.field) != before.assignments(field.field) {
2972 moved.push(format!("an item's {name} value"));
2973 }
2974 }
2975 if !moved.is_empty() {
2976 return Err(SourceError::Refused {
2977 message: format!(
2978 "GitHub changed {} after the guarded field setup; the pre-write item \
2979 assignments are:\n{}",
2980 moved.join(", "),
2981 recovery(&report, &before)?
2982 ),
2983 });
2984 }
2985 Ok(report)
2986 }
2987
2988 /// Validate configuration and capture the named credential without exposing it.
2989 ///
2990 /// # Errors
2991 ///
2992 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
2993 /// [`SourceError::Auth`] when the named credential is missing or empty.
2994 pub fn new(
2995 name: &SourceName,
2996 config: GitHubProjectsConfig,
2997 secrets: &dyn SecretResolver,
2998 ) -> Result<Self, SourceError> {
2999 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3000 }
3001
3002 /// The same, recording every request it sends into an accounting the caller holds too.
3003 ///
3004 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3005 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3006 /// up — passes the one it records those into, so the session total accounts for the
3007 /// whole session rather than for this source's share of it.
3008 ///
3009 /// # Errors
3010 ///
3011 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3012 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3013 pub fn recording_into(
3014 name: &SourceName,
3015 config: GitHubProjectsConfig,
3016 secrets: &dyn SecretResolver,
3017 ledger: Arc<Accounting>,
3018 ) -> Result<Self, SourceError> {
3019 if !valid_github_owner(&config.owner) {
3020 return Err(SourceError::Config {
3021 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3022 });
3023 }
3024 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3025 return Err(SourceError::Config {
3026 message: format!("project_number must be between 1 and {}", i32::MAX),
3027 });
3028 }
3029 if !valid_environment_name(&config.token_env) {
3030 return Err(SourceError::Config {
3031 message: "token_env must be a valid environment-variable name".into(),
3032 });
3033 }
3034 let repository = config
3035 .repository
3036 .as_deref()
3037 .map(RepositoryTarget::parse)
3038 .transpose()?;
3039 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3040 message: format!("endpoint is not a valid URL: {e}"),
3041 })?;
3042 if endpoint.scheme() != "https"
3043 && !(endpoint.scheme() == "http"
3044 && endpoint
3045 .host_str()
3046 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3047 {
3048 return Err(SourceError::Config {
3049 message:
3050 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3051 .into(),
3052 });
3053 }
3054 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3055 message: format!("environment variable {} is missing or empty; set it to a fine-grained GitHub token granting Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board", config.token_env),
3056 })?;
3057 Ok(Self {
3058 name: name.clone(),
3059 owner: config.owner,
3060 project_number: config.project_number,
3061 repository,
3062 endpoint,
3063 token,
3064 credential_name: config.token_env,
3065 statuses: StatusMapping::resolve(config.status_mapping, name)?,
3066 priorities: config
3067 .priority_mapping
3068 .map(|mapping| PriorityMapping::resolve(mapping, name))
3069 .transpose()?,
3070 client: Client::builder()
3071 .user_agent("onetaskgraph")
3072 .build()
3073 .map_err(|e| SourceError::Config {
3074 message: format!("cannot build HTTP client: {e}"),
3075 })?,
3076 created: Mutex::new(Vec::new()),
3077 updated: Mutex::new(Vec::new()),
3078 pacing: Pacing::resolve(config.pacing, name)?,
3079 last_mutation: Mutex::new(None),
3080 board_cache: Mutex::new(None),
3081 search_cache: Mutex::new(None),
3082 narrowed_cache: Mutex::new(BTreeMap::new()),
3083 fields_cache: Mutex::new(None),
3084 repository_cache: Mutex::new(BTreeMap::new()),
3085 ledger,
3086 })
3087 }
3088
3089 /// A snapshot of every request this source has sent, and what each cost.
3090 ///
3091 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3092 /// of them can sit side by side. When this source was built with
3093 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3094 /// point of building it that way.
3095 #[must_use]
3096 pub fn accounting(&self) -> accounting::Session {
3097 self.ledger.snapshot()
3098 }
3099
3100 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3101 /// rate limit rather than handing it straight back as an error.
3102 ///
3103 /// Retrying is safe for every document here, including the mutations, and the reason
3104 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3105 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3106 /// this replays has already taken effect. An outcome this source cannot know — the
3107 /// send failed, or the body could not be read, so the mutation may well have landed —
3108 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3109 /// attempt. A duplicate write would come from replaying one of those, and none is
3110 /// replayed.
3111 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3112 let doing = operation_description(query);
3113 let mut waited = Duration::ZERO;
3114 let mut waits = 0_u32;
3115 let mut backoff = self.pacing.retry_backoff;
3116 loop {
3117 if is_mutation(query) {
3118 let spacing = self.reserve_mutation_slot();
3119 if !spacing.is_zero() {
3120 tokio::time::sleep(spacing).await;
3121 }
3122 }
3123 let attempt = self.send_once(query, &variables).await;
3124 if is_mutation(query) {
3125 self.finish_mutation();
3126 }
3127 let limited = match attempt {
3128 Ok(data) => return Ok(data),
3129 Err(Attempt::Failed(error)) => return Err(error),
3130 Err(Attempt::Limited(limited)) => limited,
3131 };
3132 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3133 // move that extends a secondary limit, so a hint below the schedule's own next
3134 // wait is raised to it.
3135 let wait = match limited.hint {
3136 Some(hint) => Duration::from_secs(hint).max(backoff),
3137 None => backoff,
3138 };
3139 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3140 // A wait of nothing spends none of the budget, so it is exhaustion rather
3141 // than a retry. `Pacing::resolve` rules out every way of configuring one
3142 // except a budget of zero, where reporting the first refusal is the ask.
3143 if wait.is_zero() || wait > remaining {
3144 return Err(limited.exhausted(
3145 doing,
3146 waits,
3147 waited,
3148 wait,
3149 self.pacing.retry_budget,
3150 ));
3151 }
3152 tokio::time::sleep(wait).await;
3153 waited += wait;
3154 waits += 1;
3155 backoff = backoff.saturating_mul(2);
3156 }
3157 }
3158
3159 /// The next moment a content-creating mutation may leave this source, as a wait from
3160 /// now.
3161 ///
3162 /// The slot is reserved under the lock and the waiting happens outside it, so two
3163 /// callers take two slots rather than the same one — and no lock is held across an
3164 /// await.
3165 ///
3166 /// The moment it is spaced from is the previous mutation's *completion*, which
3167 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3168 /// own is the wrong thing to measure from.
3169 fn reserve_mutation_slot(&self) -> Duration {
3170 if self.pacing.min_mutation_interval.is_zero() {
3171 return Duration::ZERO;
3172 }
3173 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3174 // it would turn an earlier failure into a second one for no gain.
3175 let mut last = self
3176 .last_mutation
3177 .lock()
3178 .unwrap_or_else(std::sync::PoisonError::into_inner);
3179 let now = Instant::now();
3180 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3181 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3182 let at = last.map_or(now, |previous| {
3183 previous
3184 .checked_add(self.pacing.min_mutation_interval)
3185 .map_or(now, |earliest| earliest.max(now))
3186 });
3187 *last = Some(at);
3188 at.saturating_duration_since(now)
3189 }
3190
3191 /// Record that a content-creating mutation has finished, so the next one is spaced
3192 /// from here rather than from the moment this one was released.
3193 ///
3194 /// This source can only choose when a request *departs*; the limiter counts when it
3195 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3196 /// departure from the last therefore hands the limiter a gap of the interval less that
3197 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3198 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3199 /// slower machine while passing on a quick one.
3200 ///
3201 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3202 /// previous request had already arrived before its response came back, so its arrival
3203 /// is no later than this moment, and the next mutation is released at least the
3204 /// interval after this moment and arrives no earlier than it is released: the gap the
3205 /// limiter measures is therefore at least the interval, whatever transit costs and on
3206 /// whatever platform. The price is that a mutation's own round trip no longer counts
3207 /// towards its spacing, which makes this source slightly slower than the configured
3208 /// rate rather than slightly faster — the safe side of a limit that punishes being
3209 /// wrong by refusing reads for the next fifty minutes.
3210 ///
3211 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3212 /// and one that never left costs only a wait nobody needed.
3213 fn finish_mutation(&self) {
3214 if self.pacing.min_mutation_interval.is_zero() {
3215 return;
3216 }
3217 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3218 let mut last = self
3219 .last_mutation
3220 .lock()
3221 .unwrap_or_else(std::sync::PoisonError::into_inner);
3222 let now = Instant::now();
3223 // `max` rather than an assignment: a concurrent caller may already have reserved a
3224 // slot further out, and completing this request must never pull that slot back in.
3225 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3226 }
3227
3228 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3229 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3230 ///
3231 /// This is the one place a request leaves this crate, which is why the accounting is
3232 /// here rather than at each of the callers: a read path added later is counted without
3233 /// anybody remembering to count it, and
3234 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3235 /// when one is not.
3236 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3237 let Attempted {
3238 result,
3239 limits,
3240 reported_cost,
3241 } = self.attempt(query, variables).await;
3242 // No `otherwise` name: every document this source sends is one of its own, and the
3243 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3244 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3245 let outcome = match &result {
3246 Ok(_) => accounting::Outcome::Answered,
3247 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3248 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3249 };
3250 self.ledger.record(sending.finished(outcome, limits));
3251 result
3252 }
3253
3254 /// The attempt itself, with what its response said about the rate limit alongside.
3255 ///
3256 /// The two are returned together rather than recorded here because every one of the
3257 /// early exits below is a different outcome, and a record written at each of them is a
3258 /// record one of them can be added without.
3259 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3260 let mut limits = accounting::RateLimit::default();
3261 let mut reported_cost = None;
3262 let result = self
3263 .attempted(query, variables, &mut limits, &mut reported_cost)
3264 .await;
3265 Attempted {
3266 result,
3267 limits,
3268 reported_cost,
3269 }
3270 }
3271
3272 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3273 async fn attempted(
3274 &self,
3275 query: &str,
3276 variables: &Value,
3277 limits: &mut accounting::RateLimit,
3278 reported_cost: &mut Option<u64>,
3279 ) -> Result<Value, Attempt> {
3280 let response = self
3281 .client
3282 .post(self.endpoint.clone())
3283 .bearer_auth(self.token.expose_secret())
3284 .json(&json!({"query": query, "variables": variables}))
3285 .send()
3286 .await
3287 .map_err(|e| {
3288 Attempt::Failed(SourceError::Unavailable {
3289 message: format!("GitHub GraphQL request failed: {e}"),
3290 })
3291 })?;
3292 let status = response.status();
3293 let header = |name: &str| whole_seconds(response.headers().get(name));
3294 *limits = accounting::RateLimit::read(|name| {
3295 response
3296 .headers()
3297 .get(name)
3298 .and_then(|value| value.to_str().ok())
3299 .map(str::to_owned)
3300 });
3301 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3302 // that are not text at all — is "not known to be exhausted". This never makes a
3303 // response a refusal on its own: it says which limiter a refusal is attributed to
3304 // and where its hint comes from, so a value this cannot read costs a hint rather
3305 // than an answer.
3306 let exhausted = response
3307 .headers()
3308 .get("x-ratelimit-remaining")
3309 .and_then(|value| value.to_str().ok())
3310 == Some("0");
3311 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3312 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3313 // which is the same question answered as an absolute time. Nothing else here is a
3314 // hint, and a schedule is what answers a refusal that carries none.
3315 let hint = header("retry-after").or_else(|| {
3316 exhausted
3317 .then(|| header("x-ratelimit-reset"))
3318 .flatten()
3319 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3320 });
3321 // Read before it is parsed, because the evidence which tells a secondary rate
3322 // limit from a rejected credential is in the body of a response whose status says
3323 // only "forbidden" — and a non-success response was never parsed at all.
3324 let body = response.text().await.map_err(|e| {
3325 Attempt::Failed(SourceError::Unavailable {
3326 message: format!("GitHub GraphQL response could not be read: {e}"),
3327 })
3328 })?;
3329 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3330 return Err(Attempt::Limited(Limited { limiter, hint }));
3331 }
3332 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3333 return Err(Attempt::Failed(SourceError::Auth {
3334 message: format!(
3335 "GitHub rejected the configured credential with HTTP {status}; grant it Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board"
3336 ),
3337 }));
3338 }
3339 if !status.is_success() {
3340 return Err(Attempt::Failed(SourceError::Unavailable {
3341 message: format!("GitHub GraphQL returned HTTP {status}"),
3342 }));
3343 }
3344 // GitHub reports what a call cost only when the document asked it to, and no
3345 // document this source sends does — so this is `None` here and carries the figure
3346 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3347 // pick up is a `dryRun` probe's cost, which is some other document's.
3348 *reported_cost = serde_json::from_str::<Value>(&body)
3349 .ok()
3350 .as_ref()
3351 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3352 .and_then(Value::as_u64);
3353 self.answer(&body).map_err(Attempt::Failed)
3354 }
3355
3356 /// What one successful HTTP response says, once its GraphQL errors are read.
3357 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3358 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3359 message: format!("GitHub returned invalid JSON: {e}"),
3360 })?;
3361 let errors = body
3362 .get("errors")
3363 .map(|value| {
3364 value.as_array().ok_or_else(|| SourceError::Malformed {
3365 message: "GitHub response errors is not an array".into(),
3366 })
3367 })
3368 .transpose()?;
3369 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3370 let messages = errors
3371 .iter()
3372 .filter_map(|e| e.get("message").and_then(Value::as_str))
3373 .collect::<Vec<_>>()
3374 .join("; ");
3375 let message = if messages.is_empty() {
3376 "GitHub returned GraphQL errors".into()
3377 } else {
3378 messages
3379 };
3380 let normalized = message.to_ascii_lowercase();
3381 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3382 return Err(SourceError::Auth {
3383 message: format!(
3384 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3385 self.credential_name
3386 ),
3387 });
3388 }
3389 return Err(SourceError::Refused { message });
3390 }
3391 body.get("data")
3392 .filter(|data| data.is_object())
3393 .cloned()
3394 .ok_or_else(|| SourceError::Malformed {
3395 message: "GitHub response has no data object".into(),
3396 })
3397 }
3398
3399 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3400 // GraphQL cannot independently page them inside the outer item page. This source page is
3401 // deliberately bounded at that published maximum; the live drift journey exercises it.
3402 async fn board_page(
3403 &self,
3404 items_after: Option<&str>,
3405 items_first: u32,
3406 ) -> Result<Value, SourceError> {
3407 let data = self
3408 .graphql(
3409 graphql::BOARD,
3410 json!({"owner":self.owner,"number":self.project_number,
3411 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3412 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3413 )
3414 .await?;
3415 data.pointer("/owner/projectV2")
3416 .filter(|v| !v.is_null())
3417 .cloned()
3418 .ok_or_else(|| SourceError::Refused {
3419 message: format!(
3420 "GitHub project {}/{} was not found or is not visible to the token",
3421 self.owner, self.project_number
3422 ),
3423 })
3424 }
3425
3426 /// The search that finds the issues of this board, narrowed by `also` when it is
3427 /// given.
3428 ///
3429 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3430 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3431 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3432 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3433 /// from a task by the `parent` field each issue carries rather than by the search.
3434 fn board_search(&self, also: Option<&str>) -> String {
3435 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3436 match also {
3437 Some(also) => format!("{scope} {also}"),
3438 None => scope,
3439 }
3440 }
3441
3442 /// One issue this source reached directly, as the board item a read of the board would
3443 /// have produced — or `None` when this board does not hold it.
3444 ///
3445 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3446 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3447 /// item's own id, that item's field values, and the issue as its content. One resolver
3448 /// for both routes is what makes an issue read through a search, through its own node
3449 /// id, or through its project's sub-issues report the same title, the same status, the
3450 /// same labels and the same qualified id.
3451 ///
3452 /// An issue with no entry for *this* board is not this source's to report, which is
3453 /// what keeps an id naming some other repository's issue from being answered as an item
3454 /// of this board. That answer is given about an **exhausted** connection and never
3455 /// about an unread page: the entry is looked for on the page in hand, and only if that
3456 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3457 /// rest of it.
3458 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3459 if optional_str(issue, "__typename")? != Some("Issue") {
3460 return Ok(None);
3461 }
3462 let memberships = issue
3463 .get("projectItems")
3464 .ok_or_else(|| SourceError::Malformed {
3465 message: "GitHub issue is missing projectItems".into(),
3466 })?;
3467 let nodes = memberships
3468 .get("nodes")
3469 .and_then(Value::as_array)
3470 .ok_or_else(|| SourceError::Malformed {
3471 message: "GitHub issue projectItems.nodes is not an array".into(),
3472 })?;
3473 let held = match self.board_entry(nodes) {
3474 Some(held) => held.clone(),
3475 None => {
3476 let info = memberships
3477 .get("pageInfo")
3478 .ok_or_else(|| SourceError::Malformed {
3479 message: "GitHub issue projectItems has no pageInfo".into(),
3480 })?;
3481 // The page held no entry for this board. Whether that means the issue is
3482 // not on it is a question about the rest of the connection, and only a
3483 // connection with no rest answers it here.
3484 if !required_bool(info, "hasNextPage")? {
3485 return Ok(None);
3486 }
3487 let cursor = required_str(info, "endCursor")?;
3488 validate_cursor_progress(None, cursor)?;
3489 let issue_id = required_str(issue, "id")?;
3490 match self.board_membership(issue_id, cursor).await? {
3491 Some(held) => held,
3492 None => return Ok(None),
3493 }
3494 }
3495 };
3496 let item = json!({
3497 "id": required_str(&held, "id")?,
3498 "project": held.get("project"),
3499 "fieldValues": held.get("fieldValues"),
3500 "content": issue,
3501 });
3502 self.resolve(&item)
3503 }
3504
3505 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3506 ///
3507 /// One spelling of *which membership is this board's*, so the page a read carries and
3508 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3509 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3510 nodes.iter().find(|node| {
3511 node.pointer("/project/number").and_then(Value::as_u64)
3512 == Some(u64::from(self.project_number))
3513 })
3514 }
3515
3516 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3517 ///
3518 /// The recovery read: a page of memberships that holds no entry for this board says
3519 /// nothing about the memberships past it, so the connection is walked to exhaustion
3520 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3521 /// positive answer — the whole connection was read and no entry named this board —
3522 /// rather than a failure, and the walk is held to
3523 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3524 /// with a cursor that does not advance is refused instead of spun on.
3525 async fn board_membership(
3526 &self,
3527 issue: &str,
3528 after: &str,
3529 ) -> Result<Option<Value>, SourceError> {
3530 let mut after = after.to_owned();
3531 loop {
3532 let data = self
3533 .graphql(
3534 graphql::ISSUE_BOARD_ITEMS,
3535 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3536 "nestedFirst":NESTED_PAGE_SIZE}),
3537 )
3538 .await?;
3539 let Some(connection) = data
3540 .pointer("/node/projectItems")
3541 .filter(|value| !value.is_null())
3542 else {
3543 // The id resolved to nothing, or to something with no memberships to walk —
3544 // which is the same answer as a connection holding no entry for this board.
3545 return Ok(None);
3546 };
3547 let nodes = connection
3548 .get("nodes")
3549 .and_then(Value::as_array)
3550 .ok_or_else(|| SourceError::Malformed {
3551 message: "GitHub issue projectItems.nodes is not an array".into(),
3552 })?;
3553 if let Some(held) = self.board_entry(nodes) {
3554 return Ok(Some(held.clone()));
3555 }
3556 let info = connection
3557 .get("pageInfo")
3558 .ok_or_else(|| SourceError::Malformed {
3559 message: "GitHub issue projectItems has no pageInfo".into(),
3560 })?;
3561 let next = required_bool(info, "hasNextPage")?
3562 .then(|| required_str(info, "endCursor"))
3563 .transpose()?;
3564 match next {
3565 Some(next) => {
3566 validate_cursor_progress(Some(&after), next)?;
3567 after = next.to_owned();
3568 }
3569 None => return Ok(None),
3570 }
3571 }
3572 }
3573
3574 /// One page of a board-scoped issue search, and where the next page resumes.
3575 async fn search_page(
3576 &self,
3577 search: &str,
3578 first: u32,
3579 after: Option<&str>,
3580 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3581 let data = self
3582 .graphql(
3583 graphql::SEARCH_ISSUES,
3584 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3585 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3586 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3587 )
3588 .await?;
3589 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3590 message: "GitHub search response has no search connection".into(),
3591 })?;
3592 let mut found = Vec::new();
3593 for node in connection
3594 .get("nodes")
3595 .and_then(Value::as_array)
3596 .ok_or_else(|| SourceError::Malformed {
3597 message: "GitHub search nodes is not an array".into(),
3598 })?
3599 {
3600 if let Some(resolved) = self.resolve_issue(node).await? {
3601 found.push(resolved);
3602 }
3603 }
3604 let info = connection
3605 .get("pageInfo")
3606 .ok_or_else(|| SourceError::Malformed {
3607 message: "GitHub search connection has no pageInfo".into(),
3608 })?;
3609 let next = required_bool(info, "hasNextPage")?
3610 .then(|| required_str(info, "endCursor"))
3611 .transpose()?
3612 .map(str::to_owned);
3613 if let Some(next) = &next {
3614 validate_cursor_progress(after, next)?;
3615 }
3616 Ok((found, next))
3617 }
3618
3619 /// Every issue this board holds, completed with what this run wrote.
3620 ///
3621 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3622 /// is an index and is eventually consistent, so an issue this run created seconds ago
3623 /// can be absent from it, and a project listed straight after being written would
3624 /// otherwise be missing from its own board. What is added back is only what this
3625 /// process itself wrote, out of [`Self::created`], which lives and dies with the
3626 /// process.
3627 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3628 let found = self.searched_issues().await?;
3629 self.completed_with_written(found, |_| true)
3630 }
3631
3632 /// Every issue this board's own search reports, walked to exhaustion, read once per
3633 /// source.
3634 ///
3635 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3636 /// needs it too and the two would otherwise walk the same search twice in one command.
3637 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3638 /// is.
3639 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3640 let cached = self.search_cache()?.clone();
3641 if let Some(held) = cached {
3642 return Ok(held);
3643 }
3644 let mut after: Option<String> = None;
3645 let mut found = Vec::new();
3646 let search = self.board_search(None);
3647 loop {
3648 let (page, next) = self
3649 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3650 .await?;
3651 found.extend(page);
3652 match next {
3653 Some(next) => after = Some(next),
3654 None => break,
3655 }
3656 }
3657 *self.search_cache()? = Some(found.clone());
3658 Ok(found)
3659 }
3660
3661 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3662 fn search_cache(
3663 &self,
3664 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3665 self.search_cache
3666 .lock()
3667 .map_err(|_| SourceError::Unavailable {
3668 message: "this source's view of the board's issues was left inconsistent by an \
3669 earlier failure; next: run the command again"
3670 .into(),
3671 })
3672 }
3673
3674 /// `found`, with everything this run wrote that `keep` accepts and the read did not
3675 /// report.
3676 ///
3677 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3678 /// at all: the search index is behind, and a node read of an item filed moments ago can
3679 /// be too.
3680 fn completed_with_written(
3681 &self,
3682 mut found: Vec<Resolved>,
3683 keep: impl Fn(&Resolved) -> bool,
3684 ) -> Result<Vec<Resolved>, SourceError> {
3685 for own in self.created()?.iter().filter(|own| keep(own)) {
3686 if !found.iter().any(|item| item.id == own.id) {
3687 found.push(own.clone());
3688 }
3689 }
3690 Ok(found)
3691 }
3692
3693 /// What resolving one node id reached.
3694 ///
3695 /// Three answers rather than an `Option`, because a board *draft* is none of the other
3696 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3697 /// is completed by a read of the draft itself rather than reported as nothing.
3698 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3699 let asked = self
3700 .graphql(
3701 graphql::ISSUE,
3702 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3703 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3704 )
3705 .await;
3706 let data = match asked {
3707 Ok(data) => data,
3708 // A string that is not a node id at all is not a failure to report: it is an id
3709 // this board does not hold, which is what every read of one already answers.
3710 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3711 Err(error) => return Err(error),
3712 };
3713 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3714 return Ok(Reached::Nothing);
3715 };
3716 if optional_str(node, "__typename")? == Some("DraftIssue") {
3717 return Ok(Reached::Draft);
3718 }
3719 Ok(match self.resolve_issue(node).await? {
3720 Some(item) => Reached::Held(Box::new(item)),
3721 None => Reached::Nothing,
3722 })
3723 }
3724
3725 /// One item of this board by its own id, whatever kind it is.
3726 ///
3727 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
3728 /// run wrote is read first, because a node read of an item created moments ago can
3729 /// still be behind the board field values written onto it — see [`Self::created`].
3730 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3731 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
3732 return Ok(Some(own.clone()));
3733 }
3734 match self.reach(id).await? {
3735 Reached::Held(item) => Ok(Some(*item)),
3736 Reached::Nothing => Ok(None),
3737 Reached::Draft => self.draft_by_id(id).await,
3738 }
3739 }
3740
3741 /// One board draft by its own id, with the board item it sits in — or `None` when no
3742 /// item of this board is that draft's.
3743 ///
3744 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
3745 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
3746 /// links a draft to one board item, so the page this read carries is the whole of that
3747 /// connection, and a page that reports more than it holds is refused rather than read
3748 /// as an answer about memberships nobody read.
3749 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3750 let data = self
3751 .graphql(
3752 graphql::DRAFT,
3753 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3754 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
3755 )
3756 .await?;
3757 // Gone between the two reads is an answer — the draft is no longer there. Anything
3758 // else than the draft [`Self::reach`] was just told this id is, is not one.
3759 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
3760 return Ok(None);
3761 };
3762 if optional_str(draft, "__typename")? != Some("DraftIssue") {
3763 return Err(SourceError::Malformed {
3764 message: format!(
3765 "GitHub answered {} as a draft and then as something else",
3766 id.0
3767 ),
3768 });
3769 }
3770 if required_str(draft, "id")? != id.0 {
3771 return Err(SourceError::Malformed {
3772 message: format!("GitHub answered a different draft for {}", id.0),
3773 });
3774 }
3775 let memberships = draft
3776 .get("projectV2Items")
3777 .ok_or_else(|| SourceError::Malformed {
3778 message: format!("GitHub draft {} is missing projectV2Items", id.0),
3779 })?;
3780 let nodes = memberships
3781 .get("nodes")
3782 .and_then(Value::as_array)
3783 .ok_or_else(|| SourceError::Malformed {
3784 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
3785 })?;
3786 let info = memberships
3787 .get("pageInfo")
3788 .ok_or_else(|| SourceError::Malformed {
3789 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
3790 })?;
3791 // Read whether or not this board's entry is on the page: a page claiming more than
3792 // the one item GitHub links a draft to is a malformed answer either way.
3793 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
3794 return Err(SourceError::Malformed {
3795 message: format!(
3796 "GitHub draft {} reports more board items than the one GitHub links a draft \
3797 to",
3798 id.0
3799 ),
3800 });
3801 }
3802 if let Some(node) = nodes.first()
3803 && node
3804 .pointer("/project/number")
3805 .and_then(Value::as_u64)
3806 .is_none()
3807 {
3808 return Err(SourceError::Malformed {
3809 message: format!(
3810 "GitHub draft {} board item has no numeric project number",
3811 id.0
3812 ),
3813 });
3814 }
3815 let Some(held) = self.board_entry(nodes) else {
3816 return Ok(None);
3817 };
3818 if required_str(
3819 held.get("project").ok_or_else(|| SourceError::Malformed {
3820 message: format!("GitHub draft {} board item has no project", id.0),
3821 })?,
3822 "id",
3823 )? != self.board_fields().await?.id.as_str()
3824 {
3825 return Ok(None);
3826 }
3827 let item = json!({
3828 "id": required_str(held, "id")?,
3829 "project": held.get("project"),
3830 "fieldValues": held.get("fieldValues"),
3831 "content": draft,
3832 });
3833 self.resolve(&item)
3834 }
3835
3836 /// The board's own id and field definitions, for a write whose item does not carry
3837 /// them — never its items.
3838 ///
3839 /// A board this command has already listed supplies them, since it read them beside its
3840 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
3841 /// is consulted about which items the board holds: see the module documentation for
3842 /// why a question about one known item is answered by reading that item.
3843 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
3844 if let Some(board) = self.board_cache()?.as_ref() {
3845 return Ok(BoardFields {
3846 id: BoardId::parse(&board.id)?,
3847 fields: board.fields.clone(),
3848 });
3849 }
3850 if let Some(held) = self.fields_cache()?.clone() {
3851 return Ok(held);
3852 }
3853 let data = self
3854 .graphql(
3855 graphql::BOARD_FIELDS,
3856 json!({"owner":self.owner,"number":self.project_number,
3857 "nestedFirst":NESTED_PAGE_SIZE}),
3858 )
3859 .await?;
3860 let board = data
3861 .pointer("/boardFields/projectV2")
3862 .filter(|value| !value.is_null())
3863 .ok_or_else(|| SourceError::Refused {
3864 message: format!(
3865 "GitHub project {}/{} was not found or is not visible to the token",
3866 self.owner, self.project_number
3867 ),
3868 })?;
3869 let read = BoardFields {
3870 id: BoardId::parse(required_str(board, "id")?)?,
3871 fields: board.get("fields").cloned().unwrap_or(Value::Null),
3872 };
3873 *self.fields_cache()? = Some(read.clone());
3874 Ok(read)
3875 }
3876
3877 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
3878 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
3879 self.fields_cache
3880 .lock()
3881 .map_err(|_| SourceError::Unavailable {
3882 message: "this source's view of the board's fields was left inconsistent by an \
3883 earlier failure; next: run the command again"
3884 .into(),
3885 })
3886 }
3887
3888 /// What a write to `item` needs of the board, read off that item when it says enough and
3889 /// off [`Self::board_fields`] when it does not.
3890 ///
3891 /// A node read of an item names its board and carries the definition of every field it
3892 /// holds a value of — so an item naming its board, holding a value of the origin field,
3893 /// and, when the write carries a status, holding a `Status` value, needs no read of the
3894 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
3895 /// of may still be on the board, and a view reading it as absent would refuse a write the
3896 /// board can take or skip a field write the board needs, so such an item — and a create,
3897 /// which has no item yet — takes the board's fields from their own read instead.
3898 async fn fields_for(
3899 &self,
3900 item: Option<&Resolved>,
3901 writes_status: bool,
3902 selects_priority: bool,
3903 ) -> Result<BoardFields, SourceError> {
3904 if let Some(item) = item
3905 && let Some(board_id) = item.named_board()
3906 && item.defines(ORIGIN_FIELD)
3907 && (!writes_status || item.defines("Status"))
3908 && (!selects_priority || item.defines(PRIORITY_FIELD))
3909 {
3910 return Ok(BoardFields {
3911 id: board_id,
3912 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
3913 });
3914 }
3915 self.board_fields().await
3916 }
3917
3918 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
3919 /// when that id names nothing here with a sub-issue relationship to walk.
3920 ///
3921 /// `None` and an empty answer are different: `None` is *this is not an issue of this
3922 /// GitHub*, which is what sends a project selector on to be read as a name, and an
3923 /// empty vector is a project that holds nothing.
3924 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
3925 let mut after: Option<String> = None;
3926 let mut children = Vec::new();
3927 loop {
3928 let asked = self
3929 .graphql(
3930 graphql::SUB_ISSUES,
3931 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
3932 "nestedFirst":NESTED_PAGE_SIZE,
3933 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3934 )
3935 .await;
3936 let data = match asked {
3937 Ok(data) => data,
3938 // A string that is not a node id at all is not a failure to report: it is
3939 // the ordinary answer to a selector naming a project by its name.
3940 Err(error) if unresolvable_node(&error) => return Ok(None),
3941 Err(error) => return Err(error),
3942 };
3943 let Some(connection) = data
3944 .pointer("/node/subIssues")
3945 .filter(|value| !value.is_null())
3946 else {
3947 // No such node, or one with no sub-issue relationship — a board draft is
3948 // the one this board can really hold.
3949 return Ok(None);
3950 };
3951 for node in connection
3952 .get("nodes")
3953 .and_then(Value::as_array)
3954 .ok_or_else(|| SourceError::Malformed {
3955 message: "GitHub subIssues.nodes is not an array".into(),
3956 })?
3957 {
3958 if let Some(resolved) = self.resolve_issue(node).await? {
3959 children.push(resolved);
3960 }
3961 }
3962 let info = connection
3963 .get("pageInfo")
3964 .ok_or_else(|| SourceError::Malformed {
3965 message: "GitHub subIssues connection has no pageInfo".into(),
3966 })?;
3967 let next = required_bool(info, "hasNextPage")?
3968 .then(|| required_str(info, "endCursor"))
3969 .transpose()?;
3970 match next {
3971 Some(next) => {
3972 validate_cursor_progress(after.as_deref(), next)?;
3973 after = Some(next.to_owned());
3974 }
3975 None => return Ok(Some(children)),
3976 }
3977 }
3978 }
3979
3980 /// Which issue of this board a project *name* is, or `None` when none is.
3981 ///
3982 /// One bounded query which filters on that name at the server, rather than a walk of
3983 /// every issue the board holds. The name is compared again here: the qualifier narrows
3984 /// what GitHub sends, and this source decides what it names.
3985 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
3986 let search = self.board_search(Some(&title_qualifier(name)));
3987 let (candidates, _) = self.search_page(&search, MAX_PAGE_SIZE, None).await?;
3988 Ok(candidates
3989 .into_iter()
3990 .find(|item| {
3991 item.kind == BoardKind::Work(ItemKind::Project)
3992 && item.title.eq_ignore_ascii_case(name)
3993 })
3994 .map(|item| item.id))
3995 }
3996
3997 /// Everything filed under one project of this board: the sub-issues of the issue that
3998 /// project is.
3999 ///
4000 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4001 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4002 /// gains projects, or as another project gains tasks.
4003 ///
4004 /// A qualified id names the issue and is asked for its sub-issues directly: one
4005 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4006 /// read as a project *name*, which costs the one bounded search
4007 /// [`Self::project_by_name`] makes.
4008 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4009 let (project, children) = match self.sub_issues(selector).await? {
4010 Some(children) => (selector.clone(), children),
4011 None => match self.project_by_name(&selector.0).await? {
4012 Some(project) => {
4013 let children = self.sub_issues(&project).await?.unwrap_or_default();
4014 (project, children)
4015 }
4016 None => return Ok(Vec::new()),
4017 },
4018 };
4019 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4020 }
4021
4022 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4023 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4024 ///
4025 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4026 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4027 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4028 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4029 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4030 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4031 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4032 /// rather than silently narrowing a caller's answer.
4033 ///
4034 /// The instant is written to the second, rounded down, which can only widen what the
4035 /// search returns; confirmation against each candidate's own comments is what makes the
4036 /// answer exact. The search is an index that lags a write by a second or two — the module
4037 /// documentation records it — so a caller that asks again from its last instant should
4038 /// overlap the two by more than that.
4039 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4040 let found = self.searched(&updated_qualifier(since)).await?;
4041 self.completed_with_written(found, |_| true)
4042 }
4043
4044 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4045 /// narrowed by `also`, walked to exhaustion at [`MAX_PAGE_SIZE`].
4046 ///
4047 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4048 /// own record is the fresher of the two.
4049 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4050 let search = self.board_search(Some(also));
4051 let mut after: Option<String> = None;
4052 let mut found = Vec::new();
4053 loop {
4054 let (page, next) = self
4055 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4056 .await?;
4057 found.extend(page);
4058 match next {
4059 Some(next) => after = Some(next),
4060 None => return Ok(found),
4061 }
4062 }
4063 }
4064
4065 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4066 /// without enumerating the board — or `None` for a query carrying none of the three, which
4067 /// keeps the reads it always had.
4068 ///
4069 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4070 /// because it names at most a handful of items. Text and metadata are answered by one
4071 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4072 /// further by `updated:>=` when the query also asks for comment activity, since both
4073 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4074 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4075 ///
4076 /// Completed with what this process wrote, its own record winning over the index's copy
4077 /// of the same item: see [`Self::with_own_writes`].
4078 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4079 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4080 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4081 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4082 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4083 None => qualifiers,
4084 }),
4085 (None, None) => return Ok(None),
4086 };
4087 // A question about comment activity is asked afresh every time, as it always was: it
4088 // is the one a caller polls from one source while waiting for the index, and an
4089 // answer held from the first poll would be the answer to every later one.
4090 let key = query.commented_since.is_none().then(|| asked.key());
4091 let cached = match &key {
4092 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4093 None => None,
4094 };
4095 let found = match cached {
4096 Some(found) => found,
4097 None => {
4098 let found = match &asked {
4099 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4100 Narrowing::Search(also) => self.searched(also).await?,
4101 };
4102 if let Some(key) = key {
4103 self.narrowed_cache()?.insert(key, found.clone());
4104 }
4105 found
4106 }
4107 };
4108 self.with_own_writes(found).map(Some)
4109 }
4110
4111 /// Every item of this board that may carry `origin` — a superset of those that do — found
4112 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4113 ///
4114 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4115 /// which reads the field every carrier holds, whichever release wrote it — and the
4116 /// board-scoped issue search for the same id as a phrase in the body, where this source
4117 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4118 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4119 /// query's, exactly.
4120 ///
4121 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4122 /// already ended is sent its last cursor again, which answers an empty page, so the one
4123 /// document serves every page of either. What the two leave is stated in the module
4124 /// documentation: a carrier another process added within the last second or two, before
4125 /// either index has it.
4126 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4127 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4128 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4129 let mut items_after: Option<String> = None;
4130 let mut search_after: Option<String> = None;
4131 let mut found: Vec<Resolved> = Vec::new();
4132 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4133 if !found.iter().any(|held| held.id == resolved.id) {
4134 found.push(resolved);
4135 }
4136 };
4137 loop {
4138 let data = self
4139 .graphql(
4140 graphql::ORIGIN_LOOKUP,
4141 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4142 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4143 "itemsAfter":items_after,"searchAfter":search_after,
4144 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4145 "duplicates":true}),
4146 )
4147 .await?;
4148 let items = data
4149 .pointer("/originItems/projectV2/items")
4150 .filter(|value| !value.is_null())
4151 .ok_or_else(|| SourceError::Refused {
4152 message: format!(
4153 "GitHub project {}/{} was not found or is not visible to the token",
4154 self.owner, self.project_number
4155 ),
4156 })?;
4157 for item in optional_nodes(Some(items), "project items")?
4158 .into_iter()
4159 .flatten()
4160 {
4161 // The board's own items list its drafts too, and a draft is not an issue: no
4162 // narrowed read answers with one, whatever its origin field holds.
4163 if let Some(resolved) = self.resolve(item)?
4164 && resolved.content_kind == ContentKind::Issue
4165 {
4166 keep(resolved, &mut found);
4167 }
4168 }
4169 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4170 message: "GitHub search response has no search connection".into(),
4171 })?;
4172 for node in optional_nodes(Some(searched), "search")?
4173 .into_iter()
4174 .flatten()
4175 {
4176 if let Some(resolved) = self.resolve_issue(node).await? {
4177 keep(resolved, &mut found);
4178 }
4179 }
4180 let items_next = resumed(items, items_after.as_deref())?;
4181 let search_next = resumed(searched, search_after.as_deref())?;
4182 if !items_next.has_more() && !search_next.has_more() {
4183 return Ok(found);
4184 }
4185 items_after = items_next.cursor();
4186 search_after = search_next.cursor();
4187 }
4188 }
4189
4190 /// `found`, with every item this process created or wrote in its place, and every one of
4191 /// them the read did not report added.
4192 ///
4193 /// This process's own record wins over the read's copy of the same item, because a read
4194 /// of an item written moments ago can still be behind what was written onto it — the
4195 /// origin field included, which is the one a narrowed read is confirmed against — and a
4196 /// read that still names an item under a predicate this process's write moved it out of
4197 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4198 /// last saw the item change, which is what a comment-activity read rules a candidate out
4199 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4200 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4201 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4202 // A board draft is not an issue, so no narrowed read returns one, and this process
4203 // having written one does not make it an answer either.
4204 let own: Vec<Resolved> = self
4205 .created()?
4206 .iter()
4207 .chain(self.updated()?.iter())
4208 .filter(|own| own.content_kind == ContentKind::Issue)
4209 .cloned()
4210 .collect();
4211 for mut own in own {
4212 match found.iter_mut().find(|read| read.id == own.id) {
4213 Some(read) => {
4214 own.updated_at = own.updated_at.max(read.updated_at);
4215 *read = own;
4216 }
4217 None => found.push(own),
4218 }
4219 }
4220 Ok(found)
4221 }
4222
4223 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4224 /// there is no instant to hold it to.
4225 ///
4226 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4227 /// or after the instant moved it there: an issue not updated since holds no such comment,
4228 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4229 /// only as far as the first that matches. A board draft is not an issue and has no
4230 /// comments, so it never matches.
4231 async fn commented_since(
4232 &self,
4233 item: &Resolved,
4234 since: Option<DateTime<Utc>>,
4235 ) -> Result<bool, SourceError> {
4236 let Some(since) = since else {
4237 return Ok(true);
4238 };
4239 if item.content_kind == ContentKind::DraftIssue
4240 || item.updated_at.is_some_and(|updated| updated < since)
4241 {
4242 return Ok(false);
4243 }
4244 let query = TaskQuery {
4245 commented_since: Some(since),
4246 ..TaskQuery::default()
4247 };
4248 let mut after: Option<String> = None;
4249 loop {
4250 let data = self
4251 .graphql(
4252 graphql::ISSUE_COMMENTS,
4253 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4254 )
4255 .await?;
4256 let Some(connection) = data
4257 .get("node")
4258 .filter(|value| !value.is_null())
4259 .and_then(|node| node.get("comments"))
4260 .filter(|value| !value.is_null())
4261 else {
4262 // Removed since the search reported it: no longer an issue with comments.
4263 return Ok(false);
4264 };
4265 let comments = optional_nodes(Some(connection), "issue comments")?
4266 .into_iter()
4267 .flatten()
4268 .map(comment_from)
4269 .collect::<Result<Vec<_>, _>>()?;
4270 if query.comments_match(&comments) {
4271 return Ok(true);
4272 }
4273 match next_cursor(connection)? {
4274 Some(next) => {
4275 validate_cursor_progress(after.as_deref(), &next.0)?;
4276 after = Some(next.0);
4277 }
4278 None => return Ok(false),
4279 }
4280 }
4281 }
4282
4283 /// Every item on the board: the union of both enumerations GitHub offers of one.
4284 ///
4285 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4286 /// board **draft** and reads the board's own fields beside its items, and only the search
4287 /// reports an item that connection is behind on. The module documentation is where the lag and the
4288 /// measurements behind it are written down.
4289 ///
4290 /// A search result is admitted on the same terms as any other issue this source reaches
4291 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4292 /// names *this* board — so an issue the index still believes is here after it was taken
4293 /// off is refused rather than reported.
4294 ///
4295 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4296 /// which is what the cache could otherwise have broken.
4297 async fn board(&self) -> Result<Board, SourceError> {
4298 let cached = self.board_cache()?.clone();
4299 let mut board = match cached {
4300 Some(board) => board,
4301 None => {
4302 let read = self.read_board().await?;
4303 *self.board_cache()? = Some(read.clone());
4304 read
4305 }
4306 };
4307 for held in self.searched_issues().await? {
4308 if !board.items.iter().any(|item| item.id == held.id) {
4309 board.items.push(held);
4310 }
4311 }
4312 for own in self.created()?.iter() {
4313 if !board.items.iter().any(|item| item.id == own.id) {
4314 board.items.push(own.clone());
4315 }
4316 }
4317 Ok(board)
4318 }
4319
4320 /// This process's own view of the board, or the refusal a poisoned lock is.
4321 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4322 self.board_cache
4323 .lock()
4324 .map_err(|_| SourceError::Unavailable {
4325 message: "this source's view of the board was left inconsistent by an earlier \
4326 failure; next: run the command again"
4327 .into(),
4328 })
4329 }
4330
4331 /// Bring this process's own view of the board up to an item it has just written.
4332 ///
4333 /// A created item goes to `created`, which is what completes a board read GitHub's own
4334 /// eventual consistency has left behind. An item that was already there is replaced
4335 /// where it sits, so a second write of it in the same command reads its real parent
4336 /// rather than the one it had before the first write.
4337 ///
4338 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4339 /// that wins: an item this same run created is held in `created` and not in the cached
4340 /// board, and `board` completes the cached board *from* `created`, so replacing only
4341 /// the cached copy of such an item replaces nothing and the read still reports the
4342 /// title it was created with. The search is the third, and it is the one an item the
4343 /// board's own projection is behind on sits in *alone* — which is exactly the item this
4344 /// source is least able to re-read, so leaving it out would put the stale title back on
4345 /// the only items the completion in [`Self::board`] exists for.
4346 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4347 if created {
4348 self.created()?.push(item);
4349 return Ok(());
4350 }
4351 {
4352 let mut own = self.created()?;
4353 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4354 *held = item;
4355 return Ok(());
4356 }
4357 }
4358 {
4359 let mut own = self.updated()?;
4360 match own.iter_mut().find(|held| held.id == item.id) {
4361 Some(held) => *held = item.clone(),
4362 None => own.push(item.clone()),
4363 }
4364 }
4365 if let Some(board) = self.board_cache()?.as_mut()
4366 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4367 {
4368 *held = item.clone();
4369 }
4370 if let Some(found) = self.search_cache()?.as_mut()
4371 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4372 {
4373 *held = item.clone();
4374 }
4375 for found in self.narrowed_cache()?.values_mut() {
4376 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4377 *held = item.clone();
4378 }
4379 }
4380 Ok(())
4381 }
4382
4383 /// Forget one item this process has just deleted, from every half of its own view.
4384 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4385 self.created()?.retain(|own| own.id != *id);
4386 self.updated()?.retain(|own| own.id != *id);
4387 if let Some(board) = self.board_cache()?.as_mut() {
4388 board.items.retain(|item| item.id != *id);
4389 }
4390 if let Some(found) = self.search_cache()?.as_mut() {
4391 found.retain(|item| item.id != *id);
4392 }
4393 for found in self.narrowed_cache()?.values_mut() {
4394 found.retain(|item| item.id != *id);
4395 }
4396 Ok(())
4397 }
4398
4399 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4400 fn narrowed_cache(
4401 &self,
4402 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4403 self.narrowed_cache
4404 .lock()
4405 .map_err(|_| SourceError::Unavailable {
4406 message: "this source's view of a narrowed read was left inconsistent by an \
4407 earlier failure; next: run the command again"
4408 .into(),
4409 })
4410 }
4411
4412 /// Every page of the board, read from GitHub.
4413 async fn read_board(&self) -> Result<Board, SourceError> {
4414 let mut after: Option<String> = None;
4415 let mut items = Vec::new();
4416 let mut board;
4417 loop {
4418 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
4419 for item in page
4420 .pointer("/items/nodes")
4421 .and_then(Value::as_array)
4422 .ok_or_else(|| SourceError::Malformed {
4423 message: "GitHub project items.nodes is not an array".into(),
4424 })?
4425 {
4426 if let Some(resolved) = self.resolve(item)? {
4427 items.push(resolved);
4428 }
4429 }
4430 let info = page
4431 .pointer("/items/pageInfo")
4432 .ok_or_else(|| SourceError::Malformed {
4433 message: "GitHub project items have no pageInfo".into(),
4434 })?;
4435 let has_next = required_bool(info, "hasNextPage")?;
4436 let next = has_next
4437 .then(|| required_str(info, "endCursor"))
4438 .transpose()?;
4439 board = page.clone();
4440 match next {
4441 Some(next) => {
4442 validate_cursor_progress(after.as_deref(), next)?;
4443 after = Some(next.to_owned());
4444 }
4445 None => break,
4446 }
4447 }
4448 Ok(Board {
4449 id: required_str(&board, "id")?.to_owned(),
4450 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4451 items,
4452 })
4453 }
4454
4455 /// The existing items this source has written, for completing a narrowed read that is
4456 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
4457 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4458 self.updated.lock().map_err(|_| SourceError::Unavailable {
4459 message: "this source's record of what it wrote in this run was left inconsistent \
4460 by an earlier failure; next: run the command again"
4461 .into(),
4462 })
4463 }
4464
4465 /// The items this source has created, for completing a board read that is behind.
4466 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4467 self.created.lock().map_err(|_| SourceError::Unavailable {
4468 message: "this source's record of what it created in this run was left \
4469 inconsistent by an earlier failure; next: run the command again"
4470 .into(),
4471 })
4472 }
4473
4474 /// One board item as this source reports it, or `None` for content it ignores.
4475 ///
4476 /// A pull request is neither a project nor a task — it is somebody's change, not a
4477 /// unit of plan — and an item whose content the token cannot see has nothing to
4478 /// report at all.
4479 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
4480 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
4481 message: "GitHub project item is missing content".into(),
4482 })?;
4483 if content.is_null() {
4484 return Ok(None);
4485 }
4486 let content_kind = match required_str(content, "__typename")? {
4487 "Issue" => ContentKind::Issue,
4488 "DraftIssue" => ContentKind::DraftIssue,
4489 _ => return Ok(None),
4490 };
4491 let field_values = item
4492 .get("fieldValues")
4493 .ok_or_else(|| SourceError::Malformed {
4494 message: "GitHub project item is missing fieldValues".into(),
4495 })?;
4496 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
4497 let nodes = field_values
4498 .get("nodes")
4499 .and_then(Value::as_array)
4500 .ok_or_else(|| SourceError::Malformed {
4501 message: "GitHub project item fieldValues.nodes is not an array".into(),
4502 })?;
4503 if let Some(labels) = content.get("labels") {
4504 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
4505 }
4506 let raw_body = optional_str(content, "body")?.map(str::to_owned);
4507 let (body, slot) = metadata_body(raw_body.clone())?;
4508 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
4509 .map(|id| NativeId(id.to_owned()));
4510 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
4511 // to read one from; it is a task, and never a project.
4512 let sub_issues = match content_kind {
4513 ContentKind::Issue => sub_issue_total(content)?,
4514 ContentKind::DraftIssue => 0,
4515 };
4516 let content_id = required_str(content, "id")?;
4517 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
4518 message: format!("GitHub issue {content_id}: {message}"),
4519 })?;
4520 let raw_title = required_str(content, "title")?;
4521 // The design prefix is read *first*, before either of the two rules that separate
4522 // a project from a task. A document is not work whatever sub-issues it has and
4523 // whatever marker it carries, and reading the prefix later would make a design
4524 // issue with none of either an empty project.
4525 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
4526 BoardKind::Document
4527 } else if parent.is_some() {
4528 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
4529 // under a project is that project's task even when it has sub-issues of its
4530 // own.
4531 BoardKind::Work(ItemKind::Task)
4532 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
4533 BoardKind::Work(ItemKind::Project)
4534 } else {
4535 BoardKind::Work(ItemKind::Task)
4536 };
4537 // The title a person wrote, which for a document is the one without the prefix —
4538 // the same way `content` above is the body without this source's metadata slot.
4539 let title = match kind {
4540 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
4541 BoardKind::Work(_) => raw_title.to_owned(),
4542 };
4543 let own_repository = content
4544 .pointer("/repository/nameWithOwner")
4545 .and_then(Value::as_str)
4546 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
4547 .transpose()
4548 .map_err(|message| SourceError::Malformed { message })?;
4549 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
4550 Repository::from_metadata(&slot)
4551 .map_err(|message| SourceError::Malformed { message })?
4552 } else {
4553 own_repository.clone().into_iter().collect()
4554 };
4555 let id = NativeId(content_id.to_owned());
4556 // Read only for a task, because only a task has either list: a project or a
4557 // document holding one of these keys holds nothing this source reports, and the
4558 // keys are left out of its caller-visible metadata all the same.
4559 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
4560 let listed = |key: &str| {
4561 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
4562 .map_err(|message| SourceError::Malformed { message })
4563 };
4564 (
4565 listed(TaskRef::DELIVERS_KEY)?,
4566 listed(TaskRef::DELIVERED_BY_KEY)?,
4567 )
4568 } else {
4569 (Vec::new(), Vec::new())
4570 };
4571 let (option, closed, reason) = Self::status_parts(nodes, content)?;
4572 let priority = self.held_priority(nodes)?;
4573 Ok(Some(Resolved {
4574 item_id: required_str(item, "id")?.to_owned(),
4575 id,
4576 content_kind,
4577 kind,
4578 title,
4579 body: body.filter(|value| !value.is_empty()),
4580 raw_body,
4581 status: self.statuses.status(option, closed, reason),
4582 option: option.map(str::to_owned),
4583 priority,
4584 closed,
4585 delivers,
4586 delivered_by,
4587 labels: labels(content)?,
4588 parent,
4589 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
4590 number: match content_kind {
4591 ContentKind::Issue => Some(issue_number(content)?),
4592 // A draft is filed in no repository, so nothing ever numbered it:
4593 // `DraftIssue` declares no `number` at all, exactly as it declares no
4594 // `subIssuesSummary` the branch above reads.
4595 ContentKind::DraftIssue => None,
4596 },
4597 url: optional_str(content, "url")?.map(str::to_owned),
4598 created_at: optional_time(content, "createdAt")?,
4599 updated_at: optional_time(content, "updatedAt")?,
4600 own_repository,
4601 repositories,
4602 slot,
4603 // Present when the item was reached through its own issue, whose board entry
4604 // names the board; a read of the board's own items has the board already. An
4605 // empty id names nothing a field write could address, so it is read as absent and
4606 // the write goes back to reading the board.
4607 board_id: item
4608 .pointer("/project/id")
4609 .and_then(Value::as_str)
4610 .filter(|id| !id.is_empty())
4611 .map(str::to_owned),
4612 fields: field_definitions(nodes),
4613 }))
4614 }
4615
4616 /// What one board item's `Priority` field says, through this instance's mapping.
4617 ///
4618 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
4619 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
4620 /// option the mapping does not name is kept as itself — never read as a level or as
4621 /// `none` — for a read of the task to report by name.
4622 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
4623 let Some(mapping) = &self.priorities else {
4624 return Ok(HeldPriority::Read(Priority::None));
4625 };
4626 // A value of the field that names no option — a text field someone called `Priority` —
4627 // is malformed rather than `none`: reading it as no priority would let the next copy
4628 // clear one a person set.
4629 let Some(option) = field_values
4630 .iter()
4631 .find(|value| {
4632 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
4633 })
4634 .map(|value| required_str(value, "name"))
4635 .transpose()?
4636 else {
4637 return Ok(HeldPriority::Read(Priority::None));
4638 };
4639 Ok(mapping.priority_of(option).map_or_else(
4640 || HeldPriority::Unmapped(option.to_owned()),
4641 HeldPriority::Read,
4642 ))
4643 }
4644
4645 /// What one board item's status is read from: its `Status` option, whether its issue
4646 /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
4647 /// three into the status it reports.
4648 fn status_parts<'a>(
4649 field_values: &'a [Value],
4650 content: &'a Value,
4651 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
4652 let option = field_values
4653 .iter()
4654 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
4655 .map(|value| required_str(value, "name"))
4656 .transpose()?;
4657 let closed = optional_str(content, "state")? == Some("CLOSED");
4658 Ok((option, closed, optional_str(content, "stateReason")?))
4659 }
4660
4661 /// The board Status option this write selects, or the refusal that says why not.
4662 ///
4663 /// The mapped option is required for both open and terminal targets. A terminal write
4664 /// validates it before changing either representation, so it can never fall back to
4665 /// closing an issue whose board cannot display the matching status.
4666 ///
4667 /// Answers the field's id, the option's id, and the option's name as the board spells
4668 /// it — which is the name a read of the item reports once it sits there.
4669 fn column_for(
4670 &self,
4671 fields: &Value,
4672 status: &Status,
4673 target: &StatusTarget,
4674 ) -> Result<Option<(String, String, String)>, SourceError> {
4675 let wanted = match target {
4676 StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
4677 StatusTarget::Disabled => return Ok(None),
4678 };
4679 let missing = |detail: &str| SourceError::Refused {
4680 message: format!(
4681 "status {} of source {} needs the board Status option {wanted:?}, and {detail}; add that option to the board, or point status_mapping.{} of this source at one it has",
4682 category_name(status.category),
4683 self.name,
4684 category_name(status.category)
4685 ),
4686 };
4687 let Some(field) = Board::field(fields, "Status")? else {
4688 return Err(missing("this board has no Status field"));
4689 };
4690 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4691 return Err(missing(
4692 "this board's Status field is not a single-select field",
4693 ));
4694 }
4695 let option = field
4696 .get("options")
4697 .and_then(Value::as_array)
4698 .and_then(|options| {
4699 options.iter().find(|option| {
4700 option
4701 .get("name")
4702 .and_then(Value::as_str)
4703 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4704 })
4705 });
4706 match option {
4707 None => Err(missing("this board does not have it")),
4708 Some(option) => Ok(Some((
4709 required_str(field, "id")?.to_owned(),
4710 required_str(option, "id")?.to_owned(),
4711 required_str(option, "name")?.to_owned(),
4712 ))),
4713 }
4714 }
4715
4716 /// The refusal a status that closes an issue is answered with over a board draft.
4717 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
4718 SourceError::Refused {
4719 message: format!(
4720 "status {} of source {} closes the item's issue, and GitHub draft items have \
4721 no open or closed state",
4722 category_name(category),
4723 self.name
4724 ),
4725 }
4726 }
4727
4728 /// What a status write to one item needs of the board: the board's id and the
4729 /// definition of its `Status` field, read off the item when the item says both.
4730 ///
4731 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
4732 /// and its `Status` value carries that field's definition, options and all. An item that
4733 /// does not say — no board id, or no `Status` value to read the field off — takes them
4734 /// from [`Self::board_fields`], which reads no item.
4735 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
4736 if item.defines("Status")
4737 && let Some(board_id) = item.named_board()
4738 {
4739 return Ok(BoardFields {
4740 id: board_id,
4741 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4742 });
4743 }
4744 self.board_fields().await
4745 }
4746
4747 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
4748 async fn set_status(
4749 &self,
4750 id: &NativeId,
4751 category: StatusCategory,
4752 ) -> Result<Option<Status>, SourceError> {
4753 // Refused before anything is read, in the words a write of the same status is.
4754 let target = self.resolved_target(category)?;
4755 let Some(mut item) = self
4756 .item_by_id(id)
4757 .await?
4758 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4759 else {
4760 return Ok(None);
4761 };
4762 let board = self.status_board(&item).await?;
4763 let wanted = Status {
4764 category,
4765 name: category_name(category).to_owned(),
4766 };
4767 let (field, option, name) = self
4768 .column_for(&board.fields, &wanted, &target)?
4769 .ok_or_else(|| SourceError::Malformed {
4770 message: format!(
4771 "status {} of source {} names no board Status option",
4772 category_name(category),
4773 self.name
4774 ),
4775 })?;
4776 match &target {
4777 StatusTarget::Terminal(_, reason) => {
4778 if item.content_kind == ContentKind::DraftIssue {
4779 return Err(self.closes_a_draft(category));
4780 }
4781 self.set_item_field(
4782 board.id.as_str(),
4783 &item.item_id,
4784 &field,
4785 json!({"singleSelectOptionId": option}),
4786 )
4787 .await?;
4788 self.update_content(
4789 ContentKind::Issue,
4790 &item.id,
4791 json!({"stateInput": state_input(Some(&target))}),
4792 )
4793 .await?;
4794 item.closed = true;
4795 item.status = self
4796 .statuses
4797 .status(Some(&name), true, Some(reason.reason()));
4798 item.option = Some(name);
4799 }
4800 StatusTarget::Column(_) => {
4801 // An option is what an open item's status is, so a closed issue is reopened
4802 // first — sitting closed in the column, it would read back as closed. A draft has
4803 // no state to reopen.
4804 if item.content_kind == ContentKind::Issue && item.closed {
4805 self.update_content(
4806 ContentKind::Issue,
4807 &item.id,
4808 json!({"stateInput": state_input(Some(&target))}),
4809 )
4810 .await?;
4811 item.closed = false;
4812 }
4813 self.set_item_field(
4814 board.id.as_str(),
4815 &item.item_id,
4816 &field,
4817 json!({"singleSelectOptionId": option}),
4818 )
4819 .await?;
4820 item.status = self.statuses.status(Some(&name), false, None);
4821 item.option = Some(name);
4822 }
4823 StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
4824 }
4825 let status = item.status.clone();
4826 self.remember_written(item, false)?;
4827 Ok(Some(status))
4828 }
4829
4830 /// Replace one task's `delivered_by` and nothing else; see
4831 /// [`TaskSource::set_delivered_by`].
4832 ///
4833 /// One update of the body, which differs from the body GitHub holds only inside the
4834 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
4835 async fn replace_delivered_by(
4836 &self,
4837 id: &NativeId,
4838 delivered_by: &[TaskRef],
4839 ) -> Result<Option<()>, SourceError> {
4840 let entries = TaskRef::listed(
4841 TaskRef::DELIVERED_BY_KEY,
4842 id,
4843 Some(&self.name),
4844 delivered_by.to_vec(),
4845 )
4846 .map_err(|message| SourceError::Refused { message })?;
4847 let Some(mut item) = self
4848 .item_by_id(id)
4849 .await?
4850 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4851 else {
4852 return Ok(None);
4853 };
4854 let mut slot = item.slot.clone();
4855 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
4856 self.write_slot(&mut item, &slot).await?;
4857 item.delivered_by = entries;
4858 self.remember_written(item, false)?;
4859 Ok(Some(()))
4860 }
4861
4862 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
4863 /// see [`TaskSource::set_task_metadata`].
4864 ///
4865 /// `None` when this board holds no item by that id, or holds one of another kind. The
4866 /// answer is the item as this source now reads it, so what a caller is told the key
4867 /// holds is what the slot holds.
4868 ///
4869 /// A key already holding the value is answered without a write, compared as JSON rather
4870 /// than as the body's bytes: a slot a person spelled with other whitespace would
4871 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
4872 async fn set_slot_key(
4873 &self,
4874 id: &NativeId,
4875 kind: BoardKind,
4876 key: &MetadataKey,
4877 value: &Value,
4878 ) -> Result<Option<Resolved>, SourceError> {
4879 let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
4880 return Ok(None);
4881 };
4882 if item.slot.get(key.as_str()) == Some(value) {
4883 return Ok(Some(item));
4884 }
4885 let mut slot = item.slot.clone();
4886 slot.insert(key.as_str().to_owned(), value.clone());
4887 self.write_slot(&mut item, &slot).await?;
4888 self.remember_written(item.clone(), false)?;
4889 Ok(Some(item))
4890 }
4891
4892 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
4893 /// `item` up to what that write left.
4894 ///
4895 /// The body sent differs from the body GitHub holds only inside the slot — see
4896 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
4897 /// the mutation the item's content takes, so a board draft's body is written with
4898 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
4899 async fn write_slot(
4900 &self,
4901 item: &mut Resolved,
4902 slot: &BTreeMap<String, Value>,
4903 ) -> Result<(), SourceError> {
4904 let held = item.raw_body.clone().unwrap_or_default();
4905 let body = with_slot(&held, slot)?;
4906 if body != held {
4907 self.update_content(item.content_kind, &item.id, json!({"body": body}))
4908 .await?;
4909 }
4910 let (visible, slot) = metadata_body(Some(body.clone()))?;
4911 item.body = visible.filter(|value| !value.is_empty());
4912 item.raw_body = Some(body);
4913 item.slot = slot;
4914 Ok(())
4915 }
4916
4917 /// This instance's target for a category, refusing one it has disabled.
4918 ///
4919 /// Nothing here mutates the board's option set to make room for a status. GitHub
4920 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
4921 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
4922 /// field and every item's status.
4923 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
4924 let target = self.statuses.target(category).clone();
4925 if target != StatusTarget::Disabled {
4926 return Ok(target);
4927 }
4928 Err(SourceError::Refused {
4929 message: if category == StatusCategory::Draft {
4930 format!(
4931 "status draft is disabled for source {}: draft is incompatible with this \
4932 integration because GitHub draft issues cannot have sub-issues, and this \
4933 source stores a project's tasks as its issue's sub-issues",
4934 self.name
4935 )
4936 } else if category == StatusCategory::Unknown {
4937 format!(
4938 "status {} is disabled for source {}; set status_mapping.{} of this source \
4939 to one board Status option name; every word classified unknown is written \
4940 to that one option",
4941 category_name(category),
4942 self.name,
4943 category_name(category)
4944 )
4945 } else {
4946 format!(
4947 "status {} is disabled for source {}; set status_mapping.{} of this source \
4948 to a board Status option name",
4949 category_name(category),
4950 self.name,
4951 category_name(category)
4952 )
4953 },
4954 })
4955 }
4956
4957 /// What writing `priority` does to one item's `Priority` field on this board, or the
4958 /// refusal naming what the board lacks.
4959 ///
4960 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
4961 /// none already, or of an item not created yet. Every other priority selects the option
4962 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
4963 /// without that option, is refused rather than given one: reads and writes never create
4964 /// a field or an option.
4965 fn priority_write(
4966 &self,
4967 fields: &Value,
4968 existing: Option<&Resolved>,
4969 priority: Priority,
4970 ) -> Result<Option<PriorityWrite>, SourceError> {
4971 let Some(mapping) = &self.priorities else {
4972 return Err(self.holds_no_priority());
4973 };
4974 let Some(wanted) = mapping.option(priority) else {
4975 if !existing.is_some_and(Resolved::holds_priority) {
4976 return Ok(None);
4977 }
4978 let field =
4979 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
4980 message: format!(
4981 "an item holding a {PRIORITY_FIELD} value was read without that field"
4982 ),
4983 })?;
4984 return Ok(Some(PriorityWrite::Clear {
4985 field: required_str(field, "id")?.to_owned(),
4986 }));
4987 };
4988 let missing = |detail: &str| SourceError::Refused {
4989 message: format!(
4990 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
4991 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
4992 it, or point priority_mapping.{priority} of this source at an option the board \
4993 has",
4994 self.name, self.name
4995 ),
4996 };
4997 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
4998 return Err(missing(&format!(
4999 "this board has no {PRIORITY_FIELD} field"
5000 )));
5001 };
5002 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5003 return Err(missing(&format!(
5004 "this board's {PRIORITY_FIELD} field is not a single-select field"
5005 )));
5006 }
5007 // An options list that is absent or not a list is an answer this source cannot read,
5008 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5009 let option = field
5010 .get("options")
5011 .and_then(Value::as_array)
5012 .ok_or_else(|| SourceError::Malformed {
5013 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5014 })?
5015 .iter()
5016 .find(|option| {
5017 option
5018 .get("name")
5019 .and_then(Value::as_str)
5020 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5021 })
5022 .ok_or_else(|| missing("this board does not have it"))?;
5023 Ok(Some(PriorityWrite::Select {
5024 field: required_str(field, "id")?.to_owned(),
5025 option: required_str(option, "id")?.to_owned(),
5026 }))
5027 }
5028
5029 /// Apply one priority write to one board item.
5030 async fn write_priority(
5031 &self,
5032 board_id: &str,
5033 item_id: &str,
5034 write: &PriorityWrite,
5035 ) -> Result<(), SourceError> {
5036 match write {
5037 PriorityWrite::Select { field, option } => {
5038 self.set_item_field(
5039 board_id,
5040 item_id,
5041 field,
5042 json!({"singleSelectOptionId": option}),
5043 )
5044 .await
5045 }
5046 PriorityWrite::Clear { field } => {
5047 let data = self
5048 .graphql(
5049 graphql::CLEAR_FIELD,
5050 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field}}),
5051 )
5052 .await?;
5053 let returned = data
5054 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5055 .ok_or_else(|| SourceError::Malformed {
5056 message: "GitHub field clear returned no project item".into(),
5057 })?;
5058 if required_str(returned, "id")? != item_id {
5059 return Err(SourceError::Malformed {
5060 message: "GitHub field clear returned the wrong project item".into(),
5061 });
5062 }
5063 Ok(())
5064 }
5065 }
5066 }
5067
5068 /// The refusal a priority is answered with by an instance configured with no
5069 /// `priority_mapping`, which holds none.
5070 fn holds_no_priority(&self) -> SourceError {
5071 SourceError::Refused {
5072 message: format!(
5073 "source {} holds no task priority: its configuration sets no priority_mapping; \
5074 next: set priority_mapping on this source, then run `onetaskgraph sources \
5075 fields {} --apply` to set its board up",
5076 self.name, self.name
5077 ),
5078 }
5079 }
5080
5081 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5082 ///
5083 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5084 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5085 async fn set_priority(
5086 &self,
5087 id: &NativeId,
5088 priority: Priority,
5089 ) -> Result<Option<Priority>, SourceError> {
5090 if self.priorities.is_none() {
5091 return Err(self.holds_no_priority());
5092 }
5093 let Some(item) = self
5094 .item_by_id(id)
5095 .await?
5096 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5097 else {
5098 return Ok(None);
5099 };
5100 if priority == Priority::None && !item.holds_priority() {
5101 return Ok(Some(priority));
5102 }
5103 // The item's own read carries the field's definition whenever it holds a value of
5104 // it, which a clear always does; a select onto an item holding none reads the board.
5105 let board = match item.named_board() {
5106 Some(id) if item.defines(PRIORITY_FIELD) => BoardFields {
5107 id,
5108 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5109 },
5110 _ => self.board_fields().await?,
5111 };
5112 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5113 return Ok(Some(priority));
5114 };
5115 self.write_priority(board.id.as_str(), &item.item_id, &write)
5116 .await?;
5117 // Read back rather than echoed: the answer is what the board now holds, read by the
5118 // item's own id — strongly consistent, unlike a search — and past what this run
5119 // remembers writing, so a write the board did not keep is reported as it stands.
5120 let read = match self.reach(id).await? {
5121 Reached::Held(item) => Some(*item),
5122 Reached::Draft => self.draft_by_id(id).await?,
5123 Reached::Nothing => None,
5124 }
5125 .ok_or_else(|| SourceError::Malformed {
5126 message: format!("task {id} was written and then could not be read back"),
5127 })?;
5128 let answer = read.task()?.priority;
5129 self.remember_written(read, false)?;
5130 Ok(Some(answer))
5131 }
5132
5133 /// Replace one task's visible body and nothing else; see
5134 /// [`TaskSource::set_task_content`].
5135 ///
5136 /// One update of the body, which differs from the body GitHub holds only outside the
5137 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5138 /// this source keeps there reads back as it was. A body that would not change is not
5139 /// sent at all.
5140 async fn replace_content(
5141 &self,
5142 id: &NativeId,
5143 content: &str,
5144 ) -> Result<Option<()>, SourceError> {
5145 let Some(mut item) = self
5146 .item_by_id(id)
5147 .await?
5148 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5149 else {
5150 return Ok(None);
5151 };
5152 let held = item.raw_body.clone().unwrap_or_default();
5153 let body = with_content(&held, content)?;
5154 // Checked before anything is sent: content ending in what this source reads as its own
5155 // metadata slot would read back as metadata rather than as the content it was.
5156 let (visible, slot) = metadata_body(Some(body.clone()))?;
5157 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5158 return Err(SourceError::Refused {
5159 message: format!(
5160 "this content ends in what source {} reads as its own metadata slot \
5161 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5162 as content; next: remove that trailing block from the content",
5163 self.name
5164 ),
5165 });
5166 }
5167 if body != held {
5168 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5169 .await?;
5170 }
5171 item.body = visible.filter(|value| !value.is_empty());
5172 item.raw_body = Some(body);
5173 item.slot = slot;
5174 self.remember_written(item, false)?;
5175 Ok(Some(()))
5176 }
5177
5178 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5179 ///
5180 /// One read of the item, and then only what differs from it: at most one `updateIssue`
5181 /// carrying the title, the body — visible content and metadata slot together — and a
5182 /// state change, at most one `Status` option write and one `Priority` field write, and the
5183 /// `blockedBy` additions and removals the named edges differ by. A terminal status selects
5184 /// its option and then closes, as a whole write does; an open one reopens and then selects
5185 /// its option, as [`Self::set_status`] does. The origin field is never written: an update
5186 /// is of an item that already exists, whose origin is what it is.
5187 ///
5188 /// The task answered is the item as those writes left it, built from the read and what was
5189 /// sent rather than read again — the same record a later read in this run answers from.
5190 async fn targeted_update(
5191 &self,
5192 id: &NativeId,
5193 update: &TaskUpdate,
5194 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5195 // Everything this source can refuse without reading the item is refused first, in the
5196 // words a whole write of the same fields is refused with.
5197 update.consistent()?;
5198 if update
5199 .title
5200 .as_deref()
5201 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5202 {
5203 return Err(SourceError::Refused {
5204 message: format!(
5205 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5206 spells a document, so it would read back as one rather than as a task; \
5207 retitle it",
5208 self.name
5209 ),
5210 });
5211 }
5212 if let Some(delivers) = &update.delivers {
5213 TaskRef::listed(
5214 TaskRef::DELIVERS_KEY,
5215 id,
5216 Some(&self.name),
5217 delivers.clone(),
5218 )
5219 .map_err(|message| SourceError::Refused { message })?;
5220 }
5221 if self.priorities.is_none()
5222 && update
5223 .priority
5224 .is_some_and(|priority| priority != Priority::None)
5225 {
5226 return Err(self.holds_no_priority());
5227 }
5228 let target = update
5229 .status
5230 .as_ref()
5231 .map(|status| self.resolved_target(status.category))
5232 .transpose()?;
5233 let Some(mut item) = self
5234 .item_by_id(id)
5235 .await?
5236 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5237 else {
5238 return Ok(None);
5239 };
5240 let before = item.task()?;
5241
5242 let mut status_move = None;
5243 if let (Some(status), Some(target)) = (&update.status, target) {
5244 let board = self.status_board(&item).await?;
5245 let (field, option, name) = self
5246 .column_for(&board.fields, status, &target)?
5247 .ok_or_else(|| SourceError::Malformed {
5248 message: format!(
5249 "status {} of source {} names no board Status option",
5250 category_name(status.category),
5251 self.name
5252 ),
5253 })?;
5254 let terminal = matches!(target, StatusTarget::Terminal(_, _));
5255 if terminal && item.content_kind == ContentKind::DraftIssue {
5256 return Err(self.closes_a_draft(status.category));
5257 }
5258 let landed = match &target {
5259 StatusTarget::Terminal(_, reason) => {
5260 self.statuses
5261 .status(Some(&name), true, Some(reason.reason()))
5262 }
5263 _ => self.statuses.status(Some(&name), false, None),
5264 };
5265 let option_moves = item
5266 .option
5267 .as_deref()
5268 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5269 let state_moves = item.content_kind == ContentKind::Issue
5270 && (item.closed != terminal || (terminal && item.status != landed));
5271 if let Some(moves) = Moves::of(option_moves, state_moves) {
5272 status_move = Some(StatusMove {
5273 board: board.id,
5274 field,
5275 option,
5276 name,
5277 target,
5278 landed,
5279 moves,
5280 });
5281 }
5282 }
5283
5284 let mut priority_move = None;
5285 if let Some(priority) = update.priority
5286 && self.priorities.is_some()
5287 && item.priority != HeldPriority::Read(priority)
5288 {
5289 let board = match item.named_board() {
5290 Some(board) if item.defines(PRIORITY_FIELD) => BoardFields {
5291 id: board,
5292 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5293 },
5294 _ => self.board_fields().await?,
5295 };
5296 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5297 priority_move = Some((board.id, write, priority));
5298 }
5299 }
5300
5301 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5302 // recorded in the slot, and the slot travels in the one body update below.
5303 let edges = match &update.depends_on {
5304 Some(edges) => Some(
5305 self.partition_edges(BoardKind::Work(ItemKind::Task), item.content_kind, edges)
5306 .await?,
5307 ),
5308 None => None,
5309 };
5310
5311 let mut slot = item.slot.clone();
5312 for (key, value) in &update.metadata_set {
5313 slot.insert(key.as_str().to_owned(), value.clone());
5314 }
5315 for key in &update.metadata_remove {
5316 slot.remove(key.as_str());
5317 }
5318 if let Some(delivers) = &update.delivers {
5319 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
5320 }
5321 if let Some((_, recorded)) = &edges {
5322 record_edges(&mut slot, recorded);
5323 }
5324 let held = item.raw_body.clone().unwrap_or_default();
5325 let content = match &update.content {
5326 Some(content) => with_content(&held, content)?,
5327 None => held.clone(),
5328 };
5329 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
5330 // the body's bytes, as a metadata write compares it: a slot a person spelled with
5331 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
5332 let body = if slot == item.slot {
5333 content
5334 } else {
5335 with_slot(&content, &slot)?
5336 };
5337 // Checked before anything is sent, as a content write checks it: content ending in
5338 // what this source reads as its own slot would read back as metadata.
5339 let (visible, read) = metadata_body(Some(body.clone()))?;
5340 let wanted = update.content.as_deref().or(item.body.as_deref());
5341 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
5342 return Err(SourceError::Refused {
5343 message: format!(
5344 "this content ends in what source {} reads as its own metadata slot \
5345 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5346 as content; next: remove that trailing block from the content",
5347 self.name
5348 ),
5349 });
5350 }
5351 let recorded_moves =
5352 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
5353
5354 // One `updateIssue` carries all three, because every mutation spends the secondary
5355 // limiter and the title, body and state are one mutation's inputs.
5356 let mut fields = serde_json::Map::new();
5357 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
5358 fields.insert("title".to_owned(), json!(title));
5359 }
5360 if body != held {
5361 fields.insert("body".to_owned(), json!(body));
5362 }
5363 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
5364 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
5365 }
5366 let terminal = status_move
5367 .as_ref()
5368 .is_some_and(|moving| matches!(moving.target, StatusTarget::Terminal(_, _)));
5369 // A terminal option is selected before the issue closes, so a close never lands on an
5370 // item whose board cannot show it; an open one after the issue reopens.
5371 if terminal {
5372 self.select_option(&item, status_move.as_ref()).await?;
5373 }
5374 if !fields.is_empty() {
5375 self.update_content(item.content_kind, &item.id, Value::Object(fields))
5376 .await?;
5377 }
5378 if !terminal {
5379 self.select_option(&item, status_move.as_ref()).await?;
5380 }
5381 if let Some((board, write, _)) = &priority_move {
5382 self.write_priority(board.as_str(), &item.item_id, write)
5383 .await?;
5384 }
5385 let mut blocked_by_moved = false;
5386 if let Some((native, _)) = &edges
5387 && item.content_kind == ContentKind::Issue
5388 {
5389 blocked_by_moved = self
5390 .reconcile_blocked_by(&item.id, native, Issue::Existing)
5391 .await?;
5392 }
5393
5394 if let Some(title) = &update.title {
5395 item.title.clone_from(title);
5396 }
5397 item.body = visible.filter(|value| !value.is_empty());
5398 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
5399 item.slot = slot;
5400 if let Some(delivers) = &update.delivers {
5401 item.delivers.clone_from(delivers);
5402 }
5403 if let Some(moving) = status_move {
5404 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
5405 && item.content_kind == ContentKind::Issue;
5406 item.status = moving.landed;
5407 item.option = Some(moving.name);
5408 }
5409 if let Some((_, _, priority)) = priority_move {
5410 item.priority = HeldPriority::Read(priority);
5411 }
5412 let task = item.task()?;
5413 let mut written = update.changed(&before, &task);
5414 if blocked_by_moved || recorded_moves {
5415 written.insert(UpdatedField::DependsOn);
5416 }
5417 self.remember_written(item, false)?;
5418 Ok(Some(TaskUpdateOutcome {
5419 task,
5420 written,
5421 delivers_before: before.delivers,
5422 }))
5423 }
5424
5425 /// Select the `Status` option one targeted update moves an item to, when it moves it.
5426 async fn select_option(
5427 &self,
5428 item: &Resolved,
5429 moving: Option<&StatusMove>,
5430 ) -> Result<(), SourceError> {
5431 let Some(moving) = moving.filter(|moving| moving.moves.option()) else {
5432 return Ok(());
5433 };
5434 self.set_item_field(
5435 moving.board.as_str(),
5436 &item.item_id,
5437 &moving.field,
5438 json!({"singleSelectOptionId": moving.option}),
5439 )
5440 .await
5441 }
5442
5443 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
5444 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
5445 ///
5446 /// One update of the body: the content outside the slot, and inside it that one entry,
5447 /// every other entry kept as it was. This source keeps no template answers — an issue has
5448 /// no room beside itself that is not its body, and answers written there would duplicate
5449 /// what the content already says and count against GitHub's body limit — so `answers`
5450 /// reaches nothing here. A body that would not change is not sent at all.
5451 async fn replace_rendering(
5452 &self,
5453 id: &NativeId,
5454 kind: BoardKind,
5455 content: &str,
5456 provenance: &Value,
5457 ) -> Result<Option<()>, SourceError> {
5458 let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
5459 return Ok(None);
5460 };
5461 let held = item.raw_body.clone().unwrap_or_default();
5462 let mut slot = item.slot.clone();
5463 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
5464 let body = with_slot(&with_content(&held, content)?, &slot)?;
5465 // Checked before anything is sent, as a content write checks it.
5466 let (visible, read) = metadata_body(Some(body.clone()))?;
5467 if visible.as_deref().unwrap_or_default() != content || read != slot {
5468 return Err(SourceError::Refused {
5469 message: format!(
5470 "this content ends in what source {} reads as its own metadata slot \
5471 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5472 as content; next: remove that trailing block from the template",
5473 self.name
5474 ),
5475 });
5476 }
5477 if body != held {
5478 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5479 .await?;
5480 }
5481 item.body = visible.filter(|value| !value.is_empty());
5482 item.raw_body = Some(body);
5483 item.slot = read;
5484 self.remember_written(item, false)?;
5485 Ok(Some(()))
5486 }
5487
5488 async fn set_item_field(
5489 &self,
5490 board_id: &str,
5491 item_id: &str,
5492 field_id: &str,
5493 value: Value,
5494 ) -> Result<(), SourceError> {
5495 let data = self
5496 .graphql(
5497 graphql::UPDATE_FIELD,
5498 json!({"input":{
5499 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
5500 }}),
5501 )
5502 .await?;
5503 let returned = data
5504 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
5505 .ok_or_else(|| SourceError::Malformed {
5506 message: "GitHub field update returned no project item".into(),
5507 })?;
5508 if required_str(returned, "id")? != item_id {
5509 return Err(SourceError::Malformed {
5510 message: "GitHub field update returned the wrong project item".into(),
5511 });
5512 }
5513 Ok(())
5514 }
5515
5516 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
5517 let mut after: Option<String> = None;
5518 let mut ids = Vec::new();
5519 loop {
5520 let data = self
5521 .graphql(
5522 graphql::ISSUE_DEPENDENCIES,
5523 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
5524 )
5525 .await?;
5526 let connection =
5527 data.pointer("/node/blockedBy")
5528 .ok_or_else(|| SourceError::Malformed {
5529 message: "GitHub dependency response has no blockedBy connection".into(),
5530 })?;
5531 ids.extend(
5532 connection
5533 .get("nodes")
5534 .and_then(Value::as_array)
5535 .ok_or_else(|| SourceError::Malformed {
5536 message: "GitHub dependency response nodes is not an array".into(),
5537 })?
5538 .iter()
5539 .map(|value| required_str(value, "id").map(str::to_owned))
5540 .collect::<Result<Vec<_>, _>>()?,
5541 );
5542 let next = next_cursor(connection)?;
5543 if let Some(next) = &next {
5544 validate_cursor_progress(after.as_deref(), &next.0)?;
5545 }
5546 after = next.map(|cursor| cursor.0);
5547 if after.is_none() {
5548 return Ok(ids);
5549 }
5550 }
5551 }
5552
5553 async fn dependencies(
5554 &self,
5555 id: &NativeId,
5556 near_kind: ItemKind,
5557 direction: Direction,
5558 page: &PageRequest,
5559 ) -> Result<Page<DependencyEdge>, SourceError> {
5560 validate_page(page)?;
5561 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5562 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5563 let recorded = recorded_offset(cursor, direction)?;
5564 // Asked for even in the recorded phase, whose page reads nothing from the
5565 // connection: `__typename` is what says whether this item has a native
5566 // relationship at all, and that is what decides which far ends the reserved key is
5567 // allowed to hold.
5568 let data = self
5569 .graphql(
5570 graphql::ISSUE_DEPENDENCIES,
5571 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
5572 "after":if recorded.is_some() {None} else {cursor}}),
5573 )
5574 .await?;
5575 let node =
5576 data.get("node")
5577 .filter(|v| !v.is_null())
5578 .ok_or_else(|| SourceError::Refused {
5579 message: format!(
5580 "GitHub item {} was not found or does not support dependencies",
5581 id.0
5582 ),
5583 })?;
5584 let connection_name = match direction {
5585 Direction::DependsOn => "blockedBy",
5586 Direction::DependedOnBy => "blocking",
5587 };
5588 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
5589 // named natively and the reserved key may hold any far end. An issue's connections
5590 // hold issues, and this source reads them at the near item's own level.
5591 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
5592 if let Some(offset) = recorded {
5593 return Ok(recorded_page(
5594 self.recorded_edges(id, near_kind, direction, natively_names, node)
5595 .await?,
5596 offset,
5597 limit,
5598 ));
5599 }
5600 if natively_names.is_none() {
5601 return Ok(recorded_page(
5602 self.recorded_edges(id, near_kind, direction, natively_names, node)
5603 .await?,
5604 0,
5605 limit,
5606 ));
5607 }
5608 let connection = node
5609 .get(connection_name)
5610 .ok_or_else(|| SourceError::Malformed {
5611 message: "GitHub dependency response is missing its connection".into(),
5612 })?;
5613 let nodes = connection
5614 .get("nodes")
5615 .and_then(Value::as_array)
5616 .ok_or_else(|| SourceError::Malformed {
5617 message: "GitHub dependency response nodes is not an array".into(),
5618 })?;
5619 // `from` depends on `to`, always. GitHub spells the same relationship from either
5620 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
5621 // it — so the near item is `from` in one direction and `to` in the other.
5622 let items = nodes
5623 .iter()
5624 .map(|value| {
5625 let related = NativeId(required_str(value, "id")?.into());
5626 let related_kind = related_kind(value)?;
5627 let (from, to) = match direction {
5628 Direction::DependsOn => (
5629 DependencyEndpoint::from_native(id.clone(), near_kind),
5630 DependencyEndpoint::from_native(related, related_kind),
5631 ),
5632 Direction::DependedOnBy => (
5633 DependencyEndpoint::from_native(related, related_kind),
5634 DependencyEndpoint::from_native(id.clone(), near_kind),
5635 ),
5636 };
5637 Ok(DependencyEdge {
5638 from,
5639 to,
5640 kind: DependencyKind::Blocks,
5641 })
5642 })
5643 .collect::<Result<Vec<_>, SourceError>>()?;
5644 let mut next = next_cursor(connection)?;
5645 if let Some(next) = &next {
5646 validate_cursor_progress(cursor, &next.0)?;
5647 }
5648 if next.is_none()
5649 && !self
5650 .recorded_edges(id, near_kind, direction, natively_names, node)
5651 .await?
5652 .is_empty()
5653 {
5654 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
5655 }
5656 Ok(Page { items, next })
5657 }
5658
5659 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
5660 /// a far end in another source has to live: no GitHub issue relationship can name one.
5661 ///
5662 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
5663 /// source never writes one down.
5664 ///
5665 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
5666 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
5667 /// request beyond the read already made, and reading the board for them would be a
5668 /// walk of every item for one field of one. A draft has no body in that answer, because
5669 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
5670 /// listing of the board, which can be behind on the very item asked about.
5671 async fn recorded_edges(
5672 &self,
5673 id: &NativeId,
5674 near_kind: ItemKind,
5675 direction: Direction,
5676 natively_names: Option<ItemKind>,
5677 node: &Value,
5678 ) -> Result<Vec<DependencyEdge>, SourceError> {
5679 if direction != Direction::DependsOn {
5680 return Ok(Vec::new());
5681 }
5682 let slot = match node.get("body") {
5683 Some(body) if natively_names.is_some() => {
5684 metadata_body(body.as_str().map(str::to_owned))?.1
5685 }
5686 _ => {
5687 let Some(item) = self.item_by_id(id).await? else {
5688 return Ok(Vec::new());
5689 };
5690 item.slot
5691 }
5692 };
5693 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
5694 .map_err(|message| SourceError::Malformed { message })
5695 }
5696
5697 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
5698 self.repository
5699 .as_ref()
5700 .ok_or_else(|| SourceError::Refused {
5701 message: format!(
5702 "source {} has no repository configured, and a GitHub Projects board has no \
5703 repository of its own to create an issue in; set repository: owner/name on \
5704 this source",
5705 self.name
5706 ),
5707 })
5708 }
5709
5710 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
5711 /// states.
5712 ///
5713 /// The fallback is demanded first, whichever arm answers: a write without a configured
5714 /// repository is refused naming the field exactly as it was before the rule existed,
5715 /// so a source that could not write before cannot write now, rather than writing for
5716 /// the one item whose own field happens to decide it.
5717 ///
5718 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
5719 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
5720 /// entry owned by someone other than the owner of the parent issue's repository —
5721 /// GitHub accepts a sub-issue from another repository of the same owner and from no
5722 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
5723 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
5724 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
5725 /// and is visible to the token is checked where its node id is resolved, still before
5726 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
5727 /// looked up in a listing of the board, which can be minutes behind an issue its own
5728 /// `projectItems` already places on it — and that read answers first from this process's
5729 /// own record, so a project created moments ago in this command answers though GitHub
5730 /// has not caught up.
5731 async fn creation_target(
5732 &self,
5733 incoming: &Incoming<'_>,
5734 ) -> Result<RepositoryTarget, SourceError> {
5735 let fallback = self.configured_repository()?;
5736 let what = |incoming: &Incoming<'_>| {
5737 format!(
5738 "{} {:?}",
5739 incoming.written.kind().describes(),
5740 incoming.title
5741 )
5742 };
5743 let parent = match incoming.parent {
5744 Some(parent) => Some(self.item_by_id(parent).await?.ok_or_else(|| {
5745 SourceError::Refused {
5746 message: format!(
5747 "GitHub project issue {} was not found on the board of source {}, so {} \
5748 cannot be filed under it",
5749 parent.0,
5750 self.name,
5751 what(incoming)
5752 ),
5753 }
5754 })?),
5755 None => None,
5756 };
5757 let parents_repository = parent
5758 .as_ref()
5759 .map(|parent| {
5760 // A draft is on the board and so is found, but it has no repository to
5761 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
5762 // would refuse the task only once `createIssue` had made it.
5763 if parent.content_kind == ContentKind::DraftIssue {
5764 return Err(SourceError::Refused {
5765 message: format!(
5766 "GitHub project item {} on the board of source {} is a draft, \
5767 which cannot have sub-issues, so {} cannot be filed under it",
5768 parent.id.0,
5769 self.name,
5770 what(incoming)
5771 ),
5772 });
5773 }
5774 // An issue's repository is where a sub-issue is placed and whose owner it
5775 // is compared against, so a parent whose repository this source cannot
5776 // spell as `owner/name` — GitHub's login grammar is wider than this
5777 // source's floor — is one nothing can be filed under.
5778 parent
5779 .own_repository
5780 .as_ref()
5781 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
5782 .ok_or_else(|| SourceError::Malformed {
5783 message: format!(
5784 "GitHub project issue {} on the board of source {} is in {}, which \
5785 is not a {}/owner/name repository this source can place {} in",
5786 parent.id.0,
5787 self.name,
5788 parent
5789 .own_repository
5790 .as_ref()
5791 .map_or("no repository", Repository::as_str),
5792 RepositoryTarget::HOST,
5793 what(incoming)
5794 ),
5795 })
5796 })
5797 .transpose()?;
5798 match incoming.repositories {
5799 [named] => {
5800 let target =
5801 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
5802 message: format!(
5803 "{} names repository {}, which is not a {}/owner/name repository \
5804 source {} can create an issue in; name one that is, or name none",
5805 what(incoming),
5806 named.as_str(),
5807 RepositoryTarget::HOST,
5808 self.name
5809 ),
5810 })?;
5811 if let Some(parents) = &parents_repository
5812 && parents.owner != target.owner
5813 {
5814 return Err(SourceError::Refused {
5815 message: format!(
5816 "{} names repository {}, owned by {}, but its project's issue is in \
5817 {}, owned by {}, and GitHub files a sub-issue only in a repository \
5818 of the same owner as its parent issue; name a repository of {}, or \
5819 name none",
5820 what(incoming),
5821 target.slug(),
5822 target.owner,
5823 parents.slug(),
5824 parents.owner,
5825 parents.owner
5826 ),
5827 });
5828 }
5829 Ok(target)
5830 }
5831 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
5832 }
5833 }
5834
5835 /// The node id of the repository `incoming` is being created in, or the refusal naming
5836 /// the item and the repository the token cannot see.
5837 ///
5838 /// Resolved once per command per repository; see [`Self::repository_cache`].
5839 async fn repository_id(
5840 &self,
5841 repository: &RepositoryTarget,
5842 incoming: &Incoming<'_>,
5843 ) -> Result<String, SourceError> {
5844 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
5845 return Ok(id);
5846 }
5847 let data = self
5848 .graphql(
5849 graphql::REPOSITORY,
5850 json!({"owner":repository.owner,"name":repository.name}),
5851 )
5852 .await?;
5853 let node = data
5854 .get("repository")
5855 .filter(|value| !value.is_null())
5856 .ok_or_else(|| SourceError::Refused {
5857 message: format!(
5858 "GitHub repository {} was not found or is not visible to the token, so {} \
5859 {:?} cannot be created in it",
5860 repository.slug(),
5861 incoming.written.kind().describes(),
5862 incoming.title
5863 ),
5864 })?;
5865 let id = required_str(node, "id")?.to_owned();
5866 self.repository_cache()?
5867 .insert(repository.clone(), id.clone());
5868 Ok(id)
5869 }
5870
5871 fn repository_cache(
5872 &self,
5873 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
5874 self.repository_cache
5875 .lock()
5876 .map_err(|_| SourceError::Unavailable {
5877 message: "this source's record of the destination repository was left \
5878 inconsistent by an earlier failure; next: run the command again"
5879 .into(),
5880 })
5881 }
5882
5883 /// Create or update one board item, whichever kind it is.
5884 async fn write_item(
5885 &self,
5886 incoming: &Incoming<'_>,
5887 target: Option<&NativeId>,
5888 depends_on: &[DependencyEdge],
5889 ) -> Result<NativeId, SourceError> {
5890 // Refused before anything is read or written: a task or a project titled the way
5891 // this board spells a document would land as an issue this same source reads back
5892 // as a document, so the field this destination cannot carry is named rather than
5893 // written and silently reclassified.
5894 if let Written::Work(kind, _) = incoming.written
5895 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
5896 {
5897 return Err(SourceError::Refused {
5898 message: format!(
5899 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5900 spells a document, so it would read back as one rather than as a {}; \
5901 retitle it, or copy it as a document",
5902 kind.marker(),
5903 self.name,
5904 kind.marker()
5905 ),
5906 });
5907 }
5908 // The destination is read by its own id, and whether this board holds it is decided
5909 // by that read — its own `projectItems` — rather than by whether a listing of the
5910 // board happens to include it yet. See the module documentation.
5911 let existing = match target {
5912 Some(target) => {
5913 Some(
5914 self.item_by_id(target)
5915 .await?
5916 .ok_or_else(|| SourceError::Refused {
5917 message: format!("GitHub destination item {} was not found", target.0),
5918 })?,
5919 )
5920 }
5921 None => None,
5922 };
5923 let existing = existing.as_ref();
5924 let board = self
5925 .fields_for(
5926 existing,
5927 incoming.written.status().is_some(),
5928 incoming
5929 .priority
5930 .is_some_and(|priority| priority != Priority::None),
5931 )
5932 .await?;
5933 let status_target = incoming
5934 .written
5935 .status()
5936 .map(|status| self.resolved_target(status.category))
5937 .transpose()?;
5938 let column = match (incoming.written.status(), status_target.as_ref()) {
5939 (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
5940 _ => None,
5941 };
5942 // Resolved before anything is created, for the reason the column above is: a
5943 // priority this board has no option for is refused while nothing has been written.
5944 let priority_write = match incoming.priority {
5945 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
5946 None => None,
5947 };
5948 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
5949 if content_kind == ContentKind::DraftIssue {
5950 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
5951 (status_target.as_ref(), incoming.written.status())
5952 {
5953 return Err(self.closes_a_draft(status.category));
5954 }
5955 if incoming.parent.is_some() {
5956 return Err(SourceError::Refused {
5957 message: "GitHub draft items cannot be a project's sub-issue".into(),
5958 });
5959 }
5960 }
5961 match existing {
5962 Some(item) if content_kind == ContentKind::Issue => {
5963 if item.labels != incoming.labels {
5964 return Err(SourceError::Refused {
5965 message: "GitHub issue labels differ from the labels being written".into(),
5966 });
5967 }
5968 }
5969 _ => {
5970 if !incoming.labels.is_empty() {
5971 return Err(SourceError::Refused {
5972 message: "GitHub items created by this destination carry no labels".into(),
5973 });
5974 }
5975 }
5976 }
5977
5978 // An existing issue is never moved; a new one is created where the rule says. The
5979 // repository the issue really lives in is what the slot below is written against,
5980 // so a single entry that is where the issue is created travels as no key at all,
5981 // and the read side derives it back from the issue.
5982 let (own_repository, creation_target) = match existing {
5983 Some(item) => (item.own_repository.clone(), None),
5984 None => {
5985 let target = self.creation_target(incoming).await?;
5986 let origin = Repository::try_from(target.origin())
5987 .map_err(|message| SourceError::Config { message })?;
5988 (Some(origin), Some(target))
5989 }
5990 };
5991 let (native, fallback) = self
5992 .partition_edges(incoming.written.kind(), content_kind, depends_on)
5993 .await?;
5994 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
5995 let body = compose_body(incoming.content, &slot)?;
5996 // Read before anything is created, for the reason the field below is: a value
5997 // this destination cannot store has to refuse, and refusing after `createIssue`
5998 // would leave an issue behind that nothing asked for. The engine writes a
5999 // qualified id here; a caller handing this key anything else is told so rather
6000 // than having it silently stored as no origin at all.
6001 // llmlint: ignore[boundary_inputs_validated, changed_behavior_has_e2e] The qualified id's syntax is the engine's and not this plugin's to police: `GlobalId` is deliberately absent from the contract crate because a plugin never sees a qualified id (AGENTS.md), no plugin crate may depend on the engine to parse one, and `docs/metadata.md` says the contents of this key are what no plugin constructs or interprets. What this boundary owns is whether the value is a string its text field can hold, and that is what it checks.
6002 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6003 None => "",
6004 Some(Value::String(origin)) => origin.as_str(),
6005 Some(other) => {
6006 return Err(SourceError::Refused {
6007 message: format!(
6008 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6009 is {other}"
6010 ),
6011 });
6012 }
6013 };
6014 // Resolved before anything is created: a board that cannot carry the copy origin
6015 // has to refuse the write, and refusing it after `createIssue` would leave an
6016 // issue behind that nothing asked for.
6017 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6018 Some(field) => {
6019 if required_str(field, "__typename")? != "ProjectV2Field" {
6020 return Err(SourceError::Refused {
6021 message: format!(
6022 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6023 ),
6024 });
6025 }
6026 Some(required_str(field, "id")?.to_owned())
6027 }
6028 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6029 return Err(SourceError::Refused {
6030 message: format!(
6031 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6032 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6033 the board"
6034 ),
6035 });
6036 }
6037 None => None,
6038 };
6039
6040 let Landed {
6041 content_id,
6042 item_id,
6043 url,
6044 number,
6045 } = match existing {
6046 Some(item) => {
6047 self.update_existing(item, incoming, &body, status_target.as_ref())
6048 .await?;
6049 Landed {
6050 content_id: item.id.clone(),
6051 item_id: item.item_id.clone(),
6052 url: item.url.clone(),
6053 number: item.number,
6054 }
6055 }
6056 None => {
6057 let target = creation_target
6058 .as_ref()
6059 .ok_or_else(|| SourceError::Malformed {
6060 message: "a new item was decided without a repository to create it in"
6061 .into(),
6062 })?;
6063 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6064 .await?
6065 }
6066 };
6067
6068 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6069 let column = column.map(|(field, option, _)| (field, option));
6070 // Creating an item here is several calls — `createIssue`, `addProjectV2ItemById`,
6071 // then each board field, the parent and the dependencies — and GitHub can fail at
6072 // any of them. Everything this source can refuse *before* the first of those is
6073 // already checked above, so what is left is GitHub itself failing part way. When it
6074 // does over an item this call created, the issue is taken back: a write that
6075 // refused must not leave an item behind that nobody asked for, and one that does
6076 // makes the retry create a second.
6077 let landed = self
6078 .finish_write(
6079 board.id.as_str(),
6080 incoming,
6081 &content_id,
6082 &item_id,
6083 content_kind,
6084 existing,
6085 origin_field.as_deref(),
6086 origin,
6087 column,
6088 status_target.as_ref(),
6089 priority_write.as_ref(),
6090 &native,
6091 )
6092 .await;
6093 if let Err(error) = landed {
6094 if existing.is_none() {
6095 // Best effort, and the write's own failure is what the caller is told: a
6096 // refusal naming the tidy-up would hide why the write failed at all.
6097 let _ = self.delete_issue(&content_id).await;
6098 }
6099 return Err(error);
6100 }
6101
6102 let written_status = match (incoming.written.status(), status_target.as_ref()) {
6103 (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6104 self.statuses
6105 .status(written_option.as_deref(), true, Some(reason.reason()))
6106 }
6107 (Some(_), Some(StatusTarget::Column(_))) => {
6108 self.statuses.status(written_option.as_deref(), false, None)
6109 }
6110 (Some(status), _) => status.clone(),
6111 (None, _) => Status {
6112 category: StatusCategory::Unknown,
6113 name: "Open".to_owned(),
6114 },
6115 };
6116
6117 // So the rest of this command reads what it just did rather than what the board
6118 // said before it. See `remember_written` for which half takes it.
6119 let remembered = Resolved {
6120 item_id,
6121 id: content_id.clone(),
6122 content_kind,
6123 kind: incoming.written.kind(),
6124 title: incoming.title.to_owned(),
6125 // The visible half of the body this write composed, split back off it the
6126 // way a read splits it — so what this record reports is what a read of the
6127 // same issue reports, rather than the person's text with the metadata slot
6128 // still on the end of it.
6129 body: metadata_body(body.clone())?.0,
6130 raw_body: body.clone(),
6131 // A document has no status of its own; what it reads back as is whatever
6132 // the issue's own state says, which is what a re-read reports.
6133 status: written_status,
6134 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6135 priority: match incoming.priority {
6136 Some(priority) => HeldPriority::Read(priority),
6137 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6138 item.priority.clone()
6139 }),
6140 },
6141 // What `state_input` asked for: closed for a terminal target, open for any other
6142 // status, and the issue's own state left as it was by a document write.
6143 closed: content_kind == ContentKind::Issue
6144 && match status_target.as_ref() {
6145 Some(StatusTarget::Terminal(_, _)) => true,
6146 Some(_) => false,
6147 None => existing.is_some_and(|item| item.closed),
6148 },
6149 delivers: incoming.delivers.to_vec(),
6150 delivered_by: incoming.delivered_by.to_vec(),
6151 labels: incoming.labels.to_vec(),
6152 parent: incoming.parent.cloned(),
6153 origin: (!origin.is_empty()).then(|| origin.to_owned()),
6154 number,
6155 // In the update path this is the item's own url, read off `existing` where the
6156 // record above was bound, so one expression serves both halves.
6157 url,
6158 created_at: existing.and_then(|item| item.created_at),
6159 updated_at: existing.and_then(|item| item.updated_at),
6160 own_repository,
6161 repositories: incoming.repositories.to_vec(),
6162 slot,
6163 board_id: Some(board.id.as_str().to_owned()),
6164 fields: board
6165 .fields
6166 .get("nodes")
6167 .and_then(Value::as_array)
6168 .cloned()
6169 .unwrap_or_default(),
6170 };
6171 self.remember_written(remembered, existing.is_none())?;
6172 Ok(content_id)
6173 }
6174
6175 /// Everything a write does after the item exists: its board fields, its parent, and
6176 /// its dependencies.
6177 ///
6178 /// Split out of `write_item` so there is one place a failure past the point of no
6179 /// return is caught, rather than a tidy-up repeated at each `?` above.
6180 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
6181 // so there is one place a failure past the point of no return is caught, and its
6182 // arguments are exactly the values that tail already had in scope. Bundling them into a
6183 // struct would describe no concept — it would be "the arguments of this function" — and
6184 // would put the whole of `write_item`'s locals behind one more indirection.
6185 #[allow(clippy::too_many_arguments)]
6186 async fn finish_write(
6187 &self,
6188 board_id: &str,
6189 incoming: &Incoming<'_>,
6190 content_id: &NativeId,
6191 item_id: &str,
6192 content_kind: ContentKind,
6193 existing: Option<&Resolved>,
6194 origin_field: Option<&str>,
6195 origin: &str,
6196 column: Option<(String, String)>,
6197 status_target: Option<&StatusTarget>,
6198 priority: Option<&PriorityWrite>,
6199 native: &[String],
6200 ) -> Result<(), SourceError> {
6201 if let Some(field_id) = origin_field {
6202 self.set_item_field(board_id, item_id, field_id, json!({"text":origin}))
6203 .await?;
6204 }
6205
6206 if let Some((field_id, option_id)) = column {
6207 self.set_item_field(
6208 board_id,
6209 item_id,
6210 &field_id,
6211 json!({"singleSelectOptionId":option_id}),
6212 )
6213 .await?;
6214 }
6215
6216 if let Some(priority) = priority {
6217 self.write_priority(board_id, item_id, priority).await?;
6218 }
6219
6220 if content_kind == ContentKind::Issue
6221 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
6222 {
6223 self.update_content(
6224 ContentKind::Issue,
6225 content_id,
6226 json!({"stateInput":state_input(status_target)}),
6227 )
6228 .await?;
6229 }
6230
6231 if content_kind == ContentKind::Issue {
6232 self.reparent(
6233 existing.and_then(|item| item.parent.clone()),
6234 content_id,
6235 incoming.parent,
6236 )
6237 .await?;
6238 // A document takes part in no dependency graph, so writing one neither reads
6239 // nor changes the issue's own `blockedBy` relationships. Reconciling them
6240 // against the empty list a document write carries would *delete* whatever
6241 // relationships a person had made on that issue, which is a write nobody
6242 // asked for.
6243 if incoming.written.kind() != BoardKind::Document {
6244 let issue = match existing {
6245 Some(_) => Issue::Existing,
6246 None => Issue::Created,
6247 };
6248 self.reconcile_blocked_by(content_id, native, issue).await?;
6249 }
6250 }
6251 Ok(())
6252 }
6253
6254 /// Delete one issue, which takes its board item with it.
6255 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
6256 let data = self
6257 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6258 .await?;
6259 data.pointer("/deleteIssue/repository")
6260 .filter(|value| !value.is_null())
6261 .ok_or_else(|| SourceError::Malformed {
6262 message: "GitHub issue deletion returned no repository".into(),
6263 })?;
6264 self.forget(id)?;
6265 Ok(())
6266 }
6267
6268 /// Remove one item this copy created, so a copy that could not finish leaves the board
6269 /// as it found it.
6270 ///
6271 /// Deleting the issue takes its board item with it, so there is no second mutation to
6272 /// keep in step. An id the board does not hold is not an error: the item is already
6273 /// gone, which is the state this asks for. Which that is, is decided by reading the item
6274 /// by its own id — a listing of the board can still be missing an item it holds, and
6275 /// reading that as *already gone* would leave behind the very item this was asked to
6276 /// take back.
6277 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
6278 let Some(item) = self.item_by_id(id).await? else {
6279 return Ok(());
6280 };
6281 if item.content_kind == ContentKind::DraftIssue {
6282 return Err(SourceError::Refused {
6283 message: format!(
6284 "GitHub item {} is a draft, and this source removes an item by deleting \
6285 its issue; next: remove it from the board by hand",
6286 id.0
6287 ),
6288 });
6289 }
6290 let data = self
6291 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6292 .await?;
6293 data.pointer("/deleteIssue/repository")
6294 .filter(|value| !value.is_null())
6295 .ok_or_else(|| SourceError::Malformed {
6296 message: "GitHub issue deletion returned no repository".into(),
6297 })?;
6298 self.forget(id)?;
6299 Ok(())
6300 }
6301
6302 /// The issue a comment call on `task` is about, or `None` when this board holds no such
6303 /// task.
6304 ///
6305 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
6306 /// read of the task cannot disagree about which ids name one: a project or a document of
6307 /// this board is not a task here either.
6308 ///
6309 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
6310 /// issues and a draft is not one. It is refused rather than answered with an empty page,
6311 /// which would read as a task nobody has commented on yet.
6312 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
6313 let Some(item) = self
6314 .item_by_id(task)
6315 .await?
6316 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6317 else {
6318 return Ok(None);
6319 };
6320 if item.content_kind == ContentKind::DraftIssue {
6321 return Err(SourceError::Refused {
6322 message: format!(
6323 "task {} of source {} is a draft item on the board, and GitHub keeps \
6324 comments on issues alone, so a draft has none to read or write; next: \
6325 convert the draft to an issue on the board, then comment on the issue it \
6326 becomes",
6327 task.0, self.name
6328 ),
6329 });
6330 }
6331 Ok(Some(item.id))
6332 }
6333
6334 /// Whether the comment `comment` is one of `issue`'s own.
6335 ///
6336 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
6337 /// comment's id and nothing else: a comment id given against the wrong task would
6338 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
6339 /// names something that is not an issue comment, is a comment this task does not have —
6340 /// which is what GitHub refusing to resolve it means too.
6341 async fn comment_is_on(
6342 &self,
6343 issue: &NativeId,
6344 comment: &NativeId,
6345 ) -> Result<bool, SourceError> {
6346 let asked = self
6347 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
6348 .await;
6349 let data = match asked {
6350 Ok(data) => data,
6351 Err(error) if unresolvable_node(&error) => return Ok(false),
6352 Err(error) => return Err(error),
6353 };
6354 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6355 return Ok(false);
6356 };
6357 if optional_str(node, "__typename")? != Some("IssueComment") {
6358 return Ok(false);
6359 }
6360 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
6361 message: format!("GitHub issue comment {} names no issue", comment.0),
6362 })?;
6363 Ok(required_str(on, "id")? == issue.0)
6364 }
6365
6366 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
6367 async fn partition_edges(
6368 &self,
6369 near_kind: BoardKind,
6370 near_content: ContentKind,
6371 depends_on: &[DependencyEdge],
6372 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
6373 let mut native = Vec::new();
6374 let mut fallback = Vec::new();
6375 for edge in depends_on {
6376 let same_source = edge
6377 .to
6378 .source()
6379 .is_none_or(|source| source == self.name.as_str());
6380 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
6381 // `DependencyEndpoint::source` both read it that way — and a native id may hold
6382 // colons of its own, so the far end is everything after that one separator.
6383 // Splitting at the last would truncate `work:urn:task:7` to `7`.
6384 let far_id = if edge.to.is_qualified() {
6385 edge.to
6386 .id()
6387 .split_once(':')
6388 .map_or(edge.to.id(), |(_, native)| native)
6389 } else {
6390 edge.to.id()
6391 };
6392 // A same-source far end is read by its own id, exactly as the item it is a far end
6393 // of is: whether this board holds it is that read's answer, never a listing's.
6394 let far = if same_source {
6395 Some(
6396 self.item_by_id(&NativeId(far_id.to_owned()))
6397 .await?
6398 .ok_or_else(|| SourceError::Refused {
6399 message: format!("GitHub dependency item {far_id} was not found"),
6400 })?,
6401 )
6402 } else {
6403 None
6404 };
6405 let far = far.as_ref();
6406 // The caller says which kind the far end is, and this board holds the far end
6407 // itself, so a disagreement is settled here rather than stored: recorded, the
6408 // wrong kind would read back as a cross-level edge that never existed; written
6409 // natively, it would name a relationship of a different level than the caller
6410 // asked for.
6411 //
6412 // A far end this board holds as a *document* fails the same comparison and is
6413 // refused by the same sentence: `ItemKind` has no document variant because
6414 // nothing may point at one, so no caller can name it correctly and the refusal
6415 // is the only honest answer.
6416 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
6417 return Err(SourceError::Refused {
6418 message: format!(
6419 "GitHub dependency item {far_id} is a {} of this board, and this item \
6420 names it as a {}; record the kind it is",
6421 disagreeing.kind.describes(),
6422 edge.to.kind.marker()
6423 ),
6424 });
6425 }
6426 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
6427 // however the far end is spelled — and one classified native here would be
6428 // written nowhere at all, because a draft's native reconciliation never runs.
6429 let native_here = near_content == ContentKind::Issue
6430 && far.is_some_and(|far| {
6431 far.content_kind == ContentKind::Issue
6432 && BoardKind::Work(edge.to.kind) == near_kind
6433 });
6434 if native_here {
6435 native.push(far_id.to_owned());
6436 } else {
6437 fallback.push(edge.clone());
6438 }
6439 }
6440 Ok((native, fallback))
6441 }
6442
6443 async fn update_existing(
6444 &self,
6445 item: &Resolved,
6446 incoming: &Incoming<'_>,
6447 body: &Option<String>,
6448 status_target: Option<&StatusTarget>,
6449 ) -> Result<(), SourceError> {
6450 let title = incoming.written_title();
6451 let mut fields = match item.content_kind {
6452 ContentKind::DraftIssue => json!({"title":title,"body":body}),
6453 ContentKind::Issue => json!({"title":title,"body":body,
6454 "stateInput":state_input(status_target)}),
6455 };
6456 if matches!(status_target, Some(StatusTarget::Terminal(_, _))) {
6457 fields
6458 .as_object_mut()
6459 .expect("update fields are an object")
6460 .remove("stateInput");
6461 }
6462 self.update_content(item.content_kind, &item.id, fields)
6463 .await
6464 }
6465
6466 /// Update one board item's content with exactly `fields` beside its id, through the
6467 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
6468 /// a draft.
6469 ///
6470 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
6471 /// is what lets a narrow write carry the one thing it changes and nothing else.
6472 async fn update_content(
6473 &self,
6474 kind: ContentKind,
6475 id: &NativeId,
6476 fields: Value,
6477 ) -> Result<(), SourceError> {
6478 let (operation, id_key, pointer) = match kind {
6479 ContentKind::DraftIssue => (
6480 graphql::UPDATE_DRAFT,
6481 "draftIssueId",
6482 "/updateProjectV2DraftIssue/draftIssue",
6483 ),
6484 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
6485 };
6486 let mut input = fields;
6487 input[id_key] = json!(id.0);
6488 let data = self.graphql(operation, json!({"input":input})).await?;
6489 let returned = data
6490 .pointer(pointer)
6491 .ok_or_else(|| SourceError::Malformed {
6492 message: "GitHub item update returned no item".into(),
6493 })?;
6494 if required_str(returned, "id")? != id.0 {
6495 return Err(SourceError::Malformed {
6496 message: "GitHub item update returned the wrong item".into(),
6497 });
6498 }
6499 Ok(())
6500 }
6501
6502 /// Creates one issue, files it on the board, and reports what a read of it would say:
6503 /// its content id, its board item id, and the web address GitHub gave it.
6504 ///
6505 /// Two calls rather than one: `createIssue` needs a repository and answers with an
6506 /// issue that is on no board, and `addProjectV2ItemById` is what puts it there. A
6507 /// terminal status is not written here: `finish_write` selects its option first and
6508 /// closes the issue after, so a close never lands on an item whose board cannot show it.
6509 ///
6510 /// The address and the number come back here because this is the only place either is
6511 /// known before GitHub's own board read catches up — an item this run created answers
6512 /// the reads that follow it out of the record below, and one remembered without them
6513 /// would report no location and no key for the rest of the run.
6514 async fn create_and_file_issue(
6515 &self,
6516 board_id: &str,
6517 repository: &RepositoryTarget,
6518 incoming: &Incoming<'_>,
6519 body: &Option<String>,
6520 ) -> Result<Landed, SourceError> {
6521 let repository_id = self.repository_id(repository, incoming).await?;
6522 let data = self
6523 .graphql(
6524 graphql::CREATE_ISSUE,
6525 json!({"input":{
6526 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
6527 }}),
6528 )
6529 .await?;
6530 let created = data
6531 .pointer("/createIssue/issue")
6532 .filter(|value| !value.is_null())
6533 .ok_or_else(|| SourceError::Malformed {
6534 message: "GitHub issue creation returned no issue".into(),
6535 })?;
6536 let content_id = NativeId(required_str(created, "id")?.to_owned());
6537 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
6538 // a response without it is not worth failing a landed write over — the item simply
6539 // reports no location until the board read catches up, which is what it did before.
6540 let url = optional_str(created, "url")?.map(str::to_owned);
6541 // The issue exists from here on, so an unreadable number and a refused board
6542 // filing below each try, best effort, to take it back: an issue in the repository
6543 // that is on no board is an item nobody asked for and nothing here would find again.
6544 //
6545 // Its number is optional on the same terms its address is — a landed write is not
6546 // worth failing over a member that came back missing, and such an item reports no
6547 // handle until a board read catches up. A number that is *present* and is not an
6548 // unsigned integer is still a response this source cannot read.
6549 let number = match created_issue_number(created) {
6550 Ok(number) => number,
6551 Err(error) => {
6552 let _ = self.delete_issue(&content_id).await;
6553 return Err(error);
6554 }
6555 };
6556 let added = match self
6557 .graphql(
6558 graphql::ADD_TO_BOARD,
6559 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
6560 )
6561 .await
6562 {
6563 Ok(added) => added,
6564 Err(error) => {
6565 let _ = self.delete_issue(&content_id).await;
6566 return Err(error);
6567 }
6568 };
6569 let item = added
6570 .pointer("/addProjectV2ItemById/item")
6571 .filter(|value| !value.is_null())
6572 .ok_or_else(|| SourceError::Malformed {
6573 message: "GitHub board addition returned no project item".into(),
6574 })?;
6575 Ok(Landed {
6576 content_id,
6577 item_id: required_str(item, "id")?.to_owned(),
6578 url,
6579 number,
6580 })
6581 }
6582
6583 /// Move one issue under the project it now belongs to, or out of the one it left.
6584 async fn reparent(
6585 &self,
6586 held: Option<NativeId>,
6587 child: &NativeId,
6588 wanted: Option<&NativeId>,
6589 ) -> Result<(), SourceError> {
6590 if held.as_ref() == wanted {
6591 return Ok(());
6592 }
6593 if let Some(held) = &held {
6594 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
6595 .await?;
6596 }
6597 if let Some(wanted) = wanted {
6598 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
6599 .await?;
6600 }
6601 Ok(())
6602 }
6603
6604 async fn sub_issue(
6605 &self,
6606 operation: &str,
6607 parent: &NativeId,
6608 child: &NativeId,
6609 root: &str,
6610 ) -> Result<(), SourceError> {
6611 let data = self
6612 .graphql(
6613 operation,
6614 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
6615 )
6616 .await?;
6617 let issue =
6618 data.pointer(&format!("/{root}/issue"))
6619 .ok_or_else(|| SourceError::Malformed {
6620 message: "GitHub sub-issue update returned no issue".into(),
6621 })?;
6622 let sub =
6623 data.pointer(&format!("/{root}/subIssue"))
6624 .ok_or_else(|| SourceError::Malformed {
6625 message: "GitHub sub-issue update returned no sub-issue".into(),
6626 })?;
6627 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
6628 return Err(SourceError::Malformed {
6629 message: "GitHub sub-issue update returned the wrong issues".into(),
6630 });
6631 }
6632 Ok(())
6633 }
6634
6635 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
6636 /// whether there was one.
6637 ///
6638 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
6639 /// relationships are not read: there is nothing a read of them could find.
6640 async fn reconcile_blocked_by(
6641 &self,
6642 content_id: &NativeId,
6643 native: &[String],
6644 issue: Issue,
6645 ) -> Result<bool, SourceError> {
6646 let current = match issue {
6647 Issue::Created => Vec::new(),
6648 Issue::Existing => self.native_dependency_ids(content_id).await?,
6649 };
6650 let mut changed = false;
6651 for (operation, far_id) in current
6652 .iter()
6653 .filter(|id| !native.contains(id))
6654 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
6655 .chain(
6656 native
6657 .iter()
6658 .filter(|id| !current.contains(id))
6659 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
6660 )
6661 {
6662 let data = self
6663 .graphql(
6664 operation,
6665 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
6666 )
6667 .await?;
6668 let root = if operation == graphql::ADD_BLOCKED_BY {
6669 "addBlockedBy"
6670 } else {
6671 "removeBlockedBy"
6672 };
6673 let issue =
6674 data.pointer(&format!("/{root}/issue"))
6675 .ok_or_else(|| SourceError::Malformed {
6676 message: "GitHub dependency update returned no issue".into(),
6677 })?;
6678 let blocker = data
6679 .pointer(&format!("/{root}/blockingIssue"))
6680 .ok_or_else(|| SourceError::Malformed {
6681 message: "GitHub dependency update returned no blocking issue".into(),
6682 })?;
6683 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
6684 {
6685 return Err(SourceError::Malformed {
6686 message: "GitHub dependency update returned the wrong issues".into(),
6687 });
6688 }
6689 changed = true;
6690 }
6691 Ok(changed)
6692 }
6693}
6694
6695/// Whether the issue one write reconciles was created by that write or was already there.
6696#[derive(Clone, Copy, PartialEq, Eq)]
6697enum Issue {
6698 /// Created by this write, so it holds no relationships yet.
6699 Created,
6700 /// On the board before this write, holding whatever relationships it holds.
6701 Existing,
6702}
6703
6704/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
6705enum Reached {
6706 /// An issue this board holds, resolved into everything this source reports about it.
6707 Held(Box<Resolved>),
6708 /// Nothing this board holds: no such node, or a node on some other board.
6709 Nothing,
6710 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
6711 /// again by [`GitHubProjectsSource::draft_by_id`].
6712 Draft,
6713}
6714
6715/// What GitHub says when a string is not a node id it can resolve.
6716///
6717/// Matched because it is the ordinary answer to a project selector naming a project by its
6718/// *name*, and reporting that as a failure would make naming one impossible. It is read
6719/// off the refusal GitHub sent, never guessed from the shape of the string: this source
6720/// does not define the syntax of a GitHub node id and would be wrong about it.
6721const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
6722
6723/// Whether this refusal is GitHub saying the id names no node at all.
6724fn unresolvable_node(error: &SourceError) -> bool {
6725 matches!(error, SourceError::Refused { message }
6726 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
6727}
6728
6729/// One project name, as a search qualifier which filters on it at the server.
6730///
6731/// Quoted so the whole title is one phrase rather than a bag of words, with the two
6732/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
6733/// the way it documents. A title matched here is still compared for equality afterwards:
6734/// the qualifier narrows what the server sends, and this source decides what it names.
6735fn title_qualifier(name: &str) -> String {
6736 format!("in:title {}", quoted(name))
6737}
6738
6739/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
6740/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
6741/// it documents — so a value holding a qualifier's spelling is searched for rather than
6742/// obeyed.
6743fn quoted(value: &str) -> String {
6744 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
6745 format!("\"{escaped}\"")
6746}
6747
6748/// The search qualifier for the issues updated at or after `since`.
6749///
6750/// Written to the second, rounded down, which can only widen what the search returns.
6751fn updated_qualifier(since: DateTime<Utc>) -> String {
6752 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
6753}
6754
6755/// The search terms that narrow a board-scoped issue search to a task query's text and
6756/// metadata predicates, or `None` when it carries neither.
6757///
6758/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
6759/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
6760/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
6761/// matches each in any field the `in:` qualifier names, so a query naming a title search and
6762/// a metadata value searches both fields for both — wider than asked, never narrower, and
6763/// every candidate is confirmed in process afterwards.
6764///
6765/// **This narrows a text search, and that is this source's declared semantics.** GitHub
6766/// matches whole tokens where a substring rule would match inside a word, so an item holding
6767/// the text only inside a longer word is not returned. A text of nothing but whitespace
6768/// matches every item, so it narrows nothing and is not sent.
6769fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
6770 let text = query
6771 .text
6772 .as_ref()
6773 .filter(|text| !text.terms.trim().is_empty());
6774 if text.is_none() && query.metadata.is_empty() {
6775 return None;
6776 }
6777 let (title, body) = match text.map(|text| text.fields) {
6778 None => (false, true),
6779 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
6780 Some(TextFields::Content) => (false, true),
6781 Some(TextFields::TitleOrContent) => (true, true),
6782 };
6783 let fields = match (title, body) {
6784 (true, true) => "in:title,body",
6785 (true, false) => "in:title",
6786 _ => "in:body",
6787 };
6788 let phrases = text
6789 .map(|text| text.terms.clone())
6790 .into_iter()
6791 .chain(
6792 query
6793 .metadata
6794 .iter()
6795 .map(|wanted| as_stored(wanted.value())),
6796 )
6797 .map(|phrase| quoted(&phrase))
6798 .collect::<Vec<_>>();
6799 Some(format!("{fields} {}", phrases.join(" ")))
6800}
6801
6802/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
6803/// before anything is asked of GitHub.
6804///
6805/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
6806/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
6807/// left out, the search is every issue of the board. So this source says it cannot answer
6808/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
6809/// nothing GitHub could search for, and keeps the board read it always had.
6810fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
6811 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
6812 letter or digit with a bounded query";
6813 if let Some(text) = &query.text
6814 && !text.terms.trim().is_empty()
6815 && !has_words(&text.terms)
6816 {
6817 return Err(SourceError::Refused {
6818 message: format!(
6819 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
6820 text.terms
6821 ),
6822 });
6823 }
6824 if let Some(wanted) = query
6825 .metadata
6826 .iter()
6827 .find(|wanted| !has_words(wanted.value()))
6828 {
6829 return Err(SourceError::Refused {
6830 message: format!(
6831 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
6832 wanted.value(),
6833 std::iter::once(wanted.key())
6834 .chain(wanted.path().iter().map(String::as_str))
6835 .collect::<Vec<_>>()
6836 .join("/"),
6837 ),
6838 });
6839 }
6840 Ok(())
6841}
6842
6843/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
6844fn has_words(phrase: &str) -> bool {
6845 phrase.chars().any(char::is_alphanumeric)
6846}
6847
6848/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
6849///
6850/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
6851/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
6852/// which GitHub's word match would read as different words.
6853fn as_stored(value: &str) -> String {
6854 let encoded = Value::String(value.to_owned()).to_string();
6855 encoded[1..encoded.len() - 1].to_owned()
6856}
6857
6858/// The one narrower question a task query carrying a text, metadata or origin predicate is
6859/// sent as.
6860enum Narrowing {
6861 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
6862 Origin(String),
6863 /// The board-scoped issue search narrowed by these qualifiers.
6864 Search(String),
6865}
6866
6867impl Narrowing {
6868 /// What this question is remembered under for the length of one command.
6869 fn key(&self) -> String {
6870 match self {
6871 Self::Origin(origin) => format!("origin {origin}"),
6872 Self::Search(also) => format!("search {also}"),
6873 }
6874 }
6875}
6876
6877/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
6878enum Resumed {
6879 /// It reported another page, which starts after this cursor.
6880 More(String),
6881 /// It has ended. Sending this cursor again — the page's own end when it had one, and
6882 /// otherwise the cursor it was reached from — answers an empty page, so the one document
6883 /// can go on walking the other connection.
6884 Ended(Option<String>),
6885}
6886
6887impl Resumed {
6888 /// Whether the connection has another page.
6889 const fn has_more(&self) -> bool {
6890 matches!(self, Self::More(_))
6891 }
6892
6893 /// The cursor to send this connection next.
6894 fn cursor(self) -> Option<String> {
6895 match self {
6896 Self::More(next) => Some(next),
6897 Self::Ended(last) => last,
6898 }
6899 }
6900}
6901
6902/// Where `connection`, reached from `after`, resumes — refused when it reports another page
6903/// with no cursor to it, or from a cursor that does not advance.
6904fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
6905 let info = connection
6906 .get("pageInfo")
6907 .ok_or_else(|| SourceError::Malformed {
6908 message: "GitHub connection has no pageInfo".into(),
6909 })?;
6910 let end = optional_str(info, "endCursor")?;
6911 if required_bool(info, "hasNextPage")? {
6912 let next = end.ok_or_else(|| SourceError::Malformed {
6913 message: "GitHub connection reports another page and no endCursor".into(),
6914 })?;
6915 validate_cursor_progress(after, next)?;
6916 return Ok(Resumed::More(next.to_owned()));
6917 }
6918 Ok(Resumed::Ended(
6919 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
6920 ))
6921}
6922
6923/// The board, and every item on it this source reports.
6924#[derive(Clone)]
6925struct Board {
6926 id: String,
6927 fields: Value,
6928 items: Vec<Resolved>,
6929}
6930
6931/// What a write needs of the board and nothing more: its node id and its field
6932/// definitions, in the shape a read of the board's own `fields` gives them.
6933///
6934/// Deliberately no items. A write decides which item it writes, which parent it files
6935/// under and which far ends it names by reading each of them by its own id; this is the
6936/// half of the board those reads cannot carry, and holding no item is what keeps it from
6937/// ever being asked whether an item is there.
6938#[derive(Clone)]
6939struct BoardFields {
6940 id: BoardId,
6941 fields: Value,
6942}
6943
6944/// A board's node id: what a field write and `addProjectV2ItemById` address.
6945///
6946/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
6947/// refused where it is read, and one an item names blank is read as not named at all.
6948#[derive(Clone)]
6949struct BoardId(String);
6950
6951/// Where one write left its item, for the record the rest of the command reads it out of.
6952///
6953/// A named record rather than a tuple because the update arm and the create arm each fill
6954/// all four, and two `Option`s of different meaning side by side in a tuple are two
6955/// positions a reader has to count.
6956struct Landed {
6957 /// The issue's own node id, which is the [`NativeId`] this source reports.
6958 content_id: NativeId,
6959 /// The board item's id, which is what a field write addresses.
6960 // llmlint: ignore[invalid_states_unrepresentable] This field and the one below are `Resolved::item_id` and `Resolved::url` carried out of one call: the update arm assigns them from an existing `Resolved` and the whole record is assigned straight back into one. A newtype introduced here alone would be wrapped at both of those boundaries and unwrapped at every use, and would make this private record disagree with the type the same values have on the struct they come from and return to. Where the board item id gets a newtype is on `Resolved`, which is the contract's own shape and not this change's to move.
6961 item_id: String,
6962 /// The web address GitHub gave the issue, when it gave one.
6963 // llmlint: ignore[invalid_states_unrepresentable] The answer `Resolved::url` and the contract's `Task::url` already record: a web address this source never parses, resolves or compares — it reads GitHub's string and hands it back, and `Location::Url` is where the contract gives it a shape. Validating it here would have this plugin decide what GitHub may call an address.
6964 url: Option<String>,
6965 /// The issue's number on its repository, when GitHub reported one.
6966 number: Option<u64>,
6967}
6968
6969impl BoardId {
6970 fn parse(id: &str) -> Result<Self, SourceError> {
6971 if id.trim().is_empty() {
6972 return Err(SourceError::Malformed {
6973 message: "GitHub named a board with a blank node id".into(),
6974 });
6975 }
6976 Ok(Self(id.to_owned()))
6977 }
6978
6979 fn as_str(&self) -> &str {
6980 &self.0
6981 }
6982}
6983
6984impl Board {
6985 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
6986 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
6987 let nodes = fields
6988 .get("nodes")
6989 .and_then(Value::as_array)
6990 .ok_or_else(|| SourceError::Malformed {
6991 message: "GitHub project fields.nodes is not an array".into(),
6992 })?;
6993 Ok(nodes
6994 .iter()
6995 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
6996 }
6997}
6998
6999/// One board item, resolved into everything this source reports about it.
7000#[derive(Clone)]
7001struct Resolved {
7002 item_id: String,
7003 id: NativeId,
7004 content_kind: ContentKind,
7005 kind: BoardKind,
7006 title: String,
7007 body: Option<String>,
7008 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
7009 /// that changes the slot alone has to keep byte for byte outside it.
7010 raw_body: Option<String>,
7011 status: Status,
7012 /// The name of the board `Status` option this item sits in, as the board spells it.
7013 option: Option<String>,
7014 /// What its `Priority` field says, read through this instance's mapping.
7015 priority: HeldPriority,
7016 /// Whether this item's issue is closed. A draft has no such state and is never closed.
7017 closed: bool,
7018 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
7019 delivers: Vec<TaskRef>,
7020 /// Every task that delivers this one, read out of its slot. Empty for anything not a
7021 /// task.
7022 delivered_by: Vec<TaskRef>,
7023 labels: Vec<Label>,
7024 parent: Option<NativeId>,
7025 // llmlint: ignore[invalid_states_unrepresentable] The write side's reason, read back: this is the engine's qualified id, taken out of a board text field and handed on untouched. A newtype here would have this plugin define the syntax of an id `docs/metadata.md` says no plugin ever constructs or interprets.
7026 origin: Option<String>,
7027 /// The issue's own number on its repository, as GitHub reports it.
7028 ///
7029 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
7030 /// declares none, and a draft is not filed in a repository to be numbered by one — and
7031 /// an issue this run created whose creating mutation answered without one, which is a
7032 /// response GitHub's own schema says cannot happen and which a landed write is not
7033 /// worth failing over. An `Issue` read off the board always has one.
7034 number: Option<u64>,
7035 url: Option<String>,
7036 created_at: Option<DateTime<Utc>>,
7037 updated_at: Option<DateTime<Utc>>,
7038 own_repository: Option<Repository>,
7039 repositories: Vec<Repository>,
7040 slot: BTreeMap<String, Value>,
7041 /// The node id of the board this item sits on, when the read that reached it said.
7042 board_id: Option<String>,
7043 /// The definition of every board field this item holds a value of, in the shape a read
7044 /// of the board's own `fields` gives one.
7045 ///
7046 /// Only the fields this item has a value in: a field it holds nothing of is not here,
7047 /// which says nothing about whether the board has it.
7048 fields: Vec<Value>,
7049}
7050
7051impl Resolved {
7052 /// The board this item's own read names it on, when that read named one this source can
7053 /// address.
7054 fn named_board(&self) -> Option<BoardId> {
7055 self.board_id
7056 .as_deref()
7057 .and_then(|id| BoardId::parse(id).ok())
7058 }
7059
7060 /// Whether this item holds a value of the board field called `name`, and so carries
7061 /// that field's definition. `false` says nothing about whether the board has the field.
7062 fn defines(&self, name: &str) -> bool {
7063 self.fields
7064 .iter()
7065 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
7066 }
7067
7068 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
7069 /// in a field of its own, and none of the five keys that are only an encoding.
7070 ///
7071 /// The two delivery keys are left out for every kind, not only for a task: they are
7072 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
7073 /// document carrying one holds nothing a caller's own metadata could mean by it.
7074 fn metadata(&self) -> BTreeMap<String, Value> {
7075 let mut metadata = self.slot.clone();
7076 metadata.remove(Repository::METADATA_KEY);
7077 metadata.remove(DependencyEdge::RECORDED_KEY);
7078 metadata.remove(ItemKind::METADATA_KEY);
7079 metadata.remove(TaskRef::DELIVERS_KEY);
7080 metadata.remove(TaskRef::DELIVERED_BY_KEY);
7081 // The board field is the origin, and the body's copy of it is only a mirror for the
7082 // issue search to find: an item whose field holds none has none, whatever its body
7083 // says, so no reader ever sees two answers.
7084 metadata.remove(ORIGIN_KEY);
7085 if let Some(origin) = &self.origin {
7086 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
7087 }
7088 metadata
7089 }
7090
7091 /// Where this item is, as a link a reader can open.
7092 ///
7093 /// A board is a hosted place and every issue on it has a web address, so that address
7094 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
7095 /// of place it is, so a reader knows to open it rather than to read a file out. It
7096 /// does not replace or derive from `url`: the field goes on reporting exactly what it
7097 /// reported before, and this says what that address *is*.
7098 ///
7099 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
7100 /// rather than a third variant, which is the contract's "the source did not say". An
7101 /// issue this run created is not one of those: its address comes back from the
7102 /// creating mutation, so it is somewhere a reader can open from the moment it exists
7103 /// rather than from whenever the board read catches up.
7104 fn location(&self) -> Option<Location> {
7105 self.url.clone().map(Location::Url)
7106 }
7107
7108 /// The short handle this board's backend shows people for a task: the issue's number
7109 /// alone, as a decimal string.
7110 ///
7111 /// The number alone rather than `owner/repo#1043`, because that is the contract's
7112 /// value for this backend. A draft has no number and so no handle, which is the
7113 /// contract's *absent* rather than a handle of some other shape — and the native
7114 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
7115 /// derives from.
7116 fn key(&self) -> Option<String> {
7117 self.number.map(|number| number.to_string())
7118 }
7119
7120 /// Whether its `Priority` field holds a value at all, mapped or not.
7121 fn holds_priority(&self) -> bool {
7122 self.priority != HeldPriority::Read(Priority::None)
7123 }
7124
7125 /// The task this item is.
7126 ///
7127 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
7128 /// reading that as a level would be a guess, and reading it as `none` would let the next
7129 /// copy clear a priority a person set.
7130 fn task(&self) -> Result<Task, SourceError> {
7131 let priority = match &self.priority {
7132 HeldPriority::Read(priority) => *priority,
7133 HeldPriority::Unmapped(option) => {
7134 return Err(SourceError::Malformed {
7135 message: format!(
7136 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
7137 this source's priority_mapping does not name, so its priority cannot be \
7138 read; next: name {option:?} under priority_mapping, or move the item to \
7139 a mapped option",
7140 self.id,
7141 self.number
7142 .map(|number| format!(" (#{number})"))
7143 .unwrap_or_default()
7144 ),
7145 });
7146 }
7147 };
7148 Ok(Task {
7149 id: self.id.clone(),
7150 key: self.key(),
7151 title: self.title.clone(),
7152 content: self.body.clone(),
7153 status: self.status.clone(),
7154 priority,
7155 labels: self.labels.clone(),
7156 project: self.parent.clone(),
7157 url: self.url.clone(),
7158 location: self.location(),
7159 created_at: self.created_at,
7160 updated_at: self.updated_at,
7161 metadata: self.metadata(),
7162 repositories: self.repositories.clone(),
7163 delivers: self.delivers.clone(),
7164 delivered_by: self.delivered_by.clone(),
7165 })
7166 }
7167
7168 fn project(&self) -> Project {
7169 Project {
7170 id: self.id.clone(),
7171 title: self.title.clone(),
7172 content: self.body.clone(),
7173 status: self.status.clone(),
7174 labels: self.labels.clone(),
7175 url: self.url.clone(),
7176 location: self.location(),
7177 created_at: self.created_at,
7178 updated_at: self.updated_at,
7179 metadata: self.metadata(),
7180 repositories: self.repositories.clone(),
7181 }
7182 }
7183
7184 /// The same issue as a document: the project it is filed under, and no status and no
7185 /// dependencies, because a document is not work.
7186 fn document(&self) -> Document {
7187 Document {
7188 id: self.id.clone(),
7189 title: self.title.clone(),
7190 content: self.body.clone(),
7191 project: self.parent.clone(),
7192 labels: self.labels.clone(),
7193 url: self.url.clone(),
7194 location: self.location(),
7195 created_at: self.created_at,
7196 updated_at: self.updated_at,
7197 metadata: self.metadata(),
7198 repositories: self.repositories.clone(),
7199 }
7200 }
7201}
7202
7203/// Where one targeted update moves an item's status, and which of its two halves move.
7204struct StatusMove {
7205 /// The board the item's `Status` field is on.
7206 board: BoardId,
7207 /// The `Status` field's id.
7208 field: String,
7209 /// The option's id.
7210 option: String,
7211 /// The option's name, as the board spells it.
7212 name: String,
7213 /// What the status asks of the issue's state.
7214 target: StatusTarget,
7215 /// The status the item reads as once it is there.
7216 landed: Status,
7217 /// Which of the status's two halves differ from what the item holds.
7218 moves: Moves,
7219}
7220
7221/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
7222/// closed state of its issue, or both. A status neither half of which differs is no move at all,
7223/// and is not a value of this type.
7224#[derive(Clone, Copy, PartialEq, Eq)]
7225enum Moves {
7226 /// The option alone.
7227 Option,
7228 /// The issue's state alone: open, closed, or closed with another reason.
7229 State,
7230 /// Both.
7231 Both,
7232}
7233
7234impl Moves {
7235 /// What differs, or `None` when nothing does.
7236 const fn of(option: bool, state: bool) -> Option<Self> {
7237 match (option, state) {
7238 (true, true) => Some(Self::Both),
7239 (true, false) => Some(Self::Option),
7240 (false, true) => Some(Self::State),
7241 (false, false) => None,
7242 }
7243 }
7244
7245 /// Whether the option moves.
7246 const fn option(self) -> bool {
7247 matches!(self, Self::Option | Self::Both)
7248 }
7249
7250 /// Whether the issue's state moves.
7251 const fn state(self) -> bool {
7252 matches!(self, Self::State | Self::Both)
7253 }
7254}
7255
7256/// What one write is, and the status that comes with being it.
7257///
7258/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
7259/// status and a task or a project always has one, so "a document carrying a status" and
7260/// "a task carrying none" are states a write cannot be in rather than states every use
7261/// site below has to defend against.
7262enum Written<'a> {
7263 /// A document, which is not work and so has no status at all.
7264 Document,
7265 /// A task or a project, and the status it is being written with.
7266 Work(ItemKind, &'a Status),
7267}
7268
7269impl Written<'_> {
7270 /// Which of the board's three kinds this write is.
7271 const fn kind(&self) -> BoardKind {
7272 match self {
7273 Self::Document => BoardKind::Document,
7274 Self::Work(kind, _) => BoardKind::Work(*kind),
7275 }
7276 }
7277
7278 /// The status this write carries. A document carries none, so a write of one says
7279 /// nothing about the issue's open or closed state and selects no board `Status`
7280 /// option.
7281 const fn status(&self) -> Option<&Status> {
7282 match self {
7283 Self::Document => None,
7284 Self::Work(_, status) => Some(status),
7285 }
7286 }
7287}
7288
7289/// The item being written, in the one shape all three write methods reach.
7290struct Incoming<'a> {
7291 written: Written<'a>,
7292 /// The title a person wrote. A document's goes onto the issue with
7293 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
7294 title: &'a str,
7295 content: Option<&'a str>,
7296 labels: &'a [Label],
7297 metadata: &'a BTreeMap<String, Value>,
7298 repositories: &'a [Repository],
7299 parent: Option<&'a NativeId>,
7300 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
7301 /// what keeps either key out of their slot.
7302 delivers: &'a [TaskRef],
7303 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
7304 delivered_by: &'a [TaskRef],
7305 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
7306 /// project, a document, and every write to an instance with no `priority_mapping` —
7307 /// which is what keeps such a write's requests exactly what they were before.
7308 priority: Option<Priority>,
7309}
7310
7311/// What one write does to an item's `Priority` field.
7312enum PriorityWrite {
7313 /// Select this option of this field.
7314 Select {
7315 /// The `Priority` field's id.
7316 field: String,
7317 /// The mapped option's id.
7318 option: String,
7319 },
7320 /// Clear the field's value, which is what `none` is.
7321 Clear {
7322 /// The `Priority` field's id.
7323 field: String,
7324 },
7325}
7326
7327impl Incoming<'_> {
7328 /// The title this write puts on the issue.
7329 fn written_title(&self) -> String {
7330 match self.written {
7331 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
7332 Written::Work(..) => self.title.to_owned(),
7333 }
7334 }
7335}
7336
7337#[derive(Clone, Copy, PartialEq, Eq)]
7338enum ContentKind {
7339 DraftIssue,
7340 Issue,
7341}
7342
7343/// What one board issue is: a document, or the work an [`ItemKind`] names.
7344///
7345/// A type of this source's own rather than an `ItemKind` with a third variant, because
7346/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
7347/// document — the contract keeps a document out of that enum deliberately. Holding the
7348/// board's three answers in one value is what makes every place that asks "which is this?"
7349/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
7350/// two thirds of the board.
7351#[derive(Clone, Copy, PartialEq, Eq)]
7352enum BoardKind {
7353 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
7354 Document,
7355 /// Every other issue, and every draft.
7356 Work(ItemKind),
7357}
7358
7359impl BoardKind {
7360 /// How a refusal names this kind to the person reading it.
7361 const fn describes(self) -> &'static str {
7362 match self {
7363 Self::Document => "document",
7364 Self::Work(kind) => kind.marker(),
7365 }
7366 }
7367}
7368
7369/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
7370///
7371/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
7372/// the shared cross-source journeys assert one answer to one question, so two sources
7373/// that disagree about what "carries the label bug" means fail them.
7374fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
7375 let holds = |name: &String| {
7376 labels
7377 .iter()
7378 .any(|label| label.name.eq_ignore_ascii_case(name))
7379 };
7380 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
7381 && filter.all_of.iter().all(holds)
7382 && !filter.none_of.iter().any(holds)
7383}
7384
7385/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
7386/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
7387fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
7388 statuses.is_empty() || statuses.contains(&category)
7389}
7390
7391/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
7392///
7393/// `content` is the item's own prose — the body with this source's trailing metadata
7394/// comment already taken off — so a search never matches an encoding the author of the
7395/// issue never wrote.
7396fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
7397 let terms = query.terms.to_lowercase();
7398 let in_title = title.to_lowercase().contains(&terms);
7399 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
7400 match query.fields {
7401 TextFields::Title => in_title,
7402 TextFields::Content => in_content,
7403 TextFields::TitleOrContent => in_title || in_content,
7404 }
7405}
7406
7407/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
7408///
7409/// The project predicate is passed separately because a read narrowed to one project has
7410/// already answered it by asking *that project* for its own items — and re-applying it
7411/// there would compare the caller's selector, which may be a project's **name**, against
7412/// the id of the project that name resolved to, and keep nothing. Every other read passes
7413/// `query.project` and applies it here, which is what keeps `projects` a predicate this
7414/// source really does apply.
7415fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
7416 labels_match(&task.labels, &query.labels)
7417 && status_matches(task.status.category, &query.statuses)
7418 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
7419 && match project {
7420 ProjectFilter::Any => true,
7421 ProjectFilter::Orphans => task.project.is_none(),
7422 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
7423 }
7424 && query
7425 .text
7426 .as_ref()
7427 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
7428 // Against the parsed metadata slot, and against the origin field, which is where
7429 // `Resolved::metadata` reads each of them from.
7430 && query.metadata_matches(&task.metadata)
7431 && query.origin_matches(&task.metadata)
7432}
7433
7434fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
7435 labels_match(&project.labels, &query.labels)
7436 && status_matches(project.status.category, &query.statuses)
7437 && query
7438 .text
7439 .as_ref()
7440 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
7441}
7442
7443/// The same three predicates a task query carries, minus the status filter.
7444///
7445/// A document is not work, so it has no status for one to compare against and the query
7446/// type carries none. The project predicate is the same one — a design issue filed under a
7447/// project issue is in that project, and one filed under nothing is in none — so it is
7448/// spelled the same way here rather than answered differently.
7449fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
7450 labels_match(&document.labels, &query.labels)
7451 && match project {
7452 ProjectFilter::Any => true,
7453 ProjectFilter::Orphans => document.project.is_none(),
7454 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
7455 }
7456 && query
7457 .text
7458 .as_ref()
7459 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
7460}
7461
7462#[async_trait::async_trait]
7463impl TaskSource for GitHubProjectsSource {
7464 fn kind(&self) -> &'static str {
7465 KIND
7466 }
7467 fn capabilities(&self) -> Capabilities {
7468 Capabilities {
7469 projects: Support::Native,
7470 documents: Support::Native,
7471 comments: Support::Native,
7472 priority: if self.priorities.is_some() {
7473 Support::Native
7474 } else {
7475 Support::Unsupported
7476 },
7477 filter_by_priority: Support::Native,
7478 filter_by_comment_activity: Support::Native,
7479 filter_by_metadata: Support::Native,
7480 filter_by_origin: Support::Native,
7481 orphan_tasks: Support::Native,
7482 filter_by_label: Support::Native,
7483 filter_by_status: Support::Native,
7484 search_title: Support::Native,
7485 search_content: Support::Native,
7486 task_dependencies: DependencySupport::BothDirections,
7487 project_dependencies: DependencySupport::BothDirections,
7488 max_page_size: MAX_PAGE_SIZE,
7489 }
7490 }
7491 async fn health(&self) -> Result<Health, SourceError> {
7492 let board = self.board_page(None, 1).await?;
7493 Ok(Health {
7494 reachable: true,
7495 detail: Some(format!(
7496 "reading GitHub project {}/{} ({})",
7497 self.owner,
7498 self.project_number,
7499 required_str(&board, "title")?
7500 )),
7501 })
7502 }
7503 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
7504 self.item_by_id(id)
7505 .await?
7506 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7507 .map(|item| item.task())
7508 .transpose()
7509 }
7510 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
7511 Ok(self
7512 .item_by_id(id)
7513 .await?
7514 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7515 .map(|item| item.project()))
7516 }
7517 async fn query_tasks(
7518 &self,
7519 query: &TaskQuery,
7520 page: &PageRequest,
7521 ) -> Result<Page<Task>, SourceError> {
7522 validate_page(page)?;
7523 refuse_unsearchable(query)?;
7524 // A read narrowed to one project asks that project for its own tasks, so nothing
7525 // about it costs what the rest of the board holds. A read carrying a text, metadata
7526 // or origin predicate asks GitHub the narrower question those predicates are, and a
7527 // read narrowed to comment activity alone asks the board's own issue search for the
7528 // issues updated since, which is every issue a comment could have been written or
7529 // edited on since. Every other task read is a question about the whole board and is
7530 // answered by reading it.
7531 let (held, membership) = match (&query.project, query.commented_since) {
7532 (ProjectFilter::Is(project), _) => (
7533 self.project_children(project).await?,
7534 // Answered by where these items came from; see `task_matches`.
7535 &ProjectFilter::Any,
7536 ),
7537 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
7538 match (self.narrowed(query).await?, since) {
7539 (Some(narrowed), _) => (narrowed, &query.project),
7540 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
7541 (None, None) => (self.board().await?.items, &query.project),
7542 }
7543 }
7544 };
7545 // Filtered before paged: a page of a filtered result is a page of the survivors,
7546 // never the survivors of a page.
7547 let mut tasks = Vec::new();
7548 for item in held
7549 .iter()
7550 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7551 {
7552 let task = item.task()?;
7553 if task_matches(&task, query, membership)
7554 && self.commented_since(item, query.commented_since).await?
7555 {
7556 tasks.push(task);
7557 }
7558 }
7559 Ok(offset_page(
7560 tasks,
7561 numeric_cursor(page.cursor.as_ref())?,
7562 page.limit.min(MAX_PAGE_SIZE) as usize,
7563 ))
7564 }
7565 async fn query_projects(
7566 &self,
7567 query: &ProjectQuery,
7568 page: &PageRequest,
7569 ) -> Result<Page<Project>, SourceError> {
7570 validate_page(page)?;
7571 // The projects a board holds are found by an issue search scoped to that board,
7572 // never by walking the board's own item connection: what tells a project from a
7573 // task is the `parent` each issue carries, which costs nothing to read.
7574 let projects = self
7575 .board_issues()
7576 .await?
7577 .iter()
7578 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7579 .map(Resolved::project)
7580 .filter(|project| project_matches(project, query))
7581 .collect();
7582 Ok(offset_page(
7583 projects,
7584 numeric_cursor(page.cursor.as_ref())?,
7585 page.limit.min(MAX_PAGE_SIZE) as usize,
7586 ))
7587 }
7588 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
7589 Ok(self
7590 .item_by_id(id)
7591 .await?
7592 .filter(|item| item.kind == BoardKind::Document)
7593 .map(|item| item.document()))
7594 }
7595 async fn query_documents(
7596 &self,
7597 query: &DocumentQuery,
7598 page: &PageRequest,
7599 ) -> Result<Page<Document>, SourceError> {
7600 validate_page(page)?;
7601 // Narrowed to one project, this is the same sub-issue read a task list scoped to
7602 // that project makes — a document filed under a project is a sub-issue of it too,
7603 // and which of them come back is the kind this caller asked for.
7604 let (held, membership) = match &query.project {
7605 ProjectFilter::Is(project) => (
7606 self.project_children(project).await?,
7607 // Answered by where these items came from; see `task_matches`.
7608 &ProjectFilter::Any,
7609 ),
7610 ProjectFilter::Any | ProjectFilter::Orphans => {
7611 (self.board().await?.items, &query.project)
7612 }
7613 };
7614 // Filtered before paged, exactly as a task read is: a page of a filtered result is
7615 // a page of the survivors, never the survivors of a page.
7616 let documents = held
7617 .iter()
7618 .filter(|item| item.kind == BoardKind::Document)
7619 .map(Resolved::document)
7620 .filter(|document| document_matches(document, query, membership))
7621 .collect();
7622 Ok(offset_page(
7623 documents,
7624 numeric_cursor(page.cursor.as_ref())?,
7625 page.limit.min(MAX_PAGE_SIZE) as usize,
7626 ))
7627 }
7628 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
7629 validate_page(page)?;
7630 let offset = numeric_cursor(page.cursor.as_ref())?;
7631 let mut labels = self
7632 .board()
7633 .await?
7634 .items
7635 .into_iter()
7636 .flat_map(|item| item.labels)
7637 .fold(Vec::new(), |mut all, label| {
7638 if !all.iter().any(|x: &Label| x.id == label.id) {
7639 all.push(label);
7640 }
7641 all
7642 });
7643 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
7644 Ok(offset_page(
7645 labels,
7646 offset,
7647 page.limit.min(MAX_PAGE_SIZE) as usize,
7648 ))
7649 }
7650 async fn task_dependencies(
7651 &self,
7652 id: &NativeId,
7653 direction: Direction,
7654 page: &PageRequest,
7655 ) -> Result<Page<DependencyEdge>, SourceError> {
7656 self.dependencies(id, ItemKind::Task, direction, page).await
7657 }
7658 async fn project_dependencies(
7659 &self,
7660 id: &NativeId,
7661 direction: Direction,
7662 page: &PageRequest,
7663 ) -> Result<Page<DependencyEdge>, SourceError> {
7664 self.dependencies(id, ItemKind::Project, direction, page)
7665 .await
7666 }
7667
7668 fn writes(&self) -> WriteSupport {
7669 WriteSupport::Supported
7670 }
7671
7672 /// Create or update one task.
7673 ///
7674 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
7675 /// neither may name the task itself or name one task twice — and land in the body's
7676 /// metadata slot under their reserved keys, in place of any caller metadata of those
7677 /// names.
7678 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
7679 let near = write.target.as_ref().unwrap_or(&write.item.id);
7680 for (key, entries) in [
7681 (TaskRef::DELIVERS_KEY, &write.item.delivers),
7682 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
7683 ] {
7684 TaskRef::listed(key, near, Some(&self.name), entries.clone())
7685 .map_err(|message| SourceError::Refused { message })?;
7686 }
7687 if self.priorities.is_none() && write.item.priority != Priority::None {
7688 return Err(self.holds_no_priority());
7689 }
7690 self.write_item(
7691 &Incoming {
7692 written: Written::Work(ItemKind::Task, &write.item.status),
7693 title: &write.item.title,
7694 content: write.item.content.as_deref(),
7695 labels: &write.item.labels,
7696 metadata: &write.item.metadata,
7697 repositories: &write.item.repositories,
7698 parent: write.item.project.as_ref(),
7699 delivers: &write.item.delivers,
7700 delivered_by: &write.item.delivered_by,
7701 priority: self.priorities.as_ref().map(|_| write.item.priority),
7702 },
7703 write.target.as_ref(),
7704 &write.depends_on,
7705 )
7706 .await
7707 }
7708
7709 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
7710 self.write_item(
7711 &Incoming {
7712 written: Written::Work(ItemKind::Project, &write.item.status),
7713 title: &write.item.title,
7714 content: write.item.content.as_deref(),
7715 labels: &write.item.labels,
7716 metadata: &write.item.metadata,
7717 repositories: &write.item.repositories,
7718 parent: None,
7719 delivers: &[],
7720 delivered_by: &[],
7721 priority: None,
7722 },
7723 write.target.as_ref(),
7724 &write.depends_on,
7725 )
7726 .await
7727 }
7728
7729 /// Create or update one document, which is one issue titled the way this board spells
7730 /// a document.
7731 ///
7732 /// Everything else is exactly a task write: caller metadata goes to the same canonical
7733 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
7734 /// or a field this board cannot carry is refused by name rather than dropped, a target
7735 /// naming an issue this board does not hold is refused rather than created, and an
7736 /// issue this call created is taken back when the rest of the write fails.
7737 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
7738 // A document takes part in no dependency graph, so there is no far end to write
7739 // natively and none to record: a caller naming one is told so rather than having it
7740 // stored under the reserved key, where a later read would report an edge the
7741 // contract says cannot exist.
7742 if !write.depends_on.is_empty() {
7743 return Err(SourceError::Refused {
7744 message: format!(
7745 "this write names {} dependencies for a document, and a document takes \
7746 part in no dependency graph; next: put the dependency on the task or \
7747 project the document is about",
7748 write.depends_on.len()
7749 ),
7750 });
7751 }
7752 self.write_item(
7753 &Incoming {
7754 written: Written::Document,
7755 title: &write.item.title,
7756 content: write.item.content.as_deref(),
7757 labels: &write.item.labels,
7758 metadata: &write.item.metadata,
7759 repositories: &write.item.repositories,
7760 parent: write.item.project.as_ref(),
7761 delivers: &[],
7762 delivered_by: &[],
7763 priority: None,
7764 },
7765 write.target.as_ref(),
7766 &[],
7767 )
7768 .await
7769 }
7770
7771 /// Set one task's status alone.
7772 ///
7773 /// An open target reopens a closed issue with an `updateIssue` carrying only its
7774 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
7775 /// terminal target selects its mapped option, then closes with its fixed reason. No
7776 /// request carries a title, a body or a label. The status
7777 /// answered is what [`StatusMapping::status`] reads off the state just written, which is
7778 /// what a re-read reports.
7779 async fn set_task_status(
7780 &self,
7781 id: &NativeId,
7782 category: StatusCategory,
7783 ) -> Result<Option<Status>, SourceError> {
7784 self.set_status(id, category).await
7785 }
7786
7787 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
7788 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
7789 /// for `none`. Refused by an instance with no `priority_mapping`.
7790 async fn set_task_priority(
7791 &self,
7792 id: &NativeId,
7793 priority: Priority,
7794 ) -> Result<Option<Priority>, SourceError> {
7795 self.set_priority(id, priority).await
7796 }
7797
7798 /// Replace one task's content with a single body update that keeps the metadata slot
7799 /// byte for byte.
7800 async fn set_task_content(
7801 &self,
7802 id: &NativeId,
7803 content: &str,
7804 ) -> Result<Option<()>, SourceError> {
7805 self.replace_content(id, content).await
7806 }
7807
7808 /// Replace one task issue's content and its provenance slot entry with a single body
7809 /// update. The answers are not kept: see `replace_rendering`.
7810 async fn set_task_rendering(
7811 &self,
7812 id: &NativeId,
7813 content: &str,
7814 provenance: &Value,
7815 _answers: &BTreeMap<String, Value>,
7816 ) -> Result<Option<()>, SourceError> {
7817 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
7818 .await
7819 }
7820
7821 /// Replace one design-document issue's content and its provenance slot entry, on exactly
7822 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
7823 async fn set_document_rendering(
7824 &self,
7825 id: &NativeId,
7826 content: &str,
7827 provenance: &Value,
7828 _answers: &BTreeMap<String, Value>,
7829 ) -> Result<Option<()>, SourceError> {
7830 self.replace_rendering(id, BoardKind::Document, content, provenance)
7831 .await
7832 }
7833
7834 /// Apply a targeted update with one read of the item and a write only for what differs:
7835 /// at most one `updateIssue` for title, body and state, one field write each for `Status`
7836 /// and `Priority`, and the `blockedBy` difference. See `targeted_update`.
7837 async fn update_task(
7838 &self,
7839 id: &NativeId,
7840 update: &TaskUpdate,
7841 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7842 self.targeted_update(id, update).await
7843 }
7844
7845 /// Replace one task's `delivered_by` with a single body update that changes the
7846 /// metadata slot and nothing outside it.
7847 async fn set_delivered_by(
7848 &self,
7849 id: &NativeId,
7850 delivered_by: &[TaskRef],
7851 ) -> Result<Option<()>, SourceError> {
7852 self.replace_delivered_by(id, delivered_by).await
7853 }
7854
7855 /// Set one key of one task issue's metadata with a single body update that changes the
7856 /// metadata slot and nothing outside it — no title, label, state or board field request —
7857 /// and sends nothing when the task already holds that value under the key.
7858 async fn set_task_metadata(
7859 &self,
7860 id: &NativeId,
7861 key: &MetadataKey,
7862 value: &Value,
7863 ) -> Result<Option<Task>, SourceError> {
7864 Ok(self
7865 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
7866 .await?
7867 .map(|item| item.task())
7868 .transpose()?)
7869 }
7870
7871 /// Set one key of one project issue's metadata, on exactly the terms of
7872 /// [`set_task_metadata`](TaskSource::set_task_metadata).
7873 async fn set_project_metadata(
7874 &self,
7875 id: &NativeId,
7876 key: &MetadataKey,
7877 value: &Value,
7878 ) -> Result<Option<Project>, SourceError> {
7879 Ok(self
7880 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
7881 .await?
7882 .map(|item| item.project()))
7883 }
7884
7885 /// Set one key of one design-document issue's metadata, on exactly the terms of
7886 /// [`set_task_metadata`](TaskSource::set_task_metadata).
7887 async fn set_document_metadata(
7888 &self,
7889 id: &NativeId,
7890 key: &MetadataKey,
7891 value: &Value,
7892 ) -> Result<Option<Document>, SourceError> {
7893 Ok(self
7894 .set_slot_key(id, BoardKind::Document, key, value)
7895 .await?
7896 .map(|item| item.document()))
7897 }
7898
7899 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
7900 self.delete_item(id).await
7901 }
7902
7903 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
7904 self.delete_item(id).await
7905 }
7906
7907 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
7908 self.delete_item(id).await
7909 }
7910
7911 /// One page of the task issue's own comments, walked by GitHub's own cursor.
7912 ///
7913 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
7914 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
7915 async fn task_comments(
7916 &self,
7917 task: &NativeId,
7918 page: &PageRequest,
7919 ) -> Result<Option<Page<Comment>>, SourceError> {
7920 validate_page(page)?;
7921 let Some(issue) = self.commented_issue(task).await? else {
7922 return Ok(None);
7923 };
7924 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7925 let data = self
7926 .graphql(
7927 graphql::ISSUE_COMMENTS,
7928 json!({"id":issue.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after}),
7929 )
7930 .await?;
7931 // The issue was there a moment ago; one removed since is no longer a task here.
7932 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7933 return Ok(None);
7934 };
7935 let connection = node
7936 .get("comments")
7937 .filter(|value| !value.is_null())
7938 .ok_or_else(|| SourceError::Malformed {
7939 message: format!(
7940 "GitHub issue {} answered with no comments connection",
7941 issue.0
7942 ),
7943 })?;
7944 let items = optional_nodes(Some(connection), "issue comments")?
7945 .into_iter()
7946 .flatten()
7947 .map(comment_from)
7948 .collect::<Result<Vec<_>, _>>()?;
7949 let next = next_cursor(connection)?;
7950 if let Some(next) = &next {
7951 validate_cursor_progress(after, &next.0)?;
7952 }
7953 Ok(Some(Page { items, next }))
7954 }
7955
7956 /// Add one comment to the task's issue, as the account the token belongs to.
7957 ///
7958 /// The author is refused before anything is sent — not even the task is read — because
7959 /// no answer GitHub could give would make posting under another name than the one asked
7960 /// for the right outcome.
7961 async fn add_comment(
7962 &self,
7963 task: &NativeId,
7964 comment: &NewComment,
7965 ) -> Result<Option<Comment>, SourceError> {
7966 if let Some(author) = &comment.author {
7967 return Err(SourceError::Refused {
7968 message: format!(
7969 "source {} cannot post a comment as {author:?}: GitHub records the account \
7970 the token signs in as the author of every comment; next: leave --author \
7971 out, and the comment is posted as that account",
7972 self.name
7973 ),
7974 });
7975 }
7976 let Some(issue) = self.commented_issue(task).await? else {
7977 return Ok(None);
7978 };
7979 let data = self
7980 .graphql(
7981 graphql::ADD_COMMENT,
7982 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
7983 )
7984 .await?;
7985 let subject = data
7986 .pointer("/addComment/subject")
7987 .filter(|value| !value.is_null())
7988 .ok_or_else(|| SourceError::Malformed {
7989 message: "GitHub comment addition returned no subject".into(),
7990 })?;
7991 if required_str(subject, "id")? != issue.0 {
7992 return Err(SourceError::Malformed {
7993 message: "GitHub comment addition answered about another issue".into(),
7994 });
7995 }
7996 let added = data
7997 .pointer("/addComment/commentEdge/node")
7998 .filter(|value| !value.is_null())
7999 .ok_or_else(|| SourceError::Malformed {
8000 message: "GitHub comment addition returned no comment".into(),
8001 })?;
8002 comment_from(added).map(Some)
8003 }
8004
8005 async fn edit_comment(
8006 &self,
8007 task: &NativeId,
8008 comment: &NativeId,
8009 body: &CommentBody,
8010 ) -> Result<Option<Comment>, SourceError> {
8011 let Some(issue) = self.commented_issue(task).await? else {
8012 return Ok(None);
8013 };
8014 if !self.comment_is_on(&issue, comment).await? {
8015 return Ok(None);
8016 }
8017 let data = self
8018 .graphql(
8019 graphql::UPDATE_COMMENT,
8020 json!({"input":{"id":comment.0,"body":body.as_str()}}),
8021 )
8022 .await?;
8023 let edited = data
8024 .pointer("/updateIssueComment/issueComment")
8025 .filter(|value| !value.is_null())
8026 .ok_or_else(|| SourceError::Malformed {
8027 message: "GitHub comment update returned no comment".into(),
8028 })?;
8029 let edited = comment_from(edited)?;
8030 if edited.id != *comment {
8031 return Err(SourceError::Malformed {
8032 message: "GitHub comment update returned the wrong comment".into(),
8033 });
8034 }
8035 Ok(Some(edited))
8036 }
8037
8038 async fn delete_comment(
8039 &self,
8040 task: &NativeId,
8041 comment: &NativeId,
8042 ) -> Result<Option<NativeId>, SourceError> {
8043 let Some(issue) = self.commented_issue(task).await? else {
8044 return Ok(None);
8045 };
8046 if !self.comment_is_on(&issue, comment).await? {
8047 return Ok(None);
8048 }
8049 let data = self
8050 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
8051 .await?;
8052 // The payload says nothing about the comment it removed, so what is checked is that
8053 // GitHub answered the mutation at all rather than leaving it unanswered.
8054 data.get("deleteIssueComment")
8055 .filter(|value| !value.is_null())
8056 .ok_or_else(|| SourceError::Malformed {
8057 message: "GitHub comment deletion returned no payload".into(),
8058 })?;
8059 Ok(Some(comment.clone()))
8060 }
8061
8062 /// Every request this source has recorded, and what each of GitHub's two budgets was
8063 /// attributed — read off the same accounting the session report is rendered from, so
8064 /// the two cannot count one request two ways.
8065 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
8066 Ok(Some(self.ledger.snapshot().metering()))
8067 }
8068}
8069
8070/// One issue comment as the contract carries it.
8071///
8072/// `author` is absent both when GitHub answers `null` for an account that no longer exists
8073/// and when it answers an actor with no login, because either way the source did not say who
8074/// wrote it — which is what an absent author means, rather than an author called nothing.
8075fn comment_from(value: &Value) -> Result<Comment, SourceError> {
8076 Ok(Comment {
8077 id: NativeId(required_str(value, "id")?.to_owned()),
8078 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
8079 .map(str::to_owned),
8080 created_at: optional_time(value, "createdAt")?,
8081 updated_at: optional_time(value, "updatedAt")?,
8082 body: required_str(value, "body")?.to_owned(),
8083 url: optional_str(value, "url")?.map(str::to_owned),
8084 })
8085}
8086
8087/// Where the recorded tail of a dependency walk resumes; see
8088/// [`GitHubProjectsSource::recorded_edges`].
8089const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
8090
8091/// The board text field this source keeps a copy's origin in.
8092///
8093/// Named after the key it holds, and held to that name by the guard below rather than by
8094/// a reader noticing.
8095const ORIGIN_FIELD: &str = "onetaskgraph.origin";
8096
8097/// The metadata key that field holds.
8098///
8099/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
8100/// constructs or interprets the qualified id it carries. This source names it only to
8101/// route it — a short, typed value belongs in a typed field rather than in the body slot
8102/// a caller's own prose shares.
8103///
8104/// Restated rather than imported, because no plugin crate may depend on the engine. What
8105/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
8106/// target in `check`: it reads the engine's own literal and fails naming the file and the
8107/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
8108/// that creates a second item every run instead of finding the one it wrote — and that is
8109/// too late to learn it.
8110const ORIGIN_KEY: &str = "onetaskgraph.origin";
8111
8112/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
8113///
8114/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
8115/// is derived from the far end, never written down on the near item — so only a forward
8116/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
8117/// it did not come from, and it is told so rather than answered with an empty page that
8118/// reads as a walk which ended.
8119fn recorded_offset(
8120 cursor: Option<&str>,
8121 direction: Direction,
8122) -> Result<Option<usize>, SourceError> {
8123 cursor
8124 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
8125 .map(|offset| {
8126 if direction != Direction::DependsOn {
8127 return Err(SourceError::Config {
8128 message: format!(
8129 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
8130 reverse dependency read never issues; resume it in the direction \
8131 that reported it"
8132 ),
8133 });
8134 }
8135 offset.parse().map_err(|_| SourceError::Config {
8136 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
8137 })
8138 })
8139 .transpose()
8140}
8141
8142fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
8143 let mut page = offset_page(edges, offset, limit.max(1));
8144 page.next = page
8145 .next
8146 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
8147 page
8148}
8149
8150/// The kind of one issue reached through a dependency connection.
8151///
8152/// The same questions the board scan asks, over the fields the dependency document
8153/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
8154/// then anything with sub-issues or the marker is a project.
8155///
8156/// # Errors
8157///
8158/// A far end this board holds as a document is refused rather than reported. The two
8159/// answers that are not refusals would both be wrong: reporting it as a task names an id
8160/// no task read of this source can find, and reporting it as a project names one no
8161/// project read can. There is no third value to return — `ItemKind` has no document
8162/// variant, because nothing may point at a document — so the relationship itself is what
8163/// the person is told about.
8164fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
8165 let id = required_str(value, "id")?;
8166 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
8167 return Err(SourceError::Refused {
8168 message: format!(
8169 "GitHub issue {id} is a document of this board — its title begins \
8170 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
8171 on by one; next: remove that issue's blocking relationship on this board"
8172 ),
8173 });
8174 }
8175 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
8176 if parent.is_some() {
8177 return Ok(ItemKind::Task);
8178 }
8179 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
8180 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8181 message: format!("GitHub issue {id}: {message}"),
8182 })?;
8183 let sub_issues = sub_issue_total(value)?;
8184 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
8185 ItemKind::Project
8186 } else {
8187 ItemKind::Task
8188 })
8189}
8190
8191/// The `IssueStateUpdateInput` one status target asks for.
8192///
8193/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
8194/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
8195/// a currently-closed issue: without that the item would read back `Unknown` and a copy
8196/// would report a change forever. A document has no status at all, and asks for neither.
8197fn state_input(target: Option<&StatusTarget>) -> Value {
8198 match target {
8199 Some(StatusTarget::Terminal(_, reason)) => {
8200 json!({"value":"CLOSED","stateReason":reason.reason()})
8201 }
8202 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
8203 // A document has no status, so a write of one says nothing about the issue's open
8204 // or closed state rather than forcing it open: `stateInput` is what carries that
8205 // instruction, and an explicit null asks for no change to it.
8206 None => Value::Null,
8207 }
8208}
8209
8210/// The metadata one write stores in the item's body slot.
8211///
8212/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
8213/// rather than carried: the kind marker so an empty project stays readable, the
8214/// repository list only when it is not exactly the issue's own repository, and the far
8215/// ends no relationship here can name.
8216///
8217/// The copy origin is the one typed field that is also mirrored here, and only as a
8218/// mirror: it lands in the board's origin field as well, which stays the one every reader
8219/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
8220/// and catches up with a write in seconds rather than minutes — can find the item by it.
8221/// A reader of the release before this one drops the slot's copy and reads the field, so an
8222/// item written here still reads with exactly one origin there.
8223fn slot_metadata(
8224 incoming: &Incoming<'_>,
8225 own_repository: Option<&Repository>,
8226 fallback: &[DependencyEdge],
8227) -> BTreeMap<String, Value> {
8228 let mut metadata = incoming.metadata.clone();
8229 match metadata.remove(ORIGIN_KEY) {
8230 Some(Value::String(origin)) if !origin.is_empty() => {
8231 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
8232 }
8233 _ => {}
8234 }
8235 match incoming.written.kind() {
8236 BoardKind::Work(kind) => metadata.insert(
8237 ItemKind::METADATA_KEY.to_owned(),
8238 Value::String(kind.marker().to_owned()),
8239 ),
8240 // A document is told by its title, so it carries no kind marker: that key names
8241 // what a dependency endpoint points at, and nothing may point at a document.
8242 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
8243 };
8244 let derivable = own_repository
8245 .map(|own| incoming.repositories == [own.clone()])
8246 .unwrap_or(incoming.repositories.is_empty());
8247 if derivable {
8248 metadata.remove(Repository::METADATA_KEY);
8249 } else {
8250 metadata.insert(
8251 Repository::METADATA_KEY.to_owned(),
8252 Value::Array(
8253 incoming
8254 .repositories
8255 .iter()
8256 .map(|repository| Value::String(repository.as_str().to_owned()))
8257 .collect(),
8258 ),
8259 );
8260 }
8261 // The typed lists are what land, whatever the caller's own metadata held under their
8262 // keys: a key of either name travelling beside the field would otherwise be a second
8263 // answer to the same question, and the field is the one the contract names.
8264 for (key, entries) in [
8265 (TaskRef::DELIVERS_KEY, incoming.delivers),
8266 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
8267 ] {
8268 set_task_list(&mut metadata, key, entries);
8269 }
8270 record_edges(&mut metadata, fallback);
8271 metadata
8272}
8273
8274/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
8275/// one slot's metadata, or no such key when there are none.
8276fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
8277 if fallback.is_empty() {
8278 metadata.remove(DependencyEdge::RECORDED_KEY);
8279 } else {
8280 metadata.insert(
8281 DependencyEdge::RECORDED_KEY.to_owned(),
8282 Value::Array(
8283 fallback
8284 .iter()
8285 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
8286 .collect(),
8287 ),
8288 );
8289 }
8290}
8291
8292/// Every label one item carries, from its content's own connection and nowhere else.
8293///
8294/// There is no second place to read one from: no document this source sends selects the
8295/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
8296/// cannot carry one at all. The module documentation records the three schema facts that
8297/// settle it.
8298fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
8299 optional_nodes(content.get("labels"), "content labels")?
8300 .into_iter()
8301 .flatten()
8302 .map(|v| {
8303 Ok(Label {
8304 id: NativeId(required_str(v, "id")?.to_owned()),
8305 name: required_str(v, "name")?.to_owned(),
8306 color: optional_str(v, "color")?.map(str::to_owned),
8307 })
8308 })
8309 .collect()
8310}
8311
8312/// The definition of each board field one item's values are values of, in the shape a read
8313/// of the board's own `fields` gives one.
8314///
8315/// A value names its field through a fragment on that field's own type, so the type is
8316/// known from which kind of value it is: a single-select value's field is a
8317/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
8318/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
8319fn field_definitions(field_values: &[Value]) -> Vec<Value> {
8320 field_values
8321 .iter()
8322 .filter_map(|value| {
8323 let field = value.get("field")?.as_object()?;
8324 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
8325 let typename = if value.get("text").is_some() {
8326 "ProjectV2Field"
8327 } else if value.get("name").is_some() {
8328 "ProjectV2SingleSelectField"
8329 } else {
8330 return None;
8331 };
8332 let mut defined = field.clone();
8333 defined.insert("__typename".to_owned(), json!(typename));
8334 Some(Value::Object(defined))
8335 })
8336 .collect()
8337}
8338
8339fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
8340 let Some(node) = field_values
8341 .iter()
8342 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
8343 else {
8344 return Ok(None);
8345 };
8346 Ok(optional_str(node, "text")?.map(str::to_owned))
8347}
8348
8349fn valid_github_owner(owner: &str) -> bool {
8350 !owner.is_empty()
8351 && owner.len() <= 39
8352 && !owner.starts_with('-')
8353 && !owner.ends_with('-')
8354 && !owner.contains("--")
8355 && owner
8356 .bytes()
8357 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
8358}
8359
8360/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
8361/// neither of the two names a path segment already means.
8362fn valid_github_repository_name(name: &str) -> bool {
8363 !name.is_empty()
8364 && name.len() <= 100
8365 && name != "."
8366 && name != ".."
8367 && name
8368 .bytes()
8369 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
8370}
8371
8372fn valid_environment_name(name: &str) -> bool {
8373 let mut bytes = name.bytes();
8374 bytes
8375 .next()
8376 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
8377 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
8378}
8379
8380/// How many sub-issues one issue has.
8381///
8382/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
8383/// absent or non-integer one is a response this source cannot read — and reading it as
8384/// zero would classify a project as a task, which is exactly the mistake the marker
8385/// exists to keep from happening quietly.
8386fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
8387 let summary = issue
8388 .get("subIssuesSummary")
8389 .ok_or_else(|| SourceError::Malformed {
8390 message: "GitHub issue is missing subIssuesSummary".into(),
8391 })?;
8392 summary
8393 .get("total")
8394 .and_then(Value::as_u64)
8395 .ok_or_else(|| SourceError::Malformed {
8396 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
8397 })
8398}
8399
8400/// One issue's own `number`.
8401///
8402/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
8403/// an issue in this module asks for it. So a read of one that comes back without it, or
8404/// with something that is not an unsigned integer, is a response this source cannot read —
8405/// absence here is **not** "this issue has no number". A draft is the content that has
8406/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
8407/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
8408fn issue_number(issue: &Value) -> Result<u64, SourceError> {
8409 issue
8410 .get("number")
8411 .and_then(Value::as_u64)
8412 .ok_or_else(|| SourceError::Malformed {
8413 message: "GitHub issue number is missing or is not an unsigned integer".into(),
8414 })
8415}
8416
8417/// The `number` a creating mutation answered with, and `None` when it answered without one;
8418/// why a missing one is tolerated is at the call in `create_and_file_issue`.
8419fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
8420 match created.get("number") {
8421 None | Some(Value::Null) => Ok(None),
8422 Some(value) => value
8423 .as_u64()
8424 .map(Some)
8425 .ok_or_else(|| SourceError::Malformed {
8426 message: "GitHub created issue number is not an unsigned integer".into(),
8427 }),
8428 }
8429}
8430
8431fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8432 value
8433 .get(field)
8434 .and_then(Value::as_str)
8435 .ok_or_else(|| SourceError::Malformed {
8436 message: format!("GitHub response is missing string field {field}"),
8437 })
8438}
8439
8440fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8441 let found = required_str(value, field)?;
8442 if found.trim().is_empty() {
8443 return Err(SourceError::Malformed {
8444 message: format!("GitHub response has blank string field {field}"),
8445 });
8446 }
8447 Ok(found)
8448}
8449
8450/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
8451/// needs one — Linear spells them too, in its own description field.
8452///
8453/// Restated rather than shared, because a plugin crate depends on the contract crate and
8454/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
8455/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
8456/// source round-trips its own writes perfectly well under its own spelling.
8457const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
8458const METADATA_CLOSE: &str = "\n-->";
8459
8460/// What the composer puts between a non-empty visible body and the slot, and the one thing
8461/// the parser takes off the visible body when it takes the slot off — exactly once, so every
8462/// other trailing byte of the body comes back as it was written.
8463// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] How a composer lays the slot after prose is this source's own; `docs/metadata.md` and its gate settle only the delimiters, and no other source declares a separator to reconcile against.
8464const METADATA_SEPARATOR: &str = "\n\n";
8465
8466/// The visible body and the metadata slot at the end of it.
8467///
8468/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
8469/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
8470/// own content and is left alone. The visible body is everything before the slot less the
8471/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
8472fn metadata_body(
8473 body: Option<String>,
8474) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
8475 let Some(body) = body else {
8476 return Ok((None, BTreeMap::new()));
8477 };
8478 let Some(slot) = slot_span(&body)? else {
8479 return Ok((Some(body), BTreeMap::new()));
8480 };
8481 let metadata =
8482 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
8483 SourceError::Malformed {
8484 message: format!(
8485 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
8486 ),
8487 }
8488 })?;
8489 let before = &body[..slot.start];
8490 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
8491 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
8492}
8493
8494/// Where the metadata slot sits in one body, as byte offsets into it.
8495struct SlotSpan {
8496 /// Where [`METADATA_OPEN`] begins.
8497 start: usize,
8498 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
8499 encoded_start: usize,
8500 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
8501 encoded_end: usize,
8502 /// Just past [`METADATA_CLOSE`].
8503 end: usize,
8504}
8505
8506/// The slot at the very end of `body`, or `None` when it has none.
8507///
8508/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
8509/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
8510/// slot.
8511fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
8512 let Some(start) = body.rfind(METADATA_OPEN) else {
8513 return Ok(None);
8514 };
8515 let encoded_start = start + METADATA_OPEN.len();
8516 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
8517 return Err(SourceError::Malformed {
8518 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
8519 });
8520 };
8521 let encoded_end = encoded_start + relative_end;
8522 let end = encoded_end + METADATA_CLOSE.len();
8523 if !body[end..].trim().is_empty() {
8524 return Ok(None);
8525 }
8526 Ok(Some(SlotSpan {
8527 start,
8528 encoded_start,
8529 encoded_end,
8530 end,
8531 }))
8532}
8533
8534/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
8535/// slot as it was.
8536///
8537/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
8538/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
8539/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
8540/// or alone in an empty body — and a body with no slot that is given no metadata is
8541/// returned as it is.
8542fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
8543 let encoded = if metadata.is_empty() {
8544 None
8545 } else {
8546 Some(
8547 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8548 message: error.to_string(),
8549 })?,
8550 )
8551 };
8552 Ok(match (slot_span(body)?, encoded) {
8553 (Some(slot), Some(encoded)) => format!(
8554 "{}{encoded}{}",
8555 &body[..slot.encoded_start],
8556 &body[slot.encoded_end..]
8557 ),
8558 (Some(slot), None) => {
8559 let before = &body[..slot.start];
8560 format!(
8561 "{}{}",
8562 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
8563 &body[slot.end..]
8564 )
8565 }
8566 (None, None) => body.to_owned(),
8567 (None, Some(encoded)) if body.is_empty() => {
8568 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8569 }
8570 (None, Some(encoded)) => {
8571 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8572 }
8573 })
8574}
8575
8576/// `body` with everything before its metadata slot replaced by `content`, and the slot
8577/// itself kept byte for byte.
8578///
8579/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
8580/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
8581/// `content` is empty — so a read of the result reports `content` as the visible body and
8582/// the slot's metadata exactly as it was.
8583fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
8584 let Some(slot) = slot_span(body)? else {
8585 return Ok(content.to_owned());
8586 };
8587 let kept = &body[slot.start..];
8588 Ok(if content.is_empty() {
8589 kept.to_owned()
8590 } else {
8591 format!("{content}{METADATA_SEPARATOR}{kept}")
8592 })
8593}
8594
8595/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
8596fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
8597 if entries.is_empty() {
8598 metadata.remove(key);
8599 } else {
8600 metadata.insert(
8601 key.to_owned(),
8602 Value::Array(
8603 entries
8604 .iter()
8605 .map(|entry| Value::String(entry.as_str().to_owned()))
8606 .collect(),
8607 ),
8608 );
8609 }
8610}
8611
8612fn compose_body(
8613 content: Option<&str>,
8614 metadata: &BTreeMap<String, Value>,
8615) -> Result<Option<String>, SourceError> {
8616 let visible = content.unwrap_or_default();
8617 if metadata.is_empty() {
8618 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
8619 }
8620 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8621 message: error.to_string(),
8622 })?;
8623 Ok(Some(if visible.is_empty() {
8624 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8625 } else {
8626 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8627 }))
8628}
8629
8630fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
8631 value
8632 .get(field)
8633 .and_then(Value::as_bool)
8634 .ok_or_else(|| SourceError::Malformed {
8635 message: format!("GitHub response is missing boolean field {field}"),
8636 })
8637}
8638fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
8639 match value.get(field) {
8640 None | Some(Value::Null) => Ok(None),
8641 Some(value) => value
8642 .as_str()
8643 .map(Some)
8644 .ok_or_else(|| SourceError::Malformed {
8645 message: format!("GitHub response field {field} is not a string or null"),
8646 }),
8647 }
8648}
8649fn optional_nodes<'a>(
8650 connection: Option<&'a Value>,
8651 name: &str,
8652) -> Result<Option<&'a Vec<Value>>, SourceError> {
8653 match connection {
8654 None | Some(Value::Null) => Ok(None),
8655 Some(value) => value
8656 .get("nodes")
8657 .and_then(Value::as_array)
8658 .map(Some)
8659 .ok_or_else(|| SourceError::Malformed {
8660 message: format!("GitHub {name}.nodes is not an array"),
8661 }),
8662 }
8663}
8664fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
8665 let page_info = connection
8666 .get("pageInfo")
8667 .ok_or_else(|| SourceError::Malformed {
8668 message: format!("GitHub {name} has no pageInfo"),
8669 })?;
8670 if required_bool(page_info, "hasNextPage")? {
8671 return Err(SourceError::Malformed {
8672 message: format!(
8673 "GitHub {name} exceeds the supported nested connection size of {size}"
8674 ),
8675 });
8676 }
8677 Ok(())
8678}
8679fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
8680 optional_str(value, field)?
8681 .map(|timestamp| {
8682 timestamp.parse().map_err(|error| SourceError::Malformed {
8683 message: format!("GitHub response field {field} is not a timestamp: {error}"),
8684 })
8685 })
8686 .transpose()
8687}
8688fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
8689 if page.limit == 0 {
8690 Err(SourceError::Config {
8691 message: "page limit must be at least 1".into(),
8692 })
8693 } else {
8694 Ok(())
8695 }
8696}
8697fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
8698 let page = connection
8699 .get("pageInfo")
8700 .filter(|value| value.is_object())
8701 .ok_or_else(|| SourceError::Malformed {
8702 message: "GitHub connection is missing pageInfo".into(),
8703 })?;
8704 if required_bool(page, "hasNextPage")? {
8705 let cursor = required_str(page, "endCursor")?;
8706 validate_cursor_progress(None, cursor)?;
8707 Ok(Some(Cursor(cursor.into())))
8708 } else {
8709 Ok(None)
8710 }
8711}
8712fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
8713 if next.is_empty() || previous == Some(next) {
8714 Err(SourceError::Malformed {
8715 message: "GitHub pagination cursor is empty or did not advance".into(),
8716 })
8717 } else {
8718 Ok(())
8719 }
8720}
8721fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
8722 cursor.map_or(Ok(0), |c| {
8723 c.0.parse().map_err(|_| SourceError::Config {
8724 message: "page cursor is invalid".into(),
8725 })
8726 })
8727}
8728fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
8729 if offset > items.len() {
8730 return Page::last(vec![]);
8731 }
8732 let tail = items.split_off(offset);
8733 let mut selected = tail;
8734 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
8735 selected.truncate(limit);
8736 Page {
8737 items: selected,
8738 next,
8739 }
8740}