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 query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
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//! A project query's text, and a document query's scoped to no project, is that same search
167//! and narrows on the same terms, its candidates confirmed by their kind as well.
168//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
169//! so those three are applied in process over the candidates, and a query carrying none of
170//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
171//! engine compensate for work this source has already done, and declaring `projects` native
172//! while ignoring the filter (which this source once did) silently returns another project's
173//! tasks, because the engine trusts the declaration and applies nothing locally.
174//!
175//! # The three ways this source reaches an item, and what each costs
176//!
177//! A board read is charged for what its *nested* connections could return rather than for
178//! what was asked, so one whole-board read costs the same whether the question was about
179//! one project or about all of them. That is why a question about one project is never
180//! answered by reading the board:
181//!
182//! | The question | What is sent | What it costs |
183//! | --- | --- | --- |
184//! | one item, by its own id | [`graphql::ISSUE`] — `node(id:)`, carrying the field definitions of the boards it sits on and the far ends of its `blockedBy`, which is what a write of it needs — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
185//! | one task with its first page of comments, for `task show` and a comment listing | [`graphql::ISSUE_DETAIL`] — the same `node(id:)` read with the issue's `comments` | the item and a page of its comments |
186//! | several tasks with their comments, for `task show-many` | [`graphql::ISSUE_DETAILS`] — [`DETAIL_BATCH`] aliased `node(id:)` fields per request | each item and a page of its comments |
187//! | 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` — or, for a create that needs the repository's id too, [`graphql::CREATION_CONTEXT`], both in one request | the board's fields |
188//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
189//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
190//! | which projects hold a text, or which documents do when no project narrows the question | [`graphql::SEARCH_ISSUES`] — the board-scoped search with the text as one quoted phrase, `in:title`, `in:body` or both, as a task's text is sent — walked to its end in pages of twenty | the issues that match |
191//! | 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 |
192//! | 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 — in pages of twenty, only as many as the caller's rows need | the issues that match |
193//! | 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 |
194//! | 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 |
195//! | 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 |
196//!
197//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
198//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
199//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
200//! equal to declared points equal to the row. They include the origin lookup and the
201//! field/repository discovery a create needs. A bound re-copy changes status, priority,
202//! content and metadata; comment recount means a subsequent detail read. Each request here
203//! costs one declared point. A membership beyond the embedded page can additionally require
204//! the one-point membership recovery described above. A bound re-copy of a task filed under a
205//! project adds one read, the engine confirming that project's link by its own id once per
206//! command; and the same-source far ends a write newly names — those that do not already block
207//! the item, whose own read answered for them — are read together by their own ids,
208//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
209//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
210//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
211//!
212//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
213//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
214//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
215//! holds both halves.
216//!
217//! **An existing item is written body last.** A bound re-copy and a `task update` send its
218//! board fields first — the `Status` option and the `Priority` together, in one request — then
219//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
220//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
221//! undoing an earlier field when a later one fails, so that order is what makes a write
222//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
223//! the one piece of metadata written before the body, an origin a copy re-points, is put back
224//! when a later write is refused — and when putting it back is refused too, the write's own
225//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
226//! of those writes in turn, whole and as one aliased field failing after the one before it.
227//!
228//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
229//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
230//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
231//!
232//! - **A board is accepted at creation but its item is not answered, so a create still files
233//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
234//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
235//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
236//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
237//! against a real board on 2026-10-01 with a create sending the board there and reading the
238//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
239//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
240//! refused "Content already exists in this project" — GitHub had filed the issue after
241//! answering, and refuses a second filing rather than answering with the item it holds. A
242//! create therefore sends no `projectV2Ids` and files the issue with
243//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
244//! real is the read before it: the board's fields and the repository's id together, in
245//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
246//! - **A comment still reads its target first, so a comment is 2 requests.**
247//! `AddCommentInput.subjectId: ID!` is declared there with
248//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
249//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
250//! project's issue, a document's issue, an issue on no board of this source and a pull
251//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
252//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
253//! refuses those by name.
254//!
255//! | Verb | Requests / points | Documents |
256//! | --- | --- | --- |
257//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
258//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
259//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS, then UPDATE_ISSUE last |
260//! | bound copy, filed under a project | 4 | the bound copy's three, and one ISSUE of the destination project its link names, read once per command |
261//! | bound copy, newly naming n dependencies | + ceil(n / DETAIL_BATCH) + n | ISSUE_DETAILS for the far ends that do not already block the item, DETAIL_BATCH (24) to a request (one alone is ISSUE), then one ADD_BLOCKED_BY each; a far end already blocking it is answered by its own read and costs nothing |
262//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
263//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
264//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
265//! | recount | 1 | ISSUE_DETAIL |
266//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
267//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
268//! | content | 2 | ISSUE, UPDATE_ISSUE |
269//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
270//! | update | 3 | `task update` naming any of title, body, metadata, status and priority — all five included: ISSUE, UPDATE_FIELDS (the status option and the priority together), UPDATE_ISSUE (title, body with its metadata slot, and state) last |
271//! | record only | 1 | ISSUE |
272//!
273//! <!-- github-search-paging:start -->
274//! Board-scoped text, metadata, project-name and comment-activity searches send every
275//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
276//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
277//! needs rows. A page is never resized to the rows still needed: GitHub orders one
278//! search differently at different page sizes, so one fixed size makes a paged walk
279//! send exactly the requests one whole read sends, and the answer's order is the order
280//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
281//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
282//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
283//! returned and fetched pages: a limit is sliced from the pages it needs, and local
284//! confirmation can require more candidates than matching rows. Walking all pages
285//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
286//! cursor and how far into that page the last answer stopped, and resumes in the same
287//! process or a new one, without duplicates or gaps. It carries no rows: one process
288//! sends each page's search once, and a new process re-reads only the page it resumes
289//! in, then sends a further page once, never as a re-read, only when its limit still
290//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
291//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
292//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
293//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
294//! not required to include the original process's writes still omitted by the index.
295//! <!-- github-search-paging:end -->
296//!
297//! The board half of an issue — its board item's id, its `Status` option and this
298//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
299//! an item reached any of those ways resolves through the same
300//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
301//! same status, the same labels and the same qualified id. That connection comes back a
302//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
303//! on the page in hand and — only if that page reports more of the connection — in the
304//! last row's read of that one issue's memberships, resumed from the page's own cursor and
305//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
306//! report, which is what keeps an id naming another repository's issue from being answered
307//! as an item of this board; and because the page is where the search starts rather than
308//! where it ends, that answer is one about a connection read to exhaustion and never about
309//! an unread page. Nothing costs the extra read but an issue on more boards than a page
310//! holds: an issue this board really does not hold reports no next page, so its
311//! memberships are already exhausted where they arrived.
312//!
313//! **No document here selects the board's own `Labels` field, and nothing is lost by
314//! that.** An item's labels are read from its content alone, wherever that content is
315//! reached: the three documents above select `Issue.labels` on the fragment, and
316//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
317//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
318//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
319//! create one, and `ProjectV2FieldValue` — the whole of what
320//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
321//! it from the content, for every content type it exists on, and there is nothing it can
322//! hold that the content does not already say: for an `Issue` it *is* that issue's own
323//! labels, so selecting it beside them unions a set with itself.
324//!
325//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
326//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
327//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
328//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
329//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
330//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
331//! label set, and that set is the fixture issue's own, by
332//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
333//! item whose content is a draft reports an empty set, by
334//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
335//! selection is held over [`graphql::DOCUMENTS`] by
336//! `no_document_selects_the_boards_own_labels_field`.
337//!
338//! The whole-board row is still the board's own item connection, and deliberately: a
339//! **draft** board item is not an issue, so no search can list one, and the reads that have
340//! to answer for the whole board are the ones whose cost is the board's size anyway.
341//!
342//! **A question about one item this source already names by id never lists the board.**
343//! Whether that item is on this board, and what its board fields are, is answered by reading
344//! that item — its own `Issue.projectItems`, walked to exhaustion by
345//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
346//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
347//! holds. That covers a write's destination, the project a new item is filed under, a
348//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
349//! and the delete that takes back an item a copy made. What such a write needs of the board
350//! and the item does not carry — the board's id, the `Status` and origin field definitions —
351//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
352//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
353//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
354//! minutes. Scanning this host's 842-item board has refused a document copy and an update
355//! even though the items' own reads named that board. A scan there gives the wrong answer
356//! as well as paying for every page. So a `board.items` lookup does not belong on any of
357//! those paths.
358//!
359//! **What a read may return is capped too, and that cap is on the document rather than on
360//! the board.** GitHub limits the number of nodes **one query may return** to
361//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
362//! an error naming the connection the count crossed at, not a slow or a partial result.
363//! Every board this source reads is refused the same way, so no board is too big for these
364//! documents and none is small enough to save one that is over.
365//!
366//! The count is arithmetic over the document's own text: each connection contributes the
367//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
368//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
369//! restate them — `github-graphql-node-count` implements them, and
370//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
371//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
372//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
373//! same text on every run and fails naming any that reaches the limit — so a connection
374//! added to a shared fragment is caught there rather than by GitHub.
375//!
376//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
377//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
378//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
379//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
380//! effectively squared there, which is why it is the one the limit is most sensitive to.
381//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
382//! page of memberships misses is recovered by one further read rather than refused, so it
383//! buys a bound every read pays for at the price of a request only a multi-board issue
384//! pays.
385//!
386//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
387//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
388//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
389//! `cost` is rate-limit points, metered per hour across everything one credential does; it
390//! is what the two limiters [`Limiter`] tells apart meter, and a document under
391//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
392//! that second number, and `tests/point_cost.rs` pins every document in
393//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
394//! one under, the pin itself is the check. The credentialed lane reconciles both figures
395//! against GitHub's own, off a probe it already sends.
396//!
397//! **What is pinned that way is a per-document price and never a session's.** The record in
398//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
399//! **requests** and **worst-case nodes** — and neither is points. What one whole session
400//! consumes of the hourly point allowance is observable only from a credentialed run's own
401//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
402//! and what `tests/live.rs` prints at the end of every run.
403//!
404//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
405//!
406//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
407//! enumerations of a board can supply one alone.** Resolving a node id is strongly
408//! consistent, so a read by id and a project's own sub-issues are already current. The
409//! other two are not, and they are behind by different amounts and in different directions:
410//!
411//! - GitHub's **issue search** is an index and answers a write made moments ago with the
412//! value from before it — usually for a second or two.
413//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
414//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
415//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
416//! for *minutes*, while `Issue.projectItems` names the same membership at once.
417//!
418//! That second one is a measurement rather than a caution. This repository's own
419//! credentialed journey writes a project and waits for the board to report it, then writes a
420//! task and waits for the same thing seconds later on the same board: the project wait is
421//! answered through the search and converged in two or three attempts in each of three runs,
422//! and the task wait is answered through `ProjectV2.items` and converged in none of them
423//! inside thirty. Separately, an item added to a second and larger board was read back by
424//! `Issue.projectItems` on that board's own id while every one of that connection's nine
425//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
426//! through the lagging one alone is what had a board read deny an issue that had certainly
427//! landed on it.
428//!
429//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
430//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
431//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
432//! search reports what the projection is behind on. What closes the last
433//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
434//! source answers is completed with what this process itself wrote, so an item created
435//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
436//! nothing is written down, and the record dies with the process. **A wait that has to
437//! observe GitHub's own data cannot be answered from that record** — which is why the
438//! credentialed journey asks through a source built afresh, and why the union above rather
439//! than a longer wait is what makes such a wait converge.
440//!
441//! **A narrowed read is the same bargain, stated for each of the three predicates it
442//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
443//! than walking the board, and every such answer is completed with what this process wrote —
444//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
445//! each filtered by the same predicates as the rest — so an item this command wrote a moment
446//! ago is returned by a query that matches it whether or not the index has caught up. An item
447//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
448//! consistent. What is left is stated rather than papered over:
449//!
450//! | Read | Finds | Behind by |
451//! | --- | --- | --- |
452//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
453//! | 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 |
454//! | 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 |
455//! | origin, third read | this process's own writes | nothing |
456//!
457//! So an origin carrier another process added within the last second or two, before either
458//! index has it, can be missing from an origin query, and one written by the release before
459//! this one — its origin in the field alone — can be missing for as long as the board's own
460//! item connection is behind on it. A copy that must not duplicate its own earlier write
461//! relies on the link it records, not on either index. **A board draft is not an issue**, so
462//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
463//! lists one, the origin lookup drops any the board's own field filter names, and one this
464//! process wrote is not added back either.
465//!
466//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
467//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
468//! body's metadata slot, so the issue search can find it in seconds. The field is
469//! authoritative: this source reads an item's origin from the field alone, so a slot that
470//! disagrees with it, or holds one where the field holds none, is never read as a second
471//! origin — and the release before this one reads the slot, drops that key's copy for the
472//! field's, and sees the same one origin.
473//!
474//! Filtering happens before paging, so a page of a filtered result is a page of the
475//! survivors rather than the survivors of a page. Label matching and the substring rule a
476//! text candidate is confirmed by answer the same question the same way the local Markdown
477//! source's do; which candidates a text search has to confirm is GitHub's word match, which
478//! is the one place the two sources can answer the same text differently.
479//!
480//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
481//! has one source, `capabilities`, and the note above is the reasoning behind it rather
482//! than a second copy of it: without the three facts recorded here a reader takes the
483//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
484//! crate's own capabilities test, which pins every field of it against a fully spelled-out
485//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
486//! fails to compile there rather than going unasserted. -->
487//! The fixture-server tests above run wherever this crate is selected; the credentialed
488//! lane runs in the same required check, beside them, and can fail it — it verifies the
489//! 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,
490//! one filed under neither, a label on one of the three and a closed status on another —
491//! because that shape is what tells an honoured predicate from an ignored one: a board
492//! holding a single project answers a project filter the same way whether or not this
493//! source applies it, which is exactly how the defect above went unseen.
494//!
495//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
496//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
497//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
498//! nominated is what keeps a credentialed write lane off a board and a repository nobody
499//! nominated; it never asks GitHub which project was updated most recently. Before it
500//! starts, the lane also clears any item titled — and any repository label named — the way
501//! it titles and names its own artifacts, which is self-healing after an interrupted run:
502//! a process killed between its writes and its cleanup leaves artifacts the next run
503//! removes.
504//!
505//! # What a session of requests costs, and where the report is
506//!
507//! This source records **every** request it sends into [`accounting::Accounting`], at
508//! `send_once` — the one place a request leaves this crate, which is why a read path added
509//! later is counted without anybody remembering to count it. That is the whole of what this
510//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
511//! session's spend is arrived at, and what it deliberately does not know are set out.
512//!
513//! What one whole session of the live journey costs, counted that way against this crate's
514//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
515//! reduction it came out of, and with what it does and does not say about rate-limit points.
516//!
517//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
518//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
519//! code path — no environment variable, no feature, no build configuration — because an
520//! instrument nobody switches on measures nothing, and
521//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
522//! source's counts the whole session rather than this source's share. The credentialed lane
523//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
524//! passed or failed.
525//!
526//! **A live session refuses to start unless the account can afford it.** Before it does any
527//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
528//! GitHub documents as not counting against the REST rate limit and which answers both of
529//! its budgets at once — and starts only if, for each of them, what remains minus this
530//! session's estimated cost is still at least
531//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
532//! allowance. A session that cannot **declines**: it did not run, so it is
533//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
534//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
535//! waiting for the budget to come back. The estimate is derived offline from
536//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
537//! which is also where the published rule that model rests on is cited; the accounting
538//! above records the gate's own read like any other request, and
539//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
540//!
541//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
542//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
543//! text, which is what lets it run on every platform and on a pull request from a fork with
544//! no credential — and that is what actually stops a regression merging. But an offline
545//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
546//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
547//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
548//! number of nodes this query may return"* and whose `cost` is what that document would
549//! spend, both for a document **without executing it**, and the lane asks it for every query
550//! document this source sends, under the largest bindings this source sends, and fails when
551//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
552//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
553//! one; the offline pins still cover it. It records what those calls reported about the
554//! account's own allowance, because whether asking is free is a thing to observe rather than
555//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
556//! and `cost` is metered against an hourly allowance the accounting above reads off a
557//! credentialed run's own response headers.
558//!
559//! **GitHub has two rate limiters and this source is refused by both, so nothing here
560//! treats them as one thing.** The primary budget is the hourly allowance `gh api
561//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
562//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
563//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
564//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
565//! one part of.
566#![deny(missing_docs)]
567
568use std::collections::BTreeMap;
569use std::sync::{Arc, Mutex};
570use std::time::{Duration, Instant};
571
572use chrono::{DateTime, Utc};
573use onetaskgraph_plugin_api::{
574 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
575 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
576 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
577 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
578 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskDetailRead, TaskQuery,
579 TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField,
580 WriteSupport,
581};
582use reqwest::{Client, StatusCode, Url};
583use schemars::{Schema, schema_for};
584use secrecy::{ExposeSecret, SecretString};
585use serde::{Deserialize, Serialize};
586use serde_json::{Value, json};
587
588pub mod accounting;
589
590use accounting::Accounting;
591
592/// The registry name for this plugin.
593pub const KIND: &str = "github-projects";
594/// GitHub's maximum connection page size.
595pub const MAX_PAGE_SIZE: u32 = 100;
596/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
597/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
598/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
599pub const SEARCH_PAGE_SIZE: u32 = 20;
600/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
601/// its comments: the largest batch the node-count model prices at one point.
602///
603/// Each aliased item is resolved once, and what GitHub charges for it is the connections
604/// under it — its labels, its page of board memberships, the field values of each of those
605/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
606/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
607/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
608/// one point and fails if one item more would still be priced at one.
609pub const DETAIL_BATCH: usize = 24;
610
611/// The most nodes any one document this source sends may be asked to return.
612///
613/// GitHub's own published per-query ceiling, taken from
614/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
615/// workspace cannot hold a stale copy of somebody else's number. A query above it is
616/// **refused before it is executed**, whoever is asking and whatever board they are
617/// asking about — so this is a bound on the documents rather than a budget that runs out.
618///
619/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
620/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
621/// everything the credential does — two numbers against two limits, and this constant
622/// bounds only the first. The second is computed offline too, per document:
623/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
624/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
625/// lane. There is no constant like this one to hold a price under, because points are an
626/// hourly allowance rather than a per-call bound.
627///
628/// Neither is a session's price. What `session-cost.md` records of a whole session is its
629/// **requests** and its **worst-case nodes**; what a whole session spends in points is
630/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
631/// [`accounting`]. The module section on the three ways this source reaches an item says how
632/// the count is arrived at, and which of the page sizes below decide it.
633pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
634
635/// Nested connection size for the connections that hang off one item.
636///
637/// It multiplies through every document that reaches an item under a page — the count
638/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
639/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
640/// every document under these constants and fails naming any that reaches the limit, so
641/// raising this is caught there rather than by GitHub.
642const NESTED_PAGE_SIZE: u32 = 50;
643/// How many of one issue's board memberships are read when an issue is reached directly.
644///
645/// An issue reached through a search or through its own node id carries its board half in
646/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
647/// under a page of issues, so every point of it multiplies through the whole document and
648/// is paid for whether or not any issue is on a second board — which is why it is
649/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
650///
651/// **Three, because what a page misses is now recovered rather than refused**, and the
652/// recovery is what the value is chosen against. An issue whose entry for this board sits
653/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
654/// that page's own cursor — so the value trades a bound every read pays for a request only
655/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
656/// boards would pay that request *per issue*, which is order N against the one page per
657/// hundred issues a read costs today. At three it is only reached by an issue on four or
658/// more boards at once, which keeps the recovery path exceptional rather than routine for
659/// a plausible deployment.
660const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
661/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
662/// its two connections for.
663///
664/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
665/// second is a duplicate a copy already takes the first of. Both connections are walked to
666/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
667/// never what the answer is. It is small because every point of it is paid on every lookup,
668/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
669/// worst-case nodes than the one whole-board read they replaced.
670const ORIGIN_PAGE_SIZE: u32 = 3;
671
672pub use github_graphql_node_count::{NodeCountError, Variables};
673
674/// The largest value this source can bind to each page-size variable its documents name.
675///
676/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
677/// constant above it wherever a caller's own limit could reach it — `$first` at
678/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
679/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
680/// not one configuration of it, which is what makes a bound computed under it a bound on
681/// every read.
682pub fn largest_page_sizes() -> Variables {
683 Variables::from([
684 ("first".to_owned(), MAX_PAGE_SIZE),
685 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
686 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
687 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
688 ])
689}
690
691/// The most nodes `document` could be asked to return, by GitHub's published rules.
692///
693/// Computed offline from the document's own text under [`largest_page_sizes`] — no
694/// network, no credential and no schema — by
695/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
696/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
697/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
698///
699/// # Errors
700///
701/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
702/// no single operation, or binds a page size this source does not name — each of which is
703/// a defect in the document rather than a number.
704pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
705 node_count(document, &largest_page_sizes())
706}
707
708/// The most rate-limit points one call of `document` could spend, by GitHub's published
709/// rules.
710///
711/// Computed offline from the document's own text under [`largest_page_sizes`] — no
712/// network, no credential and no schema — by
713/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
714/// This is `cost`, metered **per hour** against the allowance one credential shares across
715/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
716/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
717/// under, so what `tests/point_cost.rs` does with it is pin every document in
718/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
719/// figures against GitHub's own reported `cost`.
720///
721/// # Errors
722///
723/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
724/// no single operation, or binds a page size this source does not name — each of which is
725/// a defect in the document rather than a number.
726pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
727 github_graphql_node_count::point_cost(document, &largest_page_sizes())
728}
729
730/// The most nodes `document` could be asked to return under `variables`.
731///
732/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
733/// [`accounting`] is this under the bindings one request really sent — one spelling of the
734/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
735/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
736///
737/// # Errors
738///
739/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
740/// no single operation, or binds a page size `variables` does not name.
741pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
742 github_graphql_node_count::node_count(document, variables)
743}
744
745/// The issue-title prefix that makes a board issue a document.
746///
747/// A GitHub Projects board has no document type — it holds issues — so the discriminator
748/// is the title, and this is the whole of it: an issue whose title begins with these bytes
749/// is a document and every other issue is the task or project the sub-issue rule makes it.
750///
751/// It is spelled **once**, here, and read rather than restated everywhere else — including
752/// by the shared journeys, which take it from this constant so a board fixture cannot
753/// drift from what this source reads. `docs/metadata.md` records the two consequences that
754/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
755/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
756/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
757pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
758
759/// Exact GraphQL query documents issued by this plugin.
760///
761/// Keeping the production documents here lets the pinned-schema test validate the same
762/// bytes that are sent to GitHub, rather than a test-only copy which could drift
763/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
764/// field, and its guarded caller always supplies the complete existing option set with ids.
765pub mod graphql {
766 /// The board half of one item: the field values every document here reads it from.
767 ///
768 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
769 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
770 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
771 /// *the same value*, because
772 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
773 /// one path. Three spellings of it is what would drift, so there is one.
774 ///
775 /// The `Status` option and this source's own origin text field are the whole of it. It
776 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
777 /// content, so it holds nothing the content's own `labels` do not already say, and it
778 /// would sit a label connection two page sizes deep.
779 macro_rules! board_item_values {
780 () => {
781 r#"fieldValues(first:$nestedFirst){nodes{
782 ... on ProjectV2ItemFieldSingleSelectValue{name field{
783 ... on ProjectV2SingleSelectField{id name options{id name}}
784 }}
785 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
786 }pageInfo{hasNextPage}}"#
787 };
788 }
789
790 /// Everything this source reads about one issue, wherever it reaches that issue.
791 ///
792 /// A macro rather than a constant so the three documents below can `concat!` it: one
793 /// spelling of these fields is what makes an issue read through the board-scoped
794 /// search, through its own node id, and through its project's sub-issue relationship
795 /// resolve to *the same* item, which is the whole of what
796 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
797 ///
798 /// `projectItems` is what carries the board half of an issue: the board item's own id
799 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
800 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
801 /// issue rather than on the board, which is what makes the cost of a read proportional
802 /// to what was asked for instead of to the board's size.
803 ///
804 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
805 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
806 /// not on that page: a page here is where the search for the entry starts rather than
807 /// where it ends.
808 ///
809 /// It does **not** select the board's `Labels` field value, and that is the whole of
810 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
811 /// a label connection there sits under `fieldValues` under `projectItems` under a page
812 /// of issues, spending `$nestedFirst` twice down one path, and took
813 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
814 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
815 /// above, and that connection is where every label this source reports comes from. No
816 /// document in this module selects the board field any longer, [`BOARD`] included; the
817 /// module documentation records why nothing it could have held is lost.
818 macro_rules! board_issue {
819 () => {
820 concat!(
821 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
822 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
823 projectItems(first:$boardItems){nodes{id project{id number}
824 "#,
825 board_item_values!(),
826 r#"}pageInfo{hasNextPage endCursor}}}"#
827 )
828 };
829 }
830
831 /// Every issue of one board, found by a search scoped to that board.
832 ///
833 /// This is how the projects a board holds are listed, and it selects no `items`
834 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
835 /// container walked page by page, so nothing nested inside a board item is paid for.
836 /// Which of the issues it returns is a project is then read off `parent` — GitHub
837 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
838 /// discriminator has to be applied to the field, which is a scalar on the issue and
839 /// costs nothing.
840 pub const SEARCH_ISSUES: &str = concat!(
841 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
842 search(query:$search,type:$type,first:$first,after:$after){
843 pageInfo{hasNextPage endCursor}
844 nodes{__typename ...BoardIssue}
845 }
846 }"#,
847 board_issue!()
848 );
849
850 /// What a dependency read selects of each far end: enough to say which kind of item it
851 /// is, its body included for the kind marker.
852 macro_rules! related_issue {
853 () => {
854 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
855 };
856 }
857
858 /// One issue by its own node id, which is what a qualified id names here — with what a
859 /// write of it needs and the issue does not carry in `board_issue!`: the field
860 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
861 ///
862 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
863 /// answers a write made moments ago with the value from before it, and resolving a node
864 /// id does not.
865 ///
866 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
867 /// reads it by its own id, and with them that one read answers everything the write
868 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
869 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
870 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
871 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
872 /// they sit under one item, and this read is still one point.
873 pub const ISSUE: &str = concat!(
874 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
875 node(id:$id){__typename ...BoardIssue ... on Issue{
876 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
877 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
878 ... on ProjectV2Field{__typename id name}
879 }pageInfo{hasNextPage}}}}}
880 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
881 }}
882 }"#,
883 board_issue!(),
884 related_issue!()
885 );
886
887 /// One project's tasks: the sub-issues of the issue that project is.
888 ///
889 /// The work this costs is the project's own size. Nothing about it grows as the board
890 /// gains projects, or as those projects gain tasks.
891 pub const SUB_ISSUES: &str = concat!(
892 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
893 node(id:$id){__typename
894 ... on Issue{subIssues(first:$first,after:$after){
895 pageInfo{hasNextPage endCursor}
896 nodes{__typename ...BoardIssue}
897 }}}
898 }"#,
899 board_issue!()
900 );
901
902 /// What a read of the board's own `items` selects of each item's content.
903 ///
904 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
905 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
906 /// content by one spelling.
907 macro_rules! board_item_content {
908 () => {
909 r#" content{
910 ... 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}}}
911 ... on PullRequest{__typename id}
912 ... on DraftIssue{__typename id title body createdAt updatedAt}
913 }"#
914 };
915 }
916
917 /// Reads the board's fields and one page of its items.
918 pub const BOARD: &str = concat!(
919 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
920 owner:repositoryOwner(login:$owner){
921 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
922 }
923 } fragment Board on ProjectV2 { id title
924 fields(first:$nestedFirst){nodes{
925 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
926 ... on ProjectV2Field{__typename id name}
927 }pageInfo{hasNextPage}}
928 items(first:$first,after:$after){nodes{id "#,
929 board_item_values!(),
930 board_item_content!(),
931 r#"} pageInfo{hasNextPage endCursor}}
932 }"#
933 );
934
935 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
936 /// the board.
937 ///
938 /// **`originItems`** is the board's own items narrowed by its own field filter —
939 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
940 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
941 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
942 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
943 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
944 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
945 /// fresh `addProjectV2ItemById` the way that connection does.
946 ///
947 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
948 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
949 /// indexes that comment, and the index catches up with a write in a second or two rather
950 /// than in minutes, so it finds a carrier another process wrote that the first read is
951 /// still behind on.
952 ///
953 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
954 /// — and resumes from its own cursor; a connection already walked to its end is resumed
955 /// from its last cursor, which answers an empty page. Every candidate either read returns
956 /// is confirmed against its own origin field before it is reported, so a token match of
957 /// the search or anything else the filter admits never is.
958 ///
959 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
960 /// own whole reads counts this one among them.
961 pub const ORIGIN_LOOKUP: &str = concat!(
962 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
963 originItems:repositoryOwner(login:$owner){
964 ... on ProjectV2Owner{projectV2(number:$number){
965 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
966 board_item_values!(),
967 board_item_content!(),
968 r#"} pageInfo{hasNextPage endCursor}}
969 }}
970 }
971 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
972 pageInfo{hasNextPage endCursor}
973 nodes{__typename ...BoardIssue}
974 }
975 }"#,
976 board_issue!()
977 );
978
979 /// The board's own id and field definitions, and not one of its items.
980 ///
981 /// What a write needs of the board when the item it writes does not say: the id a field
982 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
983 /// origin fields. It selects no `items`, so what it costs is the board's field list
984 /// however many items the board holds — and it decides nothing about which items those
985 /// are, which is the question a read of one item by its own id answers instead.
986 ///
987 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
988 /// board's item reads by their root counts this one among them.
989 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
990 boardFields:repositoryOwner(login:$owner){
991 ... on ProjectV2Owner{projectV2(number:$number){id
992 fields(first:$nestedFirst){nodes{
993 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
994 ... on ProjectV2Field{__typename id name}
995 }pageInfo{hasNextPage}}
996 }}
997 }
998 }"#;
999
1000 /// One board draft by its own node id, with the board item it sits in.
1001 ///
1002 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1003 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1004 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1005 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1006 /// board listing hands it to, and nothing has to list the board to find one.
1007 pub const DRAFT: &str = concat!(
1008 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1009 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1010 projectV2Items(first:$boardItems){nodes{id project{id number}
1011 "#,
1012 board_item_values!(),
1013 r#"}pageInfo{hasNextPage endCursor}}}}
1014 }"#
1015 );
1016
1017 /// One issue's board memberships alone, walked past the page a read of it carried.
1018 ///
1019 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1020 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1021 /// boards than that page holds may have this board's entry past its end. This asks that
1022 /// one issue for its memberships and nothing else — the caller already holds the issue —
1023 /// so an answer of "this board does not hold it" is only ever given about a connection
1024 /// read to exhaustion.
1025 ///
1026 /// It selects the board item's id, its project number and the same
1027 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1028 /// very same resolver: an issue recovered this way reports the same title, the same
1029 /// status, the same labels and the same qualified id as one whose entry was on the
1030 /// page.
1031 ///
1032 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1033 /// multiplies through it and the membership connection can be walked at
1034 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1035 /// further request for any issue a person really keeps.
1036 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1037 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1038 node(id:$id){
1039 ... on Issue{projectItems(first:$first,after:$after){
1040 nodes{id project{id number}
1041 "#,
1042 board_item_values!(),
1043 r#"}
1044 pageInfo{hasNextPage endCursor}}}
1045 }
1046 }"#
1047 );
1048 /// Resolves the configured repository's node id, which creating an issue requires.
1049 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1050 /// What creating an issue needs and has not read yet: the board's own id and field
1051 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1052 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1053 ///
1054 /// Sent at the point a create knows which repository it is for, when neither half is
1055 /// already known to this process; a create needing only one of them sends that one's own
1056 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1057 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1058 /// status.
1059 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1060 boardFields:repositoryOwner(login:$owner){
1061 ... on ProjectV2Owner{projectV2(number:$number){id
1062 fields(first:$nestedFirst){nodes{
1063 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1064 ... on ProjectV2Field{__typename id name}
1065 }pageInfo{hasNextPage}}
1066 }}
1067 }
1068 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1069 }"#;
1070 /// Reads both dependency directions for one issue, with each far end's own kind — and
1071 /// the issue's own body, which is where an edge to another source is recorded, so that
1072 /// half of a dependency read needs no second read of the issue or of the board.
1073 pub const ISSUE_DEPENDENCIES: &str = concat!(
1074 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1075 ... on Issue{body
1076 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1077 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1078 }}}"#,
1079 related_issue!()
1080 );
1081 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1082 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1083 /// answered when it was.
1084 pub const CREATE_ISSUE: &str =
1085 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1086 /// Puts an existing issue on the configured board.
1087 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1088 /// Updates an issue's visible fields and its open or closed state in one call.
1089 pub const UPDATE_ISSUE: &str =
1090 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1091 /// Updates an existing draft's user-visible fields.
1092 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1093 /// Updates a text or single-select value on one project item.
1094 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1095 /// Writes up to three board fields and an optional clear in one ordered mutation.
1096 pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeClear:Boolean!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}}}"#;
1097 /// Clears one project item's value of one field, which is what a `none` priority is.
1098 pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}}}}}"#;
1099 /// Creates one single-select field with its options. Only the guarded field setup may use
1100 /// this document, and only for a field the board lacks.
1101 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1102 /// Replaces a single-select field's options. Only the guarded field setup — the
1103 /// `status-options` and `fields` operations — may use this document, because GitHub
1104 /// treats the input as the complete option list.
1105 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1106 /// A fresh snapshot of the Status field and every board item's assignment.
1107 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}}}}}}"#;
1108 /// Files one issue under another as a sub-issue, which is what project membership is.
1109 pub const ADD_SUB_ISSUE: &str =
1110 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1111 /// Takes one issue back out of its parent.
1112 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1113 /// Adds GitHub's native issue blocked-by relationship.
1114 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1115 /// Removes one native issue blocked-by relationship.
1116 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1117 /// Deletes one issue, which takes its board item with it.
1118 ///
1119 /// The engine sends this in one situation only: undoing a copy that could not finish,
1120 /// over the items that same copy created. Deleting the issue removes the board item
1121 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1122 pub const DELETE_ISSUE: &str =
1123 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1124
1125 /// Everything this source reads about one issue comment, wherever it reaches one.
1126 ///
1127 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1128 /// and a comment just edited are handed to one mapper, so they are selected by one
1129 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1130 /// longer exists, and `login` is the one member every kind of actor carries.
1131 macro_rules! issue_comment {
1132 () => {
1133 "id author{login} createdAt updatedAt body url"
1134 };
1135 }
1136
1137 /// One task's comments: a page of its issue's own `comments` connection.
1138 ///
1139 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1140 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1141 /// list every time somebody edited it; left unordered the connection answers in the order
1142 /// the comments were written, which is the order GitHub documents for the same collection
1143 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1144 /// node count and the caller's own page size is pushed straight down.
1145 pub const ISSUE_COMMENTS: &str = concat!(
1146 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1147 issue_comment!(),
1148 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1149 );
1150 /// One issue by its own node id, with a page of its comments: what `task show` and a
1151 /// comment listing read, in one request.
1152 ///
1153 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1154 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1155 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1156 /// connection there would multiply through both of those documents' price, and neither
1157 /// needs one.
1158 pub const ISSUE_DETAIL: &str = concat!(
1159 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1160 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1161 issue_comment!(),
1162 r#"}pageInfo{hasNextPage endCursor}}}}
1163 }"#,
1164 board_issue!()
1165 );
1166
1167 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1168 /// page of its comments when `$comments` asks for them.
1169 macro_rules! issue_details_alias {
1170 ($n:literal) => {
1171 concat!(
1172 "\n i",
1173 stringify!($n),
1174 ":node(id:$id",
1175 stringify!($n),
1176 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1177 issue_comment!(),
1178 "}pageInfo{hasNextPage endCursor}}}}"
1179 )
1180 };
1181 }
1182
1183 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1184 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1185 ///
1186 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1187 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1188 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1189 /// neither — so every connection under it would be priced at nothing and the pin in
1190 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1191 /// one-item read the model already prices, so the batch costs what its aliases cost.
1192 ///
1193 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1194 /// slots it has no item for to the last item it does, and reads that item again; the
1195 /// price is the document's, whatever its variables, so a short batch costs what a full
1196 /// one does and nothing more.
1197 pub const ISSUE_DETAILS: &str = concat!(
1198 r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$id21:ID!,$id22:ID!,$id23:ID!,$first:Int!,$comments:Boolean!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){"#,
1199 issue_details_alias!(0),
1200 issue_details_alias!(1),
1201 issue_details_alias!(2),
1202 issue_details_alias!(3),
1203 issue_details_alias!(4),
1204 issue_details_alias!(5),
1205 issue_details_alias!(6),
1206 issue_details_alias!(7),
1207 issue_details_alias!(8),
1208 issue_details_alias!(9),
1209 issue_details_alias!(10),
1210 issue_details_alias!(11),
1211 issue_details_alias!(12),
1212 issue_details_alias!(13),
1213 issue_details_alias!(14),
1214 issue_details_alias!(15),
1215 issue_details_alias!(16),
1216 issue_details_alias!(17),
1217 issue_details_alias!(18),
1218 issue_details_alias!(19),
1219 issue_details_alias!(20),
1220 issue_details_alias!(21),
1221 issue_details_alias!(22),
1222 issue_details_alias!(23),
1223 "\n }",
1224 board_issue!()
1225 );
1226
1227 /// Which issue one comment is on, read before that comment is edited or removed.
1228 ///
1229 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1230 /// comment id given against the wrong task would change a comment on another issue.
1231 pub const COMMENT_ISSUE: &str =
1232 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1233 /// Adds one comment to an issue, signed as the account the token belongs to.
1234 pub const ADD_COMMENT: &str = concat!(
1235 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1236 issue_comment!(),
1237 r#"}}}}"#
1238 );
1239 /// Replaces the body of one issue comment.
1240 pub const UPDATE_COMMENT: &str = concat!(
1241 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1242 issue_comment!(),
1243 r#"}}}"#
1244 );
1245 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1246 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1247
1248 /// Every document above, with what this source is doing when it sends one.
1249 ///
1250 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1251 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1252 /// document added later with "talking to GitHub" and never say so.
1253 ///
1254 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1255 /// const` here that this list omits, so the two cannot part — which is the same guard
1256 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1257 pub const DOCUMENTS: [(&str, &str); 33] = [
1258 (SEARCH_ISSUES, "searching this board's issues"),
1259 (ISSUE, "reading one issue"),
1260 (
1261 ISSUE_BOARD_ITEMS,
1262 "reading one issue's board memberships past the page it came with",
1263 ),
1264 (SUB_ISSUES, "reading a project's tasks"),
1265 (BOARD, "reading the board"),
1266 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1267 (BOARD_FIELDS, "reading the board's fields"),
1268 (DRAFT, "reading one draft"),
1269 (REPOSITORY, "reading the destination repository"),
1270 (
1271 CREATION_CONTEXT,
1272 "reading the board's fields and the destination repository",
1273 ),
1274 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1275 (CREATE_ISSUE, "creating an issue"),
1276 (ADD_TO_BOARD, "adding an issue to the board"),
1277 (UPDATE_ISSUE, "updating an issue"),
1278 (UPDATE_DRAFT, "updating a draft item"),
1279 (UPDATE_FIELD, "writing a board field"),
1280 (UPDATE_FIELDS, "writing board fields together"),
1281 (CLEAR_FIELD, "clearing a board field"),
1282 (
1283 CREATE_FIELD,
1284 "creating a board single-select field with its options",
1285 ),
1286 (
1287 STATUS_OPTIONS_SNAPSHOT,
1288 "snapshotting board Status options and assignments",
1289 ),
1290 (
1291 STATUS_OPTIONS_UPDATE,
1292 "safely replacing the board Status option list",
1293 ),
1294 (ADD_SUB_ISSUE, "filing an issue under its project"),
1295 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1296 (ADD_BLOCKED_BY, "recording a dependency"),
1297 (REMOVE_BLOCKED_BY, "removing a dependency"),
1298 (DELETE_ISSUE, "deleting an issue"),
1299 (ISSUE_COMMENTS, "reading a task's comments"),
1300 (ISSUE_DETAIL, "reading one issue with its comments"),
1301 (
1302 ISSUE_DETAILS,
1303 "reading a batch of issues with their comments",
1304 ),
1305 (COMMENT_ISSUE, "reading which issue a comment is on"),
1306 (ADD_COMMENT, "adding a comment"),
1307 (UPDATE_COMMENT, "editing a comment"),
1308 (DELETE_COMMENT, "deleting a comment"),
1309 ];
1310}
1311
1312/// Which of GitHub's two rate limiters refused a request.
1313///
1314/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1315/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1316/// the whole reason this is carried rather than collapsed into "rate limited".
1317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum Limiter {
1319 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1320 Primary,
1321 /// The burst limiter over content-generating requests, which nothing reports.
1322 Secondary,
1323}
1324
1325/// The wordings GitHub answers a secondary rate limit with.
1326///
1327/// It sends them under a forbidden status, under a too-many-requests status, and inside
1328/// the `errors` of a *successful* response, which is why the text is what this matches on
1329/// rather than the status. `abuse detection` is the wording GitHub used before the
1330/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1331/// what a burst of content creation is refused with.
1332///
1333/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1334/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1335/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1336/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1337pub const SECONDARY_WORDINGS: [&str; 5] = [
1338 "secondary rate limit",
1339 "temporarily blocked from content creation",
1340 "abuse detection",
1341 "submitted too quickly",
1342 "exceeded a secondary",
1343];
1344
1345/// The wordings GitHub answers an exhausted primary budget with.
1346///
1347/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1348/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1349/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1350/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1351/// two phrases is a substring of it, so without it that answer read as a refusal that will
1352/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1353/// one reason.
1354pub const PRIMARY_WORDINGS: [&str; 4] = [
1355 "api rate limit exceeded",
1356 "api rate limit already exceeded",
1357 "rate limit exceeded",
1358 "rate_limited",
1359];
1360
1361/// What a response *says about itself*, which is the only place a refusal can be read.
1362///
1363/// Deliberately not the whole response body. A board is a place people write about their
1364/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1365/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1366/// reported. So the item data is never read: what is read is GitHub's own REST-style
1367/// `message` envelope, which is what a forbidden status carries, and the `message` and
1368/// `type` of each GraphQL error, which is where a *successful* response says it.
1369///
1370/// A body that is not JSON at all has nothing structured to read, so only a failing
1371/// response's own text is taken — a successful response that is not JSON is malformed
1372/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1373fn refusal_wording(status: StatusCode, body: &str) -> String {
1374 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1375 return if status.is_success() {
1376 String::new()
1377 } else {
1378 body.to_owned()
1379 };
1380 };
1381 let mut said: Vec<&str> = parsed
1382 .get("message")
1383 .and_then(Value::as_str)
1384 .into_iter()
1385 .collect();
1386 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1387 for error in errors {
1388 said.extend(
1389 ["message", "type"]
1390 .into_iter()
1391 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1392 );
1393 }
1394 }
1395 said.join("; ")
1396}
1397
1398impl Limiter {
1399 /// Which limiter refused this response, or `None` when none of them did.
1400 ///
1401 /// The wording is read first and the status only decides what carries none of it,
1402 /// because GitHub answers a secondary limit with a forbidden status far more often
1403 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1404 /// really is a credential this token lacks.
1405 ///
1406 /// A response is a refusal because of its status or its own wording. A spent budget
1407 /// only ever explains one; it never turns an answer into a refusal.
1408 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1409 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1410 if SECONDARY_WORDINGS
1411 .iter()
1412 .any(|wording| normalized.contains(wording))
1413 {
1414 return Some(Self::Secondary);
1415 }
1416 if status == StatusCode::TOO_MANY_REQUESTS {
1417 return Some(Self::Primary);
1418 }
1419 // An exhausted budget *explains* a response that failed; it does not make one that
1420 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1421 // request the budget allowed as well as on the ones it then refuses, so reading
1422 // the header alone threw away a good answer — and, once refusals were retried,
1423 // replayed a request that had already taken effect.
1424 if !status.is_success() && budget_exhausted {
1425 return Some(Self::Primary);
1426 }
1427 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1428 // `errors` of an HTTP 200, where nothing about the status says so at all.
1429 if status.is_success()
1430 && PRIMARY_WORDINGS
1431 .iter()
1432 .any(|wording| normalized.contains(wording))
1433 {
1434 return Some(Self::Primary);
1435 }
1436 None
1437 }
1438
1439 /// What this limiter is called where an operator can look it up.
1440 const fn name(self) -> &'static str {
1441 match self {
1442 Self::Primary => "GitHub's primary API rate limit",
1443 Self::Secondary => "GitHub's secondary rate limit",
1444 }
1445 }
1446
1447 /// What the endpoint an operator would go and check says about this limiter.
1448 const fn where_to_look(self) -> &'static str {
1449 match self {
1450 Self::Primary => {
1451 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1452 comes back."
1453 }
1454 Self::Secondary => {
1455 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1456 primary budget and does not report this one, so budget showing there says \
1457 nothing about this refusal, and every further attempt extends it."
1458 }
1459 }
1460 }
1461
1462 /// The next step this limiter actually calls for.
1463 const fn what_to_do(self) -> &'static str {
1464 match self {
1465 Self::Primary => {
1466 "wait for the reset `gh api rate_limit` reports, then run the command again."
1467 }
1468 Self::Secondary => {
1469 "leave this board alone for a few minutes, then run the command again — or \
1470 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1471 }
1472 }
1473 }
1474}
1475
1476/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1477#[derive(Debug, Clone, Copy)]
1478struct Limited {
1479 limiter: Limiter,
1480 hint: Option<u64>,
1481}
1482
1483impl Limited {
1484 /// What the caller is told once this source has waited as long as it may.
1485 ///
1486 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1487 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1488 /// about *which* limiter it was makes it a different kind of failure. What differs is
1489 /// the operator's next step, and that is what the message carries — a secondary
1490 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1491 /// budget looks fine, and then back to retry the very burst that was refused.
1492 fn exhausted(
1493 self,
1494 doing: &str,
1495 waits: u32,
1496 waited: Duration,
1497 needed: Duration,
1498 budget: Duration,
1499 ) -> SourceError {
1500 SourceError::RateLimited {
1501 retry_after_seconds: self.hint,
1502 message: Some(format!(
1503 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1504 again, and the next wait of {} would take it past the {} one call may spend \
1505 waiting. {} next: {}",
1506 self.limiter.name(),
1507 plural(waits, "refusal"),
1508 seconds(waited),
1509 seconds(needed),
1510 seconds(budget),
1511 self.limiter.where_to_look(),
1512 self.limiter.what_to_do(),
1513 )),
1514 }
1515 }
1516}
1517
1518/// One HTTP attempt's result, with what its response said about the rate limit.
1519///
1520/// The two travel together so the record and the outcome are written from the same place:
1521/// what a response said about the budget is only readable while that response is in hand,
1522/// and what the attempt *meant* is only decidable once its body has been read.
1523struct Attempted {
1524 result: Result<Value, Attempt>,
1525 limits: accounting::RateLimit,
1526 /// GitHub's own reported cost for this call, for a document that asked for it.
1527 reported_cost: Option<u64>,
1528}
1529
1530/// One attempt's outcome: an error to report, or a rate limit to wait out.
1531enum Attempt {
1532 Failed(SourceError),
1533 Limited(Limited),
1534}
1535
1536fn plural(count: u32, thing: &str) -> String {
1537 if count == 1 {
1538 format!("{count} {thing}")
1539 } else {
1540 format!("{count} {thing}s")
1541 }
1542}
1543
1544fn seconds(duration: Duration) -> String {
1545 format!("{:.1}s", duration.as_secs_f64())
1546}
1547
1548/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1549///
1550/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1551/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1552/// header, and neither is what makes a response a refusal — so the whole cost of one this
1553/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1554/// instead. Refusing the response over the header would turn a readable refusal into an
1555/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1556fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1557 value
1558 .and_then(|value| value.to_str().ok())
1559 .and_then(|value| value.trim().parse::<u64>().ok())
1560}
1561
1562/// Every mutation this source sends creates content — an issue, a board item, a field of
1563/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1564/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1565/// and what the keyword says are the same set. That is what makes the keyword a sound test
1566/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1567/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1568fn is_mutation(query: &str) -> bool {
1569 query.trim_start().starts_with("mutation")
1570}
1571
1572/// What this source was doing, for a diagnostic that has to say so.
1573///
1574/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1575/// a document added without a description is caught by that list's own gate instead of
1576/// falling through to the vague arm below.
1577fn operation_description(query: &str) -> &'static str {
1578 graphql::DOCUMENTS
1579 .iter()
1580 .find(|(document, _)| *document == query)
1581 .map_or("talking to GitHub", |(_, doing)| *doing)
1582}
1583
1584/// GitHub's published ceiling on content-generating requests, per minute.
1585///
1586/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1587/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1588/// from it, so a pacing value checked only against itself cannot go stale here.
1589pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1590/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1591/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1592/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1593pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1594/// Shortest interval between two content-creating mutations, in milliseconds.
1595///
1596/// GitHub documents two secondary limits on content-generating requests:
1597/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1598/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1599/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1600/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1601/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1602/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1603/// deliberately *not* what this paces at. An installation that wants the hourly bound
1604/// honoured for a long sequence of copies says so through
1605/// `pacing.min_mutation_interval_ms`.
1606pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1607/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1608///
1609/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1610/// own advice for a secondary limit — wait, and wait longer each time — without spending
1611/// the first minute of a transient refusal doing nothing.
1612pub const RETRY_BACKOFF_MS: u64 = 1_000;
1613/// Total time one call may spend waiting out rate limits before it reports a failure.
1614///
1615/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1616/// short enough that a command an operator is watching returns. The bound is what makes
1617/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1618/// the limiter, not in a process nobody can tell from a wedged one.
1619pub const RETRY_BUDGET_MS: u64 = 120_000;
1620
1621fn default_token_env() -> String {
1622 "GH_PROJECTS_TOKEN".to_owned()
1623}
1624fn default_endpoint() -> String {
1625 "https://api.github.com/graphql".to_owned()
1626}
1627
1628/// Where one status category lands on this board.
1629///
1630/// `null` — an absent value — disables the category for this instance, and using a
1631/// disabled status is a refusal naming the status and the instance.
1632#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1633#[serde(untagged)]
1634pub enum StatusTargetConfig {
1635 /// The name of a `Status` single-select option already on the board.
1636 Column(ColumnName),
1637}
1638
1639/// The name of a `Status` single-select option on the board.
1640///
1641/// Validated on the way in rather than checked later, so a blank option name — which
1642/// nothing on a board can be — is a state this type cannot hold.
1643#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1644#[serde(try_from = "String")]
1645#[schemars(extend("minLength" = 1))]
1646pub struct ColumnName(String);
1647
1648impl ColumnName {
1649 /// The option name, as the board spells it.
1650 fn as_str(&self) -> &str {
1651 &self.0
1652 }
1653}
1654
1655impl TryFrom<String> for ColumnName {
1656 type Error = String;
1657
1658 fn try_from(name: String) -> Result<Self, Self::Error> {
1659 if name.trim().is_empty() {
1660 return Err("a status_mapping option name cannot be blank".to_owned());
1661 }
1662 Ok(Self(name))
1663 }
1664}
1665
1666/// The two closed states this product can mean.
1667///
1668/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1669/// work nor abandoned work, so nothing here ever writes it.
1670#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1671#[serde(rename_all = "kebab-case")]
1672pub enum ClosedState {
1673 /// `COMPLETED` — precisely done.
1674 Completed,
1675 /// `NOT_PLANNED` — precisely cancelled.
1676 NotPlanned,
1677}
1678
1679impl ClosedState {
1680 const fn reason(self) -> &'static str {
1681 match self {
1682 Self::Completed => "COMPLETED",
1683 Self::NotPlanned => "NOT_PLANNED",
1684 }
1685 }
1686}
1687
1688/// Configuration for one GitHub Projects v2 board.
1689#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1690#[serde(default, deny_unknown_fields)]
1691pub struct GitHubProjectsConfig {
1692 /// Login of the user or organization which owns the board.
1693 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1694 /// The project number shown in the board's GitHub URL.
1695 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1696 // 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.
1697 /// `owner/name` of the repository this source creates an issue in when the item's own
1698 /// `repositories` field does not decide it.
1699 ///
1700 /// An item naming exactly one repository is created there; a task or a document naming
1701 /// none or several is created in its parent project's repository; and a project, or a
1702 /// task or document with no parent, naming none or several is created here. A board
1703 /// has no repository of its own and `createIssue` requires one, so a write without
1704 /// this is refused naming the field. Reads never need it.
1705 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1706 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1707 /// Environment variable containing a fine-grained token with Projects and Issues
1708 /// read/write plus Pull requests read-only access for every repository represented on
1709 /// the board.
1710 #[serde(default = "default_token_env")]
1711 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1712 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1713 #[serde(default = "default_endpoint")]
1714 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1715 /// Per-instance mapping from a status category to where it lands on this board.
1716 ///
1717 /// A category this does not mention keeps its shipped default: `backlog` to
1718 /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1719 /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1720 /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1721 /// board option; every unknown word then lands on that option and reads back as
1722 /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1723 /// word because it never creates board options.
1724 #[serde(default)]
1725 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.
1726 /// Per-instance mapping from a task's priority to an option of this board's
1727 /// single-select field named `Priority`.
1728 ///
1729 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1730 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1731 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1732 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1733 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1734 /// no two levels may name one option. Reads and writes never create the field or an
1735 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1736 /// the board lacks is refused pointing there.
1737 #[serde(default)]
1738 pub priority_mapping: Option<PriorityMappingConfig>,
1739 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1740 ///
1741 /// Every field keeps its shipped default when it is absent, and the defaults are
1742 /// GitHub's own published limits rather than taste. See [`Pacing`].
1743 #[serde(default)]
1744 pub pacing: PacingConfig,
1745}
1746
1747/// Which option of the board's `Priority` field each priority lands on.
1748///
1749/// One member per level rather than a map, so a key that is not a level is refused where
1750/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1751/// value in the field, not an option of it.
1752#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1753#[serde(default, deny_unknown_fields)]
1754pub struct PriorityMappingConfig {
1755 /// The option `urgent` lands on; `Urgent` when absent.
1756 pub urgent: Option<PriorityOptionName>,
1757 /// The option `high` lands on; `High` when absent.
1758 pub high: Option<PriorityOptionName>,
1759 /// The option `medium` lands on; `Medium` when absent.
1760 pub medium: Option<PriorityOptionName>,
1761 /// The option `low` lands on; `Low` when absent.
1762 pub low: Option<PriorityOptionName>,
1763}
1764
1765/// The name of an option of the board's `Priority` single-select field.
1766///
1767/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1768/// blank name.
1769#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1770#[serde(try_from = "String")]
1771#[schemars(extend("minLength" = 1))]
1772pub struct PriorityOptionName(String);
1773
1774impl PriorityOptionName {
1775 /// The option name, as the board spells it.
1776 fn as_str(&self) -> &str {
1777 &self.0
1778 }
1779}
1780
1781impl TryFrom<String> for PriorityOptionName {
1782 type Error = String;
1783
1784 fn try_from(name: String) -> Result<Self, Self::Error> {
1785 if name.trim().is_empty() {
1786 return Err("a priority_mapping option name cannot be blank".to_owned());
1787 }
1788 Ok(Self(name))
1789 }
1790}
1791
1792/// The name of the board field a priority is held in.
1793pub const PRIORITY_FIELD: &str = "Priority";
1794
1795/// The four priorities a board option can hold, in the order a new `Priority` field lists
1796/// them. `none` is not among them: it is the field holding no value.
1797///
1798/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1799/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1800/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1801/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1802pub const PRIORITY_LEVELS: [Priority; 4] = [
1803 Priority::Urgent,
1804 Priority::High,
1805 Priority::Medium,
1806 Priority::Low,
1807];
1808
1809/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1810/// see that list for what this pins.
1811#[must_use]
1812pub const fn level_position(priority: Priority) -> Option<usize> {
1813 match priority {
1814 Priority::None => None,
1815 Priority::Urgent => Some(0),
1816 Priority::High => Some(1),
1817 Priority::Medium => Some(2),
1818 Priority::Low => Some(3),
1819 }
1820}
1821
1822/// This instance's complete priority-to-option mapping, read in both directions.
1823///
1824/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1825/// two levels name one option.
1826#[derive(Debug, Clone)]
1827struct PriorityMapping {
1828 options: [PriorityOptionName; 4],
1829}
1830
1831impl PriorityMapping {
1832 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1833 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1834 let mapping = Self {
1835 options: [
1836 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1837 config.high.unwrap_or_else(|| shipped("High")),
1838 config.medium.unwrap_or_else(|| shipped("Medium")),
1839 config.low.unwrap_or_else(|| shipped("Low")),
1840 ],
1841 };
1842 for (index, option) in mapping.options.iter().enumerate() {
1843 if let Some(earlier) = mapping.options[..index]
1844 .iter()
1845 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1846 {
1847 return Err(SourceError::Config {
1848 message: format!(
1849 "priority_mapping of source {instance} sends both {} and {} to the board \
1850 option {:?}; one option cannot read back as two priorities",
1851 PRIORITY_LEVELS[earlier],
1852 PRIORITY_LEVELS[index],
1853 option.as_str()
1854 ),
1855 });
1856 }
1857 }
1858 Ok(mapping)
1859 }
1860
1861 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1862 fn option(&self, priority: Priority) -> Option<&str> {
1863 level_position(priority).map(|index| self.options[index].as_str())
1864 }
1865
1866 /// The priority a board option name reports, or `None` when nothing maps to it.
1867 fn priority_of(&self, option: &str) -> Option<Priority> {
1868 self.options
1869 .iter()
1870 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1871 .map(|index| PRIORITY_LEVELS[index])
1872 }
1873
1874 /// Every mapped option name, in the order a new `Priority` field lists them.
1875 fn names(&self) -> impl Iterator<Item = &str> {
1876 self.options.iter().map(PriorityOptionName::as_str)
1877 }
1878}
1879
1880/// What one item's `Priority` field says, read through this instance's mapping.
1881#[derive(Debug, Clone, PartialEq, Eq)]
1882enum HeldPriority {
1883 /// A priority this source reports: an option the mapping names, or no value (`none`).
1884 Read(Priority),
1885 /// An option the mapping does not name, which is never read as a level or as `none`.
1886 Unmapped(String),
1887}
1888
1889/// How fast this source writes, and how long it waits out a rate-limit refusal.
1890///
1891/// Configurable because a GitHub Enterprise installation sets its own limits and an
1892/// operator who has already been refused may want to go slower still — not because the
1893/// defaults are guesses.
1894#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1895#[serde(default, deny_unknown_fields)]
1896pub struct PacingConfig {
1897 /// Shortest interval between two content-creating mutations, in milliseconds.
1898 ///
1899 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1900 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1901 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.
1902 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1903 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1904 /// zero while there is a budget to spend, because a schedule of zero-length waits
1905 /// consumes none of it and so never ends.
1906 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.
1907 /// Total time one call may spend waiting out rate limits, in milliseconds.
1908 ///
1909 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1910 /// the bound is what makes this a wait rather than a hang.
1911 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.
1912}
1913
1914/// The largest any pacing setting may be, in milliseconds.
1915///
1916/// One hour. GitHub's own harshest published bound on content-generating requests works
1917/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1918/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1919/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1920/// and an interval beyond it is a command that never sends its second mutation. It also
1921/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1922/// what an `Instant` can hold on every platform.
1923pub const MAX_PACING_MS: u64 = 3_600_000;
1924
1925/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1926/// source holds.
1927#[derive(Debug, Clone, Copy)]
1928struct Pacing {
1929 min_mutation_interval: Duration,
1930 retry_backoff: Duration,
1931 retry_budget: Duration,
1932}
1933
1934impl Pacing {
1935 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1936 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1937 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1938 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1939 message: format!(
1940 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1941 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1942 GitHub's own harshest published limit"
1943 ),
1944 }),
1945 Some(value) => Ok(Duration::from_millis(value)),
1946 None => Ok(Duration::from_millis(default)),
1947 };
1948 let retry_backoff = bounded(
1949 config.retry_backoff_ms,
1950 RETRY_BACKOFF_MS,
1951 "retry_backoff_ms",
1952 )?;
1953 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1954 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1955 return Err(SourceError::Config {
1956 message: format!(
1957 "pacing.retry_backoff_ms of source {instance} is 0 while \
1958 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1959 none of that budget, so it would retry a refusal forever. Set a backoff of \
1960 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1961 waiting at all",
1962 retry_budget.as_millis()
1963 ),
1964 });
1965 }
1966 Ok(Self {
1967 min_mutation_interval: bounded(
1968 config.min_mutation_interval_ms,
1969 MIN_MUTATION_INTERVAL_MS,
1970 "min_mutation_interval_ms",
1971 )?,
1972 retry_backoff,
1973 retry_budget,
1974 })
1975 }
1976}
1977
1978/// Factory for [`GitHubProjectsSource`].
1979#[derive(Debug, Clone, Copy, Default)]
1980pub struct Plugin;
1981
1982impl SourcePlugin for Plugin {
1983 fn kind(&self) -> &'static str {
1984 KIND
1985 }
1986 fn config_schema(&self) -> Schema {
1987 schema_for!(GitHubProjectsConfig)
1988 }
1989 fn build(
1990 &self,
1991 name: &SourceName,
1992 config: &Value,
1993 secrets: &dyn SecretResolver,
1994 ) -> Result<Box<dyn TaskSource>, SourceError> {
1995 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1996 }
1997}
1998
1999impl Plugin {
2000 /// Build a source recording every request it sends into an accounting the caller holds.
2001 ///
2002 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2003 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2004 /// session total rather than two — see [`accounting`] and
2005 /// [`GitHubProjectsSource::recording_into`].
2006 ///
2007 /// # Errors
2008 ///
2009 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2010 /// [`SourceError::Config`] for configuration this plugin cannot use and
2011 /// [`SourceError::Auth`] for a credential it cannot find.
2012 pub fn build_recording_into(
2013 &self,
2014 name: &SourceName,
2015 config: &Value,
2016 secrets: &dyn SecretResolver,
2017 ledger: Arc<Accounting>,
2018 ) -> Result<Box<dyn TaskSource>, SourceError> {
2019 let config: GitHubProjectsConfig =
2020 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2021 message: format!("source {name}: {e}"),
2022 })?;
2023 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2024 |error| match error {
2025 SourceError::Config { message } => SourceError::Config {
2026 message: format!("source {name}: {message}"),
2027 },
2028 SourceError::Auth { message } => SourceError::Auth {
2029 message: format!("source {name}: {message}"),
2030 },
2031 other => other,
2032 },
2033 )?;
2034 Ok(Box::new(source))
2035 }
2036}
2037
2038/// Where a status category lands on this board, once configuration is resolved.
2039#[derive(Debug, Clone, PartialEq, Eq)]
2040enum StatusTarget {
2041 /// Not usable against this instance.
2042 Disabled,
2043 /// The board's `Status` option of this name.
2044 Column(ColumnName),
2045 /// A closed issue, with both its board option and the reason that says which closed it means.
2046 // 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.
2047 Terminal(ColumnName, ClosedState),
2048}
2049
2050/// Every status category, in the order the vocabulary declares them.
2051///
2052/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2053/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2054/// added to the shared vocabulary fails to compile until it is named there, and this
2055/// crate's suite reconciles this list against that enum's own derived schema, which is
2056/// generated from the variants rather than written beside them. The schema is what
2057/// catches a list left one short — a list checking only the positions it already holds
2058/// would pass while every mapping indexed by the new position panicked.
2059pub const CATEGORIES: [StatusCategory; 8] = [
2060 StatusCategory::Draft,
2061 StatusCategory::Backlog,
2062 StatusCategory::Todo,
2063 StatusCategory::Queued,
2064 StatusCategory::InProgress,
2065 StatusCategory::Done,
2066 StatusCategory::Cancelled,
2067 StatusCategory::Unknown,
2068];
2069
2070/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2071#[must_use]
2072pub const fn category_position(category: StatusCategory) -> usize {
2073 match category {
2074 StatusCategory::Draft => 0,
2075 StatusCategory::Backlog => 1,
2076 StatusCategory::Todo => 2,
2077 StatusCategory::Queued => 3,
2078 StatusCategory::InProgress => 4,
2079 StatusCategory::Done => 5,
2080 StatusCategory::Cancelled => 6,
2081 StatusCategory::Unknown => 7,
2082 }
2083}
2084
2085/// The spelling a status category is configured and reported under.
2086fn category_name(category: StatusCategory) -> &'static str {
2087 match category {
2088 StatusCategory::Draft => "draft",
2089 StatusCategory::Backlog => "backlog",
2090 StatusCategory::Todo => "todo",
2091 StatusCategory::Queued => "queued",
2092 StatusCategory::InProgress => "in-progress",
2093 StatusCategory::Done => "done",
2094 StatusCategory::Cancelled => "cancelled",
2095 StatusCategory::Unknown => "unknown",
2096 }
2097}
2098
2099/// A shipped default's option name.
2100///
2101/// The literals below are this file's own and non-blank, and they are validated by the
2102/// one constructor a configured name goes through rather than beside it.
2103fn shipped_column(name: &'static str) -> ColumnName {
2104 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2105}
2106
2107/// The shipped default for one category, before this instance's configuration.
2108fn shipped_default(category: StatusCategory) -> StatusTarget {
2109 match category {
2110 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2111 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2112 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2113 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2114 StatusCategory::Done => {
2115 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2116 }
2117 StatusCategory::Cancelled => {
2118 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2119 }
2120 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
2121 }
2122}
2123
2124/// This instance's complete category-to-target mapping, read in both directions.
2125///
2126/// One target per category, held at that category's own [`category_position`], so a
2127/// category missing from the mapping, named twice in it, or filed out of order is a
2128/// state this type cannot hold rather than one [`Self::target`] has to defend against.
2129#[derive(Debug, Clone)]
2130struct StatusMapping {
2131 targets: [StatusTarget; CATEGORIES.len()],
2132}
2133
2134impl StatusMapping {
2135 fn resolve(
2136 configured: BTreeMap<String, Option<StatusTargetConfig>>,
2137 instance: &SourceName,
2138 ) -> Result<Self, SourceError> {
2139 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
2140 for (key, value) in configured {
2141 let category = CATEGORIES
2142 .iter()
2143 .find(|category| category_name(**category) == key)
2144 .ok_or_else(|| SourceError::Config {
2145 message: format!(
2146 "status_mapping names {key:?}, which is not a status category of source \
2147 {instance}; the categories are {}",
2148 CATEGORIES
2149 .iter()
2150 .map(|category| category_name(*category))
2151 .collect::<Vec<_>>()
2152 .join(", ")
2153 ),
2154 })?;
2155 overrides.insert(category_name(*category), value);
2156 }
2157 // `CATEGORIES[position] == category` for every category — the crate's suite
2158 // asserts it — so mapping the list in order fills each category's own slot.
2159 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
2160 None => shipped_default(category),
2161 Some(None) => StatusTarget::Disabled,
2162 Some(Some(StatusTargetConfig::Column(option))) => match category {
2163 StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
2164 StatusCategory::Cancelled => {
2165 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2166 }
2167 _ => StatusTarget::Column(option),
2168 },
2169 });
2170 let mapping = Self { targets };
2171 for (index, category) in CATEGORIES.into_iter().enumerate() {
2172 let option = match mapping.target(category) {
2173 StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
2174 StatusTarget::Disabled => continue,
2175 };
2176 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
2177 matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2178 if name.as_str().eq_ignore_ascii_case(option.as_str()))
2179 }) {
2180 return Err(SourceError::Config {
2181 message: format!(
2182 "status_mapping of source {instance} sends both {} and {} to the board \
2183 option {:?}; one option cannot read back as two categories",
2184 category_name(*other),
2185 category_name(category),
2186 option.as_str()
2187 ),
2188 });
2189 }
2190 }
2191 Ok(mapping)
2192 }
2193
2194 fn target(&self, category: StatusCategory) -> &StatusTarget {
2195 &self.targets[category_position(category)]
2196 }
2197
2198 /// The category a board option name reports, or `None` when nothing maps to it.
2199 fn category_of(&self, option: &str) -> Option<StatusCategory> {
2200 CATEGORIES.into_iter().find(|category| {
2201 matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2202 if name.as_str().eq_ignore_ascii_case(option))
2203 })
2204 }
2205
2206 /// The status an item reports, from the three things a read of it says: its board
2207 /// `Status` option, whether its issue is closed, and the reason it was closed with.
2208 ///
2209 /// The closed state decides the category and the `Status` option decides the name, so
2210 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2211 /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2212 /// duplicate is not finished work, and calling it done is a lie the next copy would
2213 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2214 /// it is read permissively rather than refused — reads are faithful, and refusals
2215 /// belong on writes.
2216 ///
2217 /// One function of those three rather than of a response, so a narrow status write can
2218 /// answer what a re-read would report by applying it to the state it has just written.
2219 fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2220 if closed {
2221 let category = match reason {
2222 None | Some("COMPLETED") => StatusCategory::Done,
2223 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2224 Some(_) => StatusCategory::Unknown,
2225 };
2226 let fallback = match category {
2227 StatusCategory::Done => "Done",
2228 StatusCategory::Cancelled => "Cancelled",
2229 _ => "Closed",
2230 };
2231 return Status {
2232 category,
2233 name: option.unwrap_or(fallback).to_owned(),
2234 };
2235 }
2236 let name = option.unwrap_or("Open").to_owned();
2237 Status {
2238 category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2239 name,
2240 }
2241 }
2242}
2243
2244// 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.
2245/// One repository this source can create an issue in, as `owner/name`.
2246///
2247/// Every `createIssue` this source sends names one of these: the item's own single
2248/// `repositories` entry, else its parent project issue's repository, else the configured
2249/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2250/// that choice and says what it refuses before `createIssue`.
2251// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2252#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2253struct RepositoryTarget {
2254 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2255 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2256}
2257
2258impl RepositoryTarget {
2259 fn parse(value: &str) -> Result<Self, SourceError> {
2260 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2261 message: format!(
2262 "repository must be spelled owner/name; {value:?} names no repository"
2263 ),
2264 })?;
2265 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2266 return Err(SourceError::Config {
2267 message: format!(
2268 "repository must be spelled owner/name with a GitHub login and one \
2269 repository name; {value:?} is not"
2270 ),
2271 });
2272 }
2273 Ok(Self {
2274 owner: owner.to_owned(),
2275 name: name.to_owned(),
2276 })
2277 }
2278
2279 /// The one host whose repositories this source creates issues in, spelled once: it is
2280 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2281 const HOST: &str = "github.com";
2282
2283 fn origin(&self) -> String {
2284 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2285 }
2286
2287 /// The repository a normalized origin names, or why it is none this source can create
2288 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2289 fn from_origin(origin: &Repository) -> Result<Self, String> {
2290 let not_here = || {
2291 format!(
2292 "{} is not a {}/owner/name repository",
2293 origin.as_str(),
2294 Self::HOST
2295 )
2296 };
2297 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2298 if host != Self::HOST {
2299 return Err(not_here());
2300 }
2301 Self::parse(rest).map_err(|_| not_here())
2302 }
2303
2304 fn slug(&self) -> String {
2305 format!("{}/{}", self.owner, self.name)
2306 }
2307}
2308
2309/// A source which reads GitHub afresh for every operation.
2310pub struct GitHubProjectsSource {
2311 /// This source's configured name, used both to tell a far end naming this source
2312 /// from one naming a system it knows nothing about, and to name the instance a
2313 /// status refusal is about.
2314 name: SourceName,
2315 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2316 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2317 repository: Option<RepositoryTarget>,
2318 endpoint: Url,
2319 token: SecretString,
2320 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2321 statuses: StatusMapping,
2322 /// Where each priority lands on this board, or `None` when this instance holds none.
2323 priorities: Option<PriorityMapping>,
2324 client: Client,
2325 /// Every item this source has created since it was built, in the order it created
2326 /// them.
2327 ///
2328 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2329 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2330 /// a copy resolving a dependency on an item it had just created refused it as not
2331 /// found. A board read is completed from this — an item remembered here and absent from
2332 /// the read is added back, because the board really does hold it and only the read is
2333 /// behind.
2334 ///
2335 /// It is not a cache of a user's work: nothing is remembered that this process did not
2336 /// itself just write, it lives and dies with the process, and it is never consulted for
2337 /// an item this source did not create.
2338 created: Mutex<Vec<Resolved>>,
2339 /// Every item that already existed and that this source has written since it was built,
2340 /// as it wrote it.
2341 ///
2342 /// The other half of [`Self::created`], held on the same terms and for the reason a
2343 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2344 /// filter is an index behind a write this process made moments ago, so a query matching
2345 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2346 /// is remembered that this process did not itself just write.
2347 updated: Mutex<Vec<Resolved>>,
2348 /// How fast this source writes, and how long it waits out a refusal.
2349 pacing: Pacing,
2350 /// When the last content-creating mutation finished, or the moment the furthest-out
2351 /// reserved slot releases the next one, whichever is later — so the one after it can be
2352 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2353 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2354 /// what it is measured from.
2355 last_mutation: Mutex<Option<Instant>>,
2356 /// The board as this process last read it, for the length of one command.
2357 ///
2358 /// A copy of a project used to re-read the whole board, paged, before writing each of
2359 /// its items, which is by far the largest part of a copy's request count and none of
2360 /// its work. Nothing else changes this board while a command runs — this source's own
2361 /// writes are the only writer — so one read answers them all.
2362 ///
2363 /// It is not a store of a user's work and it is not the cache the no-persistence
2364 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2365 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2366 /// an item this command created and then depends on resolves whether or not GitHub's
2367 /// own eventually-consistent read has caught up. A write to an item already on the
2368 /// board updates the entry here too, so what this holds is the last read plus this
2369 /// process's own writes rather than a snapshot taken before them.
2370 board_cache: Mutex<Option<Board>>,
2371 /// Every issue this board's own search reported, for the length of one command.
2372 ///
2373 /// The second half of a board read, and cached for the same reason and on the same
2374 /// terms as the first: it lives and dies with the process, nothing is written down, and
2375 /// a write this process makes updates the entry here exactly as it updates the one in
2376 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2377 /// that lists this board's projects and its tasks pays for one search rather than two.
2378 search_cache: Mutex<Option<Vec<Resolved>>>,
2379 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2380 /// the length of one command.
2381 ///
2382 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2383 /// and dies with the process, nothing is written down, a write this process makes
2384 /// updates the entry here as it updates the other two, and every answer is completed
2385 /// with this process's own writes each time it is given. A command that asks the same
2386 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2387 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2388 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2389 search_next: Mutex<BTreeMap<String, Option<String>>>,
2390 /// Records already resolved in this source instance, reused by writes and
2391 /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2392 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2393 /// The board's own id and field definitions as this process last read them on their
2394 /// own, for the length of one command.
2395 ///
2396 /// What a write needs of the board and its item does not say, read once per command
2397 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2398 /// lives and dies with the process and nothing is written down. It holds no item and so
2399 /// can answer no question about one — see [`Self::board_fields`].
2400 fields_cache: Mutex<Option<BoardFields>>,
2401 /// Each destination repository's node id, resolved once per repository
2402 /// rather than per issue created.
2403 ///
2404 /// A repository's node id does not change, and re-reading it for every issue of a copy
2405 /// spent one request per item on an answer this source already had. It is a map rather
2406 /// than one entry because a copy files each item in the repository its own
2407 /// `repositories` field names, so a plan across five repositories asks GitHub five
2408 /// times and not once per item.
2409 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2410 /// What every request this source sends is recorded into.
2411 ///
2412 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2413 /// a request leaves this crate, so nothing has to be switched on for a session to be
2414 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2415 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2416 /// source's reads and writes — adds up one accounting instead of two. See
2417 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2418 ledger: Arc<Accounting>,
2419}
2420
2421/// GitHub's closed single-select color vocabulary.
2422#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2423#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2424pub enum StatusOptionColor {
2425 /// Gray.
2426 Gray,
2427 /// Blue.
2428 Blue,
2429 /// Green.
2430 Green,
2431 /// Yellow.
2432 Yellow,
2433 /// Purple.
2434 Purple,
2435 /// Red.
2436 Red,
2437 /// Orange.
2438 Orange,
2439 /// Pink.
2440 Pink,
2441}
2442
2443/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2444/// applies its additions.
2445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2446pub enum SetupMode {
2447 /// Read without mutation.
2448 Plan,
2449 /// Apply and verify.
2450 Apply,
2451}
2452
2453/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2454/// against it goes on compiling.
2455pub type StatusOptionsMode = SetupMode;
2456
2457/// The explicit result of the requested operation.
2458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2459#[serde(rename_all = "kebab-case")]
2460pub enum StatusOptionsOutcome {
2461 /// A read-only plan.
2462 Planned,
2463 /// Apply found nothing missing.
2464 Unchanged,
2465 /// Additions were applied and verified.
2466 Applied,
2467}
2468
2469/// A GitHub single-select option's opaque GraphQL node identifier.
2470#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2471#[serde(transparent)]
2472pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2473
2474impl TryFrom<String> for StatusOptionId {
2475 type Error = String;
2476
2477 fn try_from(id: String) -> Result<Self, Self::Error> {
2478 if id.trim().is_empty() {
2479 return Err("a GitHub Status option id cannot be blank".to_owned());
2480 }
2481 Ok(Self(id))
2482 }
2483}
2484
2485/// One existing or proposed option in a guarded Status-field update.
2486#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2487pub struct StatusOption {
2488 /// GitHub's stable id.
2489 pub id: StatusOptionId,
2490 /// The visible option name.
2491 pub name: ColumnName,
2492 /// GitHub's single-select color token.
2493 pub color: StatusOptionColor,
2494 /// The option description, including an empty one.
2495 pub description: String,
2496}
2497
2498/// One board item's Status assignment, retained as recovery data.
2499#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2500pub struct StatusAssignment {
2501 /// The project item id whose assignment this is.
2502 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2503 // carried verbatim as operator recovery data; introducing a semantic type would claim
2504 // validation rules GitHub does not publish and no operation here interprets.
2505 pub item_id: String,
2506 /// The selected option, absent when the item has no status.
2507 #[serde(skip_serializing_if = "Option::is_none")]
2508 pub option: Option<AssignedStatusOption>,
2509}
2510
2511/// The inseparable id and name of an assigned option.
2512#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2513pub struct AssignedStatusOption {
2514 /// GitHub's stable id.
2515 pub id: StatusOptionId,
2516 /// The visible name.
2517 pub name: ColumnName,
2518}
2519
2520/// The plan and verified outcome of reconciling configured Status options.
2521#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2522pub struct StatusOptionsReport {
2523 /// The configured source name.
2524 pub source: SourceName,
2525 /// Configured option names absent before the operation.
2526 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2527 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2528 // serialized string here preserves the report's intentionally simple public contract.
2529 pub missing: Vec<String>,
2530 /// What the requested operation did.
2531 pub outcome: StatusOptionsOutcome,
2532 /// The complete option list observed before any mutation.
2533 pub existing: Vec<StatusOption>,
2534}
2535
2536#[derive(Debug, Clone, PartialEq, Eq)]
2537struct StatusSnapshot {
2538 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2539 // passed back as the mutation's project identity; a newtype could enforce no stronger
2540 // invariant because GitHub publishes no grammar for it.
2541 board_id: String,
2542 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2543 // passed back as the mutation's field identity; a newtype could enforce no stronger
2544 // invariant because GitHub publishes no grammar for it.
2545 field_id: String,
2546 options: Vec<StatusOption>,
2547 assignments: Vec<StatusAssignment>,
2548}
2549
2550/// The name of the board field a status is held in.
2551const STATUS_FIELD: &str = "Status";
2552
2553/// Every item's value of each field `report` names, as it stood before the setup wrote
2554/// anything — what a person puts back when the setup is refused part way.
2555fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2556 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2557 .fields
2558 .iter()
2559 .map(|field| (field.field.name(), before.assignments(field.field)))
2560 .collect();
2561 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2562 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2563 })
2564}
2565
2566/// One board field the guarded setup reads and writes — every one it reads, and the only
2567/// ones it writes.
2568#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2569pub enum BoardField {
2570 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2571 Status,
2572 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2573 Priority,
2574}
2575
2576impl BoardField {
2577 /// The field's name on the board.
2578 #[must_use]
2579 pub const fn name(self) -> &'static str {
2580 match self {
2581 Self::Status => STATUS_FIELD,
2582 Self::Priority => PRIORITY_FIELD,
2583 }
2584 }
2585
2586 /// The field a board calls `name`, or `None` for one this setup does not own.
2587 fn named(name: &str) -> Option<Self> {
2588 [Self::Status, Self::Priority]
2589 .into_iter()
2590 .find(|field| field.name() == name)
2591 }
2592}
2593
2594/// What the guarded setup did to one field.
2595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2596#[serde(rename_all = "kebab-case")]
2597pub enum FieldOutcome {
2598 /// A read-only plan.
2599 Planned,
2600 /// Apply found the field there with every configured option.
2601 Unchanged,
2602 /// Missing options were added to the field that was there, and verified.
2603 Applied,
2604 /// The field was not there; it was created holding the configured options, and verified.
2605 Created,
2606}
2607
2608/// One field's plan, or its verified outcome.
2609#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2610pub struct FieldReport {
2611 /// Which field.
2612 pub field: BoardField,
2613 /// Whether the board had the field before the operation.
2614 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2615 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2616 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2617 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2618 // one constructor, and it derives `outcome` from `exists` in one match.
2619 pub exists: bool,
2620 /// Configured option names the field lacked before the operation — every one of them,
2621 /// in the order a new field lists them, when the field was not there at all.
2622 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2623 // mapping name and has therefore already passed its nonblank validation; the serialized
2624 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2625 pub missing: Vec<String>,
2626 /// What the requested operation did.
2627 pub outcome: FieldOutcome,
2628 /// The field's complete option list observed before any mutation; empty when the field
2629 /// was not there.
2630 pub existing: Vec<StatusOption>,
2631}
2632
2633/// The plan and verified outcome of setting up every field a source's configuration names.
2634#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2635pub struct FieldsReport {
2636 /// The configured source name.
2637 pub source: SourceName,
2638 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2639 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2640 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2641 // per field would change a published JSON shape. The states the list could hold and the
2642 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2643 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2644 pub fields: Vec<FieldReport>,
2645}
2646
2647/// Which options one field is configured with, in the order a new field would list them.
2648struct FieldPlan {
2649 field: BoardField,
2650 wanted: Vec<String>,
2651}
2652
2653/// One single-select field as the guarded setup snapshots it.
2654#[derive(Debug, Clone, PartialEq, Eq)]
2655struct SnapshotField {
2656 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2657 // passed back as the mutation's field identity; a newtype could enforce no stronger
2658 // invariant because GitHub publishes no grammar for it.
2659 field_id: String,
2660 options: Vec<StatusOption>,
2661}
2662
2663/// Every single-select field of a board and every item's value of each.
2664#[derive(Debug, Clone, PartialEq, Eq)]
2665struct BoardSnapshot {
2666 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2667 // passed back as the mutation's project identity; a newtype could enforce no stronger
2668 // invariant because GitHub publishes no grammar for it.
2669 board_id: String,
2670 fields: BTreeMap<BoardField, SnapshotField>,
2671 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2672 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2673}
2674
2675impl BoardSnapshot {
2676 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2677 /// carries.
2678 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2679 self.items
2680 .iter()
2681 .map(|(item_id, values)| StatusAssignment {
2682 item_id: item_id.clone(),
2683 option: values.get(&field).cloned(),
2684 })
2685 .collect()
2686 }
2687}
2688
2689impl GitHubProjectsSource {
2690 /// Report missing configured Status options and, when `apply` is true, add them with
2691 /// a whole-list mutation that preserves every existing id and verifies the result.
2692 ///
2693 /// # Errors
2694 ///
2695 /// Refuses a board without a single-select `Status` field. A post-write difference in
2696 /// any pre-existing option id or item assignment is refused with the complete pre-write
2697 /// assignment snapshot in the diagnostic for recovery.
2698 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2699 // successful mutation, both drift refusals, source selection, missing Status, casing,
2700 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2701 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2702 // responses from entering the defensive malformed-response branches below.
2703 pub async fn status_options(
2704 &self,
2705 mode: StatusOptionsMode,
2706 ) -> Result<StatusOptionsReport, SourceError> {
2707 let before = self.status_snapshot().await?;
2708 let configured = self
2709 .statuses
2710 .targets
2711 .iter()
2712 // A terminal category's option is as configured as an open one's: a terminal
2713 // write validates it before closing and refuses when the board lacks it.
2714 .filter_map(|target| match target {
2715 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2716 Some(name.as_str().to_owned())
2717 }
2718 StatusTarget::Disabled => None,
2719 });
2720 let missing = configured
2721 .filter(|wanted| {
2722 !before
2723 .options
2724 .iter()
2725 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2726 })
2727 .collect::<Vec<_>>();
2728 let report = StatusOptionsReport {
2729 source: self.name.clone(),
2730 missing: missing.clone(),
2731 outcome: match (mode, missing.is_empty()) {
2732 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2733 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2734 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2735 },
2736 existing: before.options.clone(),
2737 };
2738 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2739 return Ok(report);
2740 }
2741 let mut options = before
2742 .options
2743 .iter()
2744 .map(|option| {
2745 json!({
2746 "id": option.id, "name": option.name, "color": option.color,
2747 "description": option.description,
2748 })
2749 })
2750 .collect::<Vec<_>>();
2751 options.extend(missing.iter().map(|name| {
2752 json!({
2753 "name": name, "color": "GRAY", "description": ""
2754 })
2755 }));
2756 self.graphql(
2757 graphql::STATUS_OPTIONS_UPDATE,
2758 json!({"input": {
2759 "projectId": before.board_id, "fieldId": before.field_id,
2760 "singleSelectOptions": options,
2761 }}),
2762 )
2763 .await?;
2764 let after = self.status_snapshot().await?;
2765 let options_preserved = before
2766 .options
2767 .iter()
2768 .all(|old| after.options.iter().any(|new| new == old));
2769 let additions_present = missing.iter().all(|wanted| {
2770 after
2771 .options
2772 .iter()
2773 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2774 });
2775 if !options_preserved || !additions_present || after.assignments != before.assignments {
2776 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2777 SourceError::Malformed {
2778 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2779 }
2780 })?;
2781 return Err(SourceError::Refused {
2782 message: format!(
2783 "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}"
2784 ),
2785 });
2786 }
2787 Ok(report)
2788 }
2789
2790 /// A fresh snapshot of the Status field and every board item's assignment of it.
2791 ///
2792 /// # Errors
2793 ///
2794 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2795 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2796 // Status alone, as this operation has always read it: a `Priority` field is another
2797 // operation's, so nothing about it can refuse this one.
2798 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2799 let field = board
2800 .fields
2801 .remove(&BoardField::Status)
2802 .ok_or_else(|| self.no_status_field())?;
2803 Ok(StatusSnapshot {
2804 assignments: board.assignments(BoardField::Status),
2805 board_id: board.board_id,
2806 field_id: field.field_id,
2807 options: field.options,
2808 })
2809 }
2810
2811 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2812 fn no_status_field(&self) -> SourceError {
2813 SourceError::Refused {
2814 message: format!("source {} board has no Status field", self.name),
2815 }
2816 }
2817
2818 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2819 // the real CLI loopback journey, including pagination. The individual malformed guards
2820 // are defensive validation of a schema-pinned third-party response, not separate user
2821 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2822 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2823 /// every board item's value of each, walked to the end of the board's items. A field not
2824 /// in `owned` is read past whatever it holds.
2825 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2826 let mut after: Option<String> = None;
2827 let mut snapshot: Option<BoardSnapshot> = None;
2828 loop {
2829 let data = self
2830 .graphql(
2831 graphql::STATUS_OPTIONS_SNAPSHOT,
2832 json!({
2833 "owner": self.owner, "number": self.project_number,
2834 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2835 }),
2836 )
2837 .await?;
2838 let board = data
2839 .pointer("/owner/projectV2")
2840 .filter(|board| board.is_object())
2841 .ok_or_else(|| SourceError::Refused {
2842 message: format!(
2843 "source {} has no accessible GitHub Projects board",
2844 self.name
2845 ),
2846 })?;
2847 if board
2848 .pointer("/fields/pageInfo/hasNextPage")
2849 .and_then(Value::as_bool)
2850 != Some(false)
2851 {
2852 return Err(SourceError::Malformed {
2853 message:
2854 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2855 .into(),
2856 });
2857 }
2858 let mut fields = BTreeMap::new();
2859 // Only the fields this setup owns, by name: a node the single-select fragment did not
2860 // match carries no name, and a person's own single-select field — a `Size`, a
2861 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2862 // `Status` or `Priority` field without its options is malformed, not absent.
2863 // 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.
2864 for (owned, field) in board
2865 .pointer("/fields/nodes")
2866 .and_then(Value::as_array)
2867 .ok_or_else(|| SourceError::Malformed {
2868 message: "GitHub project fields.nodes is not an array".into(),
2869 })?
2870 .iter()
2871 .filter_map(|field| {
2872 let named = BoardField::named(field.get("name")?.as_str()?)?;
2873 owned.contains(&named).then_some((named, field))
2874 })
2875 {
2876 let options = field
2877 .get("options")
2878 .and_then(Value::as_array)
2879 .ok_or_else(|| SourceError::Malformed {
2880 message: "GitHub single-select field options is not an array".into(),
2881 })?
2882 .iter()
2883 .map(|option| {
2884 Ok(StatusOption {
2885 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2886 .map_err(|message| SourceError::Malformed { message })?,
2887 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2888 .map_err(|message| SourceError::Malformed {
2889 message: format!(
2890 "GitHub single-select option name is invalid: {message}"
2891 ),
2892 })?,
2893 color: serde_json::from_value(
2894 option.get("color").cloned().unwrap_or(Value::Null),
2895 )
2896 .map_err(|error| {
2897 SourceError::Malformed {
2898 message: format!(
2899 "GitHub single-select option color is invalid: {error}"
2900 ),
2901 }
2902 })?,
2903 description: optional_str(option, "description")?
2904 .unwrap_or_default()
2905 .to_owned(),
2906 })
2907 })
2908 .collect::<Result<Vec<_>, SourceError>>()?;
2909 let snapshot = SnapshotField {
2910 field_id: required_nonblank_str(field, "id")?.to_owned(),
2911 options,
2912 };
2913 // A board's field names are unique, so a second one is an answer that cannot
2914 // say which field the setup would act on — refused rather than one chosen.
2915 if fields.insert(owned, snapshot).is_some() {
2916 return Err(SourceError::Malformed {
2917 message: format!(
2918 "GitHub answered two {} fields for this board",
2919 owned.name()
2920 ),
2921 });
2922 }
2923 }
2924 let board_id = required_nonblank_str(board, "id")?.to_owned();
2925 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2926 board_id,
2927 fields,
2928 items: Vec::new(),
2929 });
2930 let items = board
2931 .pointer("/items/nodes")
2932 .and_then(Value::as_array)
2933 .ok_or_else(|| SourceError::Malformed {
2934 message: "GitHub project items.nodes is not an array".into(),
2935 })?;
2936 for item in items {
2937 let field_values =
2938 item.get("fieldValues")
2939 .ok_or_else(|| SourceError::Malformed {
2940 message: "GitHub project item is missing fieldValues".into(),
2941 })?;
2942 if field_values
2943 .pointer("/pageInfo/hasNextPage")
2944 .and_then(Value::as_bool)
2945 != Some(false)
2946 {
2947 return Err(SourceError::Malformed {
2948 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2949 });
2950 }
2951 let values = item
2952 .pointer("/fieldValues/nodes")
2953 .and_then(Value::as_array)
2954 .ok_or_else(|| SourceError::Malformed {
2955 message: "GitHub project item fieldValues.nodes is not an array".into(),
2956 })?;
2957 let item_id = required_nonblank_str(item, "id")?;
2958 let mut assigned = BTreeMap::new();
2959 for value in values {
2960 let Some(field) = value
2961 .pointer("/field/name")
2962 .and_then(Value::as_str)
2963 .and_then(BoardField::named)
2964 .filter(|field| owned.contains(field))
2965 else {
2966 continue;
2967 };
2968 let held = assigned.insert(
2969 field,
2970 AssignedStatusOption {
2971 id: StatusOptionId::try_from(
2972 required_str(value, "optionId")?.to_owned(),
2973 )
2974 .map_err(|message| SourceError::Malformed { message })?,
2975 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2976 .map_err(|message| SourceError::Malformed {
2977 message: format!(
2978 "GitHub assigned {} name is invalid: {message}",
2979 field.name()
2980 ),
2981 })?,
2982 },
2983 );
2984 // An item holds one value of a field, so a second one leaves no way to
2985 // tell which it holds — and a verification or recovery built on either
2986 // could restore the wrong one.
2987 if held.is_some() {
2988 return Err(SourceError::Malformed {
2989 message: format!(
2990 "GitHub answered two {} values for board item {item_id}",
2991 field.name()
2992 ),
2993 });
2994 }
2995 }
2996 current.items.push((item_id.to_owned(), assigned));
2997 }
2998 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2999 message: "GitHub project is missing items".into(),
3000 })?;
3001 let has_next = page
3002 .pointer("/pageInfo/hasNextPage")
3003 .and_then(Value::as_bool)
3004 .ok_or_else(|| SourceError::Malformed {
3005 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3006 })?;
3007 if !has_next {
3008 break;
3009 }
3010 let next =
3011 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3012 validate_cursor_progress(after.as_deref(), next)?;
3013 after = Some(next.to_owned());
3014 }
3015 snapshot.ok_or_else(|| SourceError::Malformed {
3016 message: "GitHub returned no board field snapshot".into(),
3017 })
3018 }
3019 // llmlint: ignore-end[changed_behavior_has_e2e]
3020
3021 /// Report every board field this source's configuration names and, with
3022 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3023 /// the `Priority` field when the board has none.
3024 ///
3025 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3026 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3027 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3028 /// color and description: the whole option list goes back with every existing id, because
3029 /// a re-minted id clears every item's value.
3030 ///
3031 /// # Errors
3032 ///
3033 /// Refuses a board without a single-select `Status` field. After an apply the board is
3034 /// read again, and a pre-existing option or any item's value of either field that moved is
3035 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3036 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3037 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3038 // with no Status field and a non-github-projects source through the compiled CLI against
3039 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3040 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3041 let owned: Vec<BoardField> = if self.priorities.is_some() {
3042 vec![BoardField::Status, BoardField::Priority]
3043 } else {
3044 vec![BoardField::Status]
3045 };
3046 let before = self.board_snapshot(&owned).await?;
3047 let mut plans = vec![FieldPlan {
3048 field: BoardField::Status,
3049 wanted: self
3050 .statuses
3051 .targets
3052 .iter()
3053 .filter_map(|target| match target {
3054 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
3055 Some(name.as_str().to_owned())
3056 }
3057 StatusTarget::Disabled => None,
3058 })
3059 .collect(),
3060 }];
3061 if !before.fields.contains_key(&BoardField::Status) {
3062 return Err(self.no_status_field());
3063 }
3064 if let Some(mapping) = &self.priorities {
3065 plans.push(FieldPlan {
3066 field: BoardField::Priority,
3067 wanted: mapping.names().map(str::to_owned).collect(),
3068 });
3069 }
3070 // The snapshot reads single-select fields alone, so a field it did not find may still
3071 // be on the board under the name, of another type: creating one beside it would fail
3072 // part way, or leave two fields of one name. Asked of the board's own field list, and
3073 // only when a field is missing.
3074 if plans
3075 .iter()
3076 .any(|plan| !before.fields.contains_key(&plan.field))
3077 {
3078 let board = self.board_fields().await?;
3079 for plan in plans
3080 .iter()
3081 .filter(|plan| !before.fields.contains_key(&plan.field))
3082 {
3083 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3084 return Err(SourceError::Refused {
3085 message: format!(
3086 "source {}'s board has a {} field that is not a single-select field \
3087 (it is a {}), so it cannot hold this source's options; next: rename \
3088 or remove that field, then run this again",
3089 self.name,
3090 plan.field.name(),
3091 optional_str(field, "__typename")?.unwrap_or("field of another type")
3092 ),
3093 });
3094 }
3095 }
3096 }
3097 let mut reports = Vec::new();
3098 for plan in &plans {
3099 let held = before.fields.get(&plan.field);
3100 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3101 let mut missing: Vec<String> = Vec::new();
3102 for wanted in &plan.wanted {
3103 let present = existing
3104 .iter()
3105 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3106 || missing
3107 .iter()
3108 .any(|named| named.eq_ignore_ascii_case(wanted));
3109 if !present {
3110 missing.push(wanted.clone());
3111 }
3112 }
3113 reports.push(FieldReport {
3114 field: plan.field,
3115 exists: held.is_some(),
3116 outcome: match (mode, held.is_some(), missing.is_empty()) {
3117 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3118 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3119 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3120 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3121 },
3122 missing,
3123 existing,
3124 });
3125 }
3126 let report = FieldsReport {
3127 source: self.name.clone(),
3128 fields: reports,
3129 };
3130 let writes: Vec<&FieldReport> = report
3131 .fields
3132 .iter()
3133 .filter(|field| !field.missing.is_empty() || !field.exists)
3134 .collect();
3135 if mode == SetupMode::Plan || writes.is_empty() {
3136 return Ok(report);
3137 }
3138 let mut landed: Vec<&str> = Vec::new();
3139 for field in &writes {
3140 let added = field
3141 .missing
3142 .iter()
3143 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3144 let sent = match before.fields.get(&field.field) {
3145 Some(held) => {
3146 let mut options = held
3147 .options
3148 .iter()
3149 .map(|option| {
3150 json!({
3151 "id": option.id, "name": option.name, "color": option.color,
3152 "description": option.description,
3153 })
3154 })
3155 .collect::<Vec<_>>();
3156 options.extend(added);
3157 self.graphql(
3158 graphql::STATUS_OPTIONS_UPDATE,
3159 json!({"input": {
3160 "projectId": before.board_id, "fieldId": held.field_id,
3161 "singleSelectOptions": options,
3162 }}),
3163 )
3164 .await
3165 }
3166 None => {
3167 self.graphql(
3168 graphql::CREATE_FIELD,
3169 json!({"input": {
3170 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3171 "name": field.field.name(),
3172 "singleSelectOptions": added.collect::<Vec<_>>(),
3173 }}),
3174 )
3175 .await
3176 }
3177 };
3178 // A mutation that failed does not establish that GitHub left its field as it was,
3179 // so every failure from here on carries the recovery data a drift refusal does.
3180 match sent {
3181 Ok(_) => landed.push(field.field.name()),
3182 Err(error) => {
3183 let changed = if landed.is_empty() {
3184 String::new()
3185 } else {
3186 format!("changed the {} field and then ", landed.join(" and "))
3187 };
3188 return Err(SourceError::Refused {
3189 message: format!(
3190 "the guarded field setup {changed}failed on the {} field, which it may \
3191 have changed part way: {error}; the pre-write item assignments \
3192 are:\n{}",
3193 field.field.name(),
3194 recovery(&report, &before)?
3195 ),
3196 });
3197 }
3198 }
3199 }
3200 // The board has been written, so a verification read that fails leaves it unverified
3201 // rather than unchanged, and says what to put back.
3202 let after = match self.board_snapshot(&owned).await {
3203 Ok(after) => after,
3204 Err(error) => {
3205 return Err(SourceError::Refused {
3206 message: format!(
3207 "the guarded field setup changed the {} field and then could not read the \
3208 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3209 landed.join(" and "),
3210 recovery(&report, &before)?
3211 ),
3212 });
3213 }
3214 };
3215 let mut moved = Vec::new();
3216 for field in &report.fields {
3217 let name = field.field.name();
3218 let now = after
3219 .fields
3220 .get(&field.field)
3221 .map(|held| held.options.as_slice())
3222 .unwrap_or_default();
3223 if !field.existing.iter().all(|old| now.contains(old)) {
3224 moved.push(format!(
3225 "a pre-existing {name} option id, name, color or description"
3226 ));
3227 }
3228 if !field.missing.iter().all(|wanted| {
3229 now.iter()
3230 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3231 }) {
3232 moved.push(format!("an added {name} option"));
3233 }
3234 if after.assignments(field.field) != before.assignments(field.field) {
3235 moved.push(format!("an item's {name} value"));
3236 }
3237 }
3238 if !moved.is_empty() {
3239 return Err(SourceError::Refused {
3240 message: format!(
3241 "GitHub changed {} after the guarded field setup; the pre-write item \
3242 assignments are:\n{}",
3243 moved.join(", "),
3244 recovery(&report, &before)?
3245 ),
3246 });
3247 }
3248 Ok(report)
3249 }
3250
3251 /// Validate configuration and capture the named credential without exposing it.
3252 ///
3253 /// # Errors
3254 ///
3255 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3256 /// [`SourceError::Auth`] when the named credential is missing or empty.
3257 pub fn new(
3258 name: &SourceName,
3259 config: GitHubProjectsConfig,
3260 secrets: &dyn SecretResolver,
3261 ) -> Result<Self, SourceError> {
3262 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3263 }
3264
3265 /// The same, recording every request it sends into an accounting the caller holds too.
3266 ///
3267 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3268 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3269 /// up — passes the one it records those into, so the session total accounts for the
3270 /// whole session rather than for this source's share of it.
3271 ///
3272 /// # Errors
3273 ///
3274 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3275 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3276 pub fn recording_into(
3277 name: &SourceName,
3278 config: GitHubProjectsConfig,
3279 secrets: &dyn SecretResolver,
3280 ledger: Arc<Accounting>,
3281 ) -> Result<Self, SourceError> {
3282 if !valid_github_owner(&config.owner) {
3283 return Err(SourceError::Config {
3284 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3285 });
3286 }
3287 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3288 return Err(SourceError::Config {
3289 message: format!("project_number must be between 1 and {}", i32::MAX),
3290 });
3291 }
3292 if !valid_environment_name(&config.token_env) {
3293 return Err(SourceError::Config {
3294 message: "token_env must be a valid environment-variable name".into(),
3295 });
3296 }
3297 let repository = config
3298 .repository
3299 .as_deref()
3300 .map(RepositoryTarget::parse)
3301 .transpose()?;
3302 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3303 message: format!("endpoint is not a valid URL: {e}"),
3304 })?;
3305 if endpoint.scheme() != "https"
3306 && !(endpoint.scheme() == "http"
3307 && endpoint
3308 .host_str()
3309 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3310 {
3311 return Err(SourceError::Config {
3312 message:
3313 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3314 .into(),
3315 });
3316 }
3317 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3318 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),
3319 })?;
3320 Ok(Self {
3321 name: name.clone(),
3322 owner: config.owner,
3323 project_number: config.project_number,
3324 repository,
3325 endpoint,
3326 token,
3327 credential_name: config.token_env,
3328 statuses: StatusMapping::resolve(config.status_mapping, name)?,
3329 priorities: config
3330 .priority_mapping
3331 .map(|mapping| PriorityMapping::resolve(mapping, name))
3332 .transpose()?,
3333 client: Client::builder()
3334 .user_agent("onetaskgraph")
3335 .build()
3336 .map_err(|e| SourceError::Config {
3337 message: format!("cannot build HTTP client: {e}"),
3338 })?,
3339 created: Mutex::new(Vec::new()),
3340 updated: Mutex::new(Vec::new()),
3341 pacing: Pacing::resolve(config.pacing, name)?,
3342 last_mutation: Mutex::new(None),
3343 board_cache: Mutex::new(None),
3344 search_cache: Mutex::new(None),
3345 narrowed_cache: Mutex::new(BTreeMap::new()),
3346 resolved_cache: Mutex::new(BTreeMap::new()),
3347 search_next: Mutex::new(BTreeMap::new()),
3348 fields_cache: Mutex::new(None),
3349 repository_cache: Mutex::new(BTreeMap::new()),
3350 ledger,
3351 })
3352 }
3353
3354 /// A snapshot of every request this source has sent, and what each cost.
3355 ///
3356 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3357 /// of them can sit side by side. When this source was built with
3358 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3359 /// point of building it that way.
3360 #[must_use]
3361 pub fn accounting(&self) -> accounting::Session {
3362 self.ledger.snapshot()
3363 }
3364
3365 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3366 /// rate limit rather than handing it straight back as an error.
3367 ///
3368 /// Retrying is safe for every document here, including the mutations, and the reason
3369 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3370 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3371 /// this replays has already taken effect. An outcome this source cannot know — the
3372 /// send failed, or the body could not be read, so the mutation may well have landed —
3373 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3374 /// attempt. A duplicate write would come from replaying one of those, and none is
3375 /// replayed.
3376 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3377 if is_mutation(query)
3378 && ![
3379 graphql::ADD_COMMENT,
3380 graphql::UPDATE_COMMENT,
3381 graphql::DELETE_COMMENT,
3382 ]
3383 .contains(&query)
3384 {
3385 let mut cache = self.resolved_cache()?;
3386 for argument in ["input", "second", "third", "clear"] {
3387 if let Some(input) = variables.get(argument) {
3388 cache.retain(|id, item| {
3389 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3390 input
3391 .get(key)
3392 .and_then(Value::as_str)
3393 .is_some_and(|value| value == id.0 || value == item.item_id)
3394 })
3395 });
3396 }
3397 }
3398 }
3399 let doing = operation_description(query);
3400 let mut waited = Duration::ZERO;
3401 let mut waits = 0_u32;
3402 let mut backoff = self.pacing.retry_backoff;
3403 loop {
3404 if is_mutation(query) {
3405 let spacing = self.reserve_mutation_slot();
3406 if !spacing.is_zero() {
3407 tokio::time::sleep(spacing).await;
3408 }
3409 }
3410 let attempt = self.send_once(query, &variables).await;
3411 if is_mutation(query) {
3412 self.finish_mutation();
3413 }
3414 let limited = match attempt {
3415 Ok(data) => return Ok(data),
3416 Err(Attempt::Failed(error)) => return Err(error),
3417 Err(Attempt::Limited(limited)) => limited,
3418 };
3419 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3420 // move that extends a secondary limit, so a hint below the schedule's own next
3421 // wait is raised to it.
3422 let wait = match limited.hint {
3423 Some(hint) => Duration::from_secs(hint).max(backoff),
3424 None => backoff,
3425 };
3426 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3427 // A wait of nothing spends none of the budget, so it is exhaustion rather
3428 // than a retry. `Pacing::resolve` rules out every way of configuring one
3429 // except a budget of zero, where reporting the first refusal is the ask.
3430 if wait.is_zero() || wait > remaining {
3431 return Err(limited.exhausted(
3432 doing,
3433 waits,
3434 waited,
3435 wait,
3436 self.pacing.retry_budget,
3437 ));
3438 }
3439 tokio::time::sleep(wait).await;
3440 waited += wait;
3441 waits += 1;
3442 backoff = backoff.saturating_mul(2);
3443 }
3444 }
3445
3446 /// The next moment a content-creating mutation may leave this source, as a wait from
3447 /// now.
3448 ///
3449 /// The slot is reserved under the lock and the waiting happens outside it, so two
3450 /// callers take two slots rather than the same one — and no lock is held across an
3451 /// await.
3452 ///
3453 /// The moment it is spaced from is the previous mutation's *completion*, which
3454 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3455 /// own is the wrong thing to measure from.
3456 fn reserve_mutation_slot(&self) -> Duration {
3457 if self.pacing.min_mutation_interval.is_zero() {
3458 return Duration::ZERO;
3459 }
3460 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3461 // it would turn an earlier failure into a second one for no gain.
3462 let mut last = self
3463 .last_mutation
3464 .lock()
3465 .unwrap_or_else(std::sync::PoisonError::into_inner);
3466 let now = Instant::now();
3467 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3468 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3469 let at = last.map_or(now, |previous| {
3470 previous
3471 .checked_add(self.pacing.min_mutation_interval)
3472 .map_or(now, |earliest| earliest.max(now))
3473 });
3474 *last = Some(at);
3475 at.saturating_duration_since(now)
3476 }
3477
3478 /// Record that a content-creating mutation has finished, so the next one is spaced
3479 /// from here rather than from the moment this one was released.
3480 ///
3481 /// This source can only choose when a request *departs*; the limiter counts when it
3482 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3483 /// departure from the last therefore hands the limiter a gap of the interval less that
3484 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3485 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3486 /// slower machine while passing on a quick one.
3487 ///
3488 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3489 /// previous request had already arrived before its response came back, so its arrival
3490 /// is no later than this moment, and the next mutation is released at least the
3491 /// interval after this moment and arrives no earlier than it is released: the gap the
3492 /// limiter measures is therefore at least the interval, whatever transit costs and on
3493 /// whatever platform. The price is that a mutation's own round trip no longer counts
3494 /// towards its spacing, which makes this source slightly slower than the configured
3495 /// rate rather than slightly faster — the safe side of a limit that punishes being
3496 /// wrong by refusing reads for the next fifty minutes.
3497 ///
3498 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3499 /// and one that never left costs only a wait nobody needed.
3500 fn finish_mutation(&self) {
3501 if self.pacing.min_mutation_interval.is_zero() {
3502 return;
3503 }
3504 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3505 let mut last = self
3506 .last_mutation
3507 .lock()
3508 .unwrap_or_else(std::sync::PoisonError::into_inner);
3509 let now = Instant::now();
3510 // `max` rather than an assignment: a concurrent caller may already have reserved a
3511 // slot further out, and completing this request must never pull that slot back in.
3512 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3513 }
3514
3515 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3516 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3517 ///
3518 /// This is the one place a request leaves this crate, which is why the accounting is
3519 /// here rather than at each of the callers: a read path added later is counted without
3520 /// anybody remembering to count it, and
3521 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3522 /// when one is not.
3523 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3524 let Attempted {
3525 result,
3526 limits,
3527 reported_cost,
3528 } = self.attempt(query, variables).await;
3529 // No `otherwise` name: every document this source sends is one of its own, and the
3530 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3531 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3532 let outcome = match &result {
3533 Ok(_) => accounting::Outcome::Answered,
3534 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3535 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3536 };
3537 self.ledger.record(sending.finished(outcome, limits));
3538 result
3539 }
3540
3541 /// The attempt itself, with what its response said about the rate limit alongside.
3542 ///
3543 /// The two are returned together rather than recorded here because every one of the
3544 /// early exits below is a different outcome, and a record written at each of them is a
3545 /// record one of them can be added without.
3546 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3547 let mut limits = accounting::RateLimit::default();
3548 let mut reported_cost = None;
3549 let result = self
3550 .attempted(query, variables, &mut limits, &mut reported_cost)
3551 .await;
3552 Attempted {
3553 result,
3554 limits,
3555 reported_cost,
3556 }
3557 }
3558
3559 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3560 async fn attempted(
3561 &self,
3562 query: &str,
3563 variables: &Value,
3564 limits: &mut accounting::RateLimit,
3565 reported_cost: &mut Option<u64>,
3566 ) -> Result<Value, Attempt> {
3567 let response = self
3568 .client
3569 .post(self.endpoint.clone())
3570 .bearer_auth(self.token.expose_secret())
3571 .json(&json!({"query": query, "variables": variables}))
3572 .send()
3573 .await
3574 .map_err(|e| {
3575 Attempt::Failed(SourceError::Unavailable {
3576 message: format!("GitHub GraphQL request failed: {e}"),
3577 })
3578 })?;
3579 let status = response.status();
3580 let header = |name: &str| whole_seconds(response.headers().get(name));
3581 *limits = accounting::RateLimit::read(|name| {
3582 response
3583 .headers()
3584 .get(name)
3585 .and_then(|value| value.to_str().ok())
3586 .map(str::to_owned)
3587 });
3588 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3589 // that are not text at all — is "not known to be exhausted". This never makes a
3590 // response a refusal on its own: it says which limiter a refusal is attributed to
3591 // and where its hint comes from, so a value this cannot read costs a hint rather
3592 // than an answer.
3593 let exhausted = response
3594 .headers()
3595 .get("x-ratelimit-remaining")
3596 .and_then(|value| value.to_str().ok())
3597 == Some("0");
3598 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3599 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3600 // which is the same question answered as an absolute time. Nothing else here is a
3601 // hint, and a schedule is what answers a refusal that carries none.
3602 let hint = header("retry-after").or_else(|| {
3603 exhausted
3604 .then(|| header("x-ratelimit-reset"))
3605 .flatten()
3606 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3607 });
3608 // Read before it is parsed, because the evidence which tells a secondary rate
3609 // limit from a rejected credential is in the body of a response whose status says
3610 // only "forbidden" — and a non-success response was never parsed at all.
3611 let body = response.text().await.map_err(|e| {
3612 Attempt::Failed(SourceError::Unavailable {
3613 message: format!("GitHub GraphQL response could not be read: {e}"),
3614 })
3615 })?;
3616 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3617 return Err(Attempt::Limited(Limited { limiter, hint }));
3618 }
3619 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3620 return Err(Attempt::Failed(SourceError::Auth {
3621 message: format!(
3622 "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"
3623 ),
3624 }));
3625 }
3626 if !status.is_success() {
3627 return Err(Attempt::Failed(SourceError::Unavailable {
3628 message: format!("GitHub GraphQL returned HTTP {status}"),
3629 }));
3630 }
3631 // GitHub reports what a call cost only when the document asked it to, and no
3632 // document this source sends does — so this is `None` here and carries the figure
3633 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3634 // pick up is a `dryRun` probe's cost, which is some other document's.
3635 *reported_cost = serde_json::from_str::<Value>(&body)
3636 .ok()
3637 .as_ref()
3638 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3639 .and_then(Value::as_u64);
3640 self.answer(&body).map_err(Attempt::Failed)
3641 }
3642
3643 /// What one successful HTTP response says, once its GraphQL errors are read.
3644 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3645 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3646 message: format!("GitHub returned invalid JSON: {e}"),
3647 })?;
3648 let errors = body
3649 .get("errors")
3650 .map(|value| {
3651 value.as_array().ok_or_else(|| SourceError::Malformed {
3652 message: "GitHub response errors is not an array".into(),
3653 })
3654 })
3655 .transpose()?;
3656 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3657 let messages = errors
3658 .iter()
3659 .filter_map(|e| e.get("message").and_then(Value::as_str))
3660 .collect::<Vec<_>>()
3661 .join("; ");
3662 let message = if messages.is_empty() {
3663 "GitHub returned GraphQL errors".into()
3664 } else {
3665 messages
3666 };
3667 let normalized = message.to_ascii_lowercase();
3668 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3669 return Err(SourceError::Auth {
3670 message: format!(
3671 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3672 self.credential_name
3673 ),
3674 });
3675 }
3676 return Err(SourceError::Refused { message });
3677 }
3678 body.get("data")
3679 .filter(|data| data.is_object())
3680 .cloned()
3681 .ok_or_else(|| SourceError::Malformed {
3682 message: "GitHub response has no data object".into(),
3683 })
3684 }
3685
3686 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3687 // GraphQL cannot independently page them inside the outer item page. This source page is
3688 // deliberately bounded at that published maximum; the live drift journey exercises it.
3689 async fn board_page(
3690 &self,
3691 items_after: Option<&str>,
3692 items_first: u32,
3693 ) -> Result<Value, SourceError> {
3694 let data = self
3695 .graphql(
3696 graphql::BOARD,
3697 json!({"owner":self.owner,"number":self.project_number,
3698 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3699 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3700 )
3701 .await?;
3702 data.pointer("/owner/projectV2")
3703 .filter(|v| !v.is_null())
3704 .cloned()
3705 .ok_or_else(|| SourceError::Refused {
3706 message: format!(
3707 "GitHub project {}/{} was not found or is not visible to the token",
3708 self.owner, self.project_number
3709 ),
3710 })
3711 }
3712
3713 /// The search that finds the issues of this board, narrowed by `also` when it is
3714 /// given.
3715 ///
3716 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3717 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3718 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3719 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3720 /// from a task by the `parent` field each issue carries rather than by the search.
3721 fn board_search(&self, also: Option<&str>) -> String {
3722 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3723 match also {
3724 Some(also) => format!("{scope} {also}"),
3725 None => scope,
3726 }
3727 }
3728
3729 /// One issue this source reached directly, as the board item a read of the board would
3730 /// have produced — or `None` when this board does not hold it.
3731 ///
3732 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3733 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3734 /// item's own id, that item's field values, and the issue as its content. One resolver
3735 /// for both routes is what makes an issue read through a search, through its own node
3736 /// id, or through its project's sub-issues report the same title, the same status, the
3737 /// same labels and the same qualified id.
3738 ///
3739 /// An issue with no entry for *this* board is not this source's to report, which is
3740 /// what keeps an id naming some other repository's issue from being answered as an item
3741 /// of this board. That answer is given about an **exhausted** connection and never
3742 /// about an unread page: the entry is looked for on the page in hand, and only if that
3743 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3744 /// rest of it.
3745 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3746 if optional_str(issue, "__typename")? != Some("Issue") {
3747 return Ok(None);
3748 }
3749 let memberships = issue
3750 .get("projectItems")
3751 .ok_or_else(|| SourceError::Malformed {
3752 message: "GitHub issue is missing projectItems".into(),
3753 })?;
3754 let nodes = memberships
3755 .get("nodes")
3756 .and_then(Value::as_array)
3757 .ok_or_else(|| SourceError::Malformed {
3758 message: "GitHub issue projectItems.nodes is not an array".into(),
3759 })?;
3760 let held = match self.board_entry(nodes) {
3761 Some(held) => held.clone(),
3762 None => {
3763 let info = memberships
3764 .get("pageInfo")
3765 .ok_or_else(|| SourceError::Malformed {
3766 message: "GitHub issue projectItems has no pageInfo".into(),
3767 })?;
3768 // The page held no entry for this board. Whether that means the issue is
3769 // not on it is a question about the rest of the connection, and only a
3770 // connection with no rest answers it here.
3771 if !required_bool(info, "hasNextPage")? {
3772 return Ok(None);
3773 }
3774 let cursor = required_str(info, "endCursor")?;
3775 validate_cursor_progress(None, cursor)?;
3776 let issue_id = required_str(issue, "id")?;
3777 match self.board_membership(issue_id, cursor).await? {
3778 Some(held) => held,
3779 None => return Ok(None),
3780 }
3781 }
3782 };
3783 let item = json!({
3784 "id": required_str(&held, "id")?,
3785 "project": held.get("project"),
3786 "fieldValues": held.get("fieldValues"),
3787 "content": issue,
3788 });
3789 self.resolve(&item)
3790 }
3791
3792 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3793 ///
3794 /// One spelling of *which membership is this board's*, so the page a read carries and
3795 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3796 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3797 nodes.iter().find(|node| {
3798 node.pointer("/project/number").and_then(Value::as_u64)
3799 == Some(u64::from(self.project_number))
3800 })
3801 }
3802
3803 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3804 ///
3805 /// The recovery read: a page of memberships that holds no entry for this board says
3806 /// nothing about the memberships past it, so the connection is walked to exhaustion
3807 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3808 /// positive answer — the whole connection was read and no entry named this board —
3809 /// rather than a failure, and the walk is held to
3810 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3811 /// with a cursor that does not advance is refused instead of spun on.
3812 async fn board_membership(
3813 &self,
3814 issue: &str,
3815 after: &str,
3816 ) -> Result<Option<Value>, SourceError> {
3817 let mut after = after.to_owned();
3818 loop {
3819 let data = self
3820 .graphql(
3821 graphql::ISSUE_BOARD_ITEMS,
3822 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3823 "nestedFirst":NESTED_PAGE_SIZE}),
3824 )
3825 .await?;
3826 let Some(connection) = data
3827 .pointer("/node/projectItems")
3828 .filter(|value| !value.is_null())
3829 else {
3830 // The id resolved to nothing, or to something with no memberships to walk —
3831 // which is the same answer as a connection holding no entry for this board.
3832 return Ok(None);
3833 };
3834 let nodes = connection
3835 .get("nodes")
3836 .and_then(Value::as_array)
3837 .ok_or_else(|| SourceError::Malformed {
3838 message: "GitHub issue projectItems.nodes is not an array".into(),
3839 })?;
3840 if let Some(held) = self.board_entry(nodes) {
3841 return Ok(Some(held.clone()));
3842 }
3843 let info = connection
3844 .get("pageInfo")
3845 .ok_or_else(|| SourceError::Malformed {
3846 message: "GitHub issue projectItems has no pageInfo".into(),
3847 })?;
3848 let next = required_bool(info, "hasNextPage")?
3849 .then(|| required_str(info, "endCursor"))
3850 .transpose()?;
3851 match next {
3852 Some(next) => {
3853 validate_cursor_progress(Some(&after), next)?;
3854 after = next.to_owned();
3855 }
3856 None => return Ok(None),
3857 }
3858 }
3859 }
3860
3861 /// One page of a board-scoped issue search, and where the next page resumes.
3862 async fn search_page(
3863 &self,
3864 search: &str,
3865 first: u32,
3866 after: Option<&str>,
3867 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3868 let data = self
3869 .graphql(
3870 graphql::SEARCH_ISSUES,
3871 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3872 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3873 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3874 )
3875 .await?;
3876 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3877 message: "GitHub search response has no search connection".into(),
3878 })?;
3879 let mut found = Vec::new();
3880 for node in connection
3881 .get("nodes")
3882 .and_then(Value::as_array)
3883 .ok_or_else(|| SourceError::Malformed {
3884 message: "GitHub search nodes is not an array".into(),
3885 })?
3886 {
3887 if let Some(resolved) = self.resolve_issue(node).await? {
3888 found.push(resolved);
3889 }
3890 }
3891 let info = connection
3892 .get("pageInfo")
3893 .ok_or_else(|| SourceError::Malformed {
3894 message: "GitHub search connection has no pageInfo".into(),
3895 })?;
3896 let next = required_bool(info, "hasNextPage")?
3897 .then(|| required_str(info, "endCursor"))
3898 .transpose()?
3899 .map(str::to_owned);
3900 if let Some(next) = &next {
3901 validate_cursor_progress(after, next)?;
3902 }
3903 Ok((found, next))
3904 }
3905
3906 /// Every issue this board holds, completed with what this run wrote.
3907 ///
3908 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3909 /// is an index and is eventually consistent, so an issue this run created seconds ago
3910 /// can be absent from it, and a project listed straight after being written would
3911 /// otherwise be missing from its own board. What is added back is only what this
3912 /// process itself wrote, out of [`Self::created`], which lives and dies with the
3913 /// process.
3914 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3915 let found = self.searched_issues().await?;
3916 self.completed_with_written(found, |_| true)
3917 }
3918
3919 /// Every issue this board's own search reports, walked to exhaustion, read once per
3920 /// source.
3921 ///
3922 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3923 /// needs it too and the two would otherwise walk the same search twice in one command.
3924 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3925 /// is.
3926 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3927 let cached = self.search_cache()?.clone();
3928 if let Some(held) = cached {
3929 return Ok(held);
3930 }
3931 let mut after: Option<String> = None;
3932 let mut found = Vec::new();
3933 let search = self.board_search(None);
3934 loop {
3935 let (page, next) = self
3936 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3937 .await?;
3938 found.extend(page);
3939 match next {
3940 Some(next) => after = Some(next),
3941 None => break,
3942 }
3943 }
3944 *self.search_cache()? = Some(found.clone());
3945 Ok(found)
3946 }
3947
3948 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3949 fn search_cache(
3950 &self,
3951 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3952 self.search_cache
3953 .lock()
3954 .map_err(|_| SourceError::Unavailable {
3955 message: "this source's view of the board's issues was left inconsistent by an \
3956 earlier failure; next: run the command again"
3957 .into(),
3958 })
3959 }
3960
3961 /// `found`, with everything this run wrote that `keep` accepts and the read did not
3962 /// report.
3963 ///
3964 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3965 /// at all: the search index is behind, and a node read of an item filed moments ago can
3966 /// be too.
3967 fn completed_with_written(
3968 &self,
3969 mut found: Vec<Resolved>,
3970 keep: impl Fn(&Resolved) -> bool,
3971 ) -> Result<Vec<Resolved>, SourceError> {
3972 for own in self.created()?.iter().filter(|own| keep(own)) {
3973 if !found.iter().any(|item| item.id == own.id) {
3974 found.push(own.clone());
3975 }
3976 }
3977 Ok(found)
3978 }
3979
3980 /// What resolving one node id reached.
3981 ///
3982 /// Three answers rather than an `Option`, because a board *draft* is none of the other
3983 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3984 /// is completed by a read of the draft itself rather than reported as nothing.
3985 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3986 let asked = self
3987 .graphql(
3988 graphql::ISSUE,
3989 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
3990 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3991 )
3992 .await;
3993 let data = match asked {
3994 Ok(data) => data,
3995 // A string that is not a node id at all is not a failure to report: it is an id
3996 // this board does not hold, which is what every read of one already answers.
3997 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3998 Err(error) => return Err(error),
3999 };
4000 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4001 return Ok(Reached::Nothing);
4002 };
4003 if optional_str(node, "__typename")? == Some("DraftIssue") {
4004 return Ok(Reached::Draft);
4005 }
4006 Ok(match self.resolve_issue(node).await? {
4007 Some(item) => Reached::Held(Box::new(item)),
4008 None => Reached::Nothing,
4009 })
4010 }
4011
4012 /// One item of this board by its own id, whatever kind it is.
4013 ///
4014 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4015 /// run wrote is read first, because a node read of an item created moments ago can
4016 /// still be behind the board field values written onto it — see [`Self::created`].
4017 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4018 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4019 return Ok(Some(own.clone()));
4020 }
4021 match self.reach(id).await? {
4022 Reached::Held(item) => Ok(Some(*item)),
4023 Reached::Nothing => Ok(None),
4024 Reached::Draft => self.draft_by_id(id).await,
4025 }
4026 }
4027
4028 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4029 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4030 /// than one request per id.
4031 ///
4032 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4033 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4034 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4035 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4036 /// is completed by a read of the draft, exactly as there.
4037 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4038 let mut found: Vec<Option<Option<Resolved>>> = {
4039 let created = self.created()?;
4040 ids.iter()
4041 .map(|id| {
4042 created
4043 .iter()
4044 .find(|own| own.id == *id)
4045 .map(|own| Some(own.clone()))
4046 })
4047 .collect()
4048 };
4049 let unread: Vec<NativeId> = ids
4050 .iter()
4051 .zip(&found)
4052 .filter(|(_, found)| found.is_none())
4053 .map(|(id, _)| id.clone())
4054 .collect();
4055 let mut read = Vec::with_capacity(unread.len());
4056 if let [one] = unread.as_slice() {
4057 read.push(self.item_by_id(one).await?);
4058 } else {
4059 for batch in unread.chunks(DETAIL_BATCH) {
4060 let data = match self
4061 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4062 .await
4063 {
4064 Ok(data) => data,
4065 Err(error) if unresolvable_node(&error) => {
4066 for id in batch {
4067 read.push(self.item_by_id(id).await?);
4068 }
4069 continue;
4070 }
4071 Err(error) => return Err(error),
4072 };
4073 for (slot, id) in batch.iter().enumerate() {
4074 let node =
4075 data.get(format!("i{slot}"))
4076 .ok_or_else(|| SourceError::Malformed {
4077 message: format!(
4078 "GitHub answered a batch read with no item for {}",
4079 id.0
4080 ),
4081 })?;
4082 read.push(if node.is_null() {
4083 None
4084 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4085 self.draft_by_id(id).await?
4086 } else {
4087 if optional_str(node, "__typename")? == Some("Issue")
4088 && required_str(node, "id")? != id.0
4089 {
4090 return Err(SourceError::Malformed {
4091 message: format!(
4092 "GitHub answered the read of {} with issue {}",
4093 id.0,
4094 required_str(node, "id")?
4095 ),
4096 });
4097 }
4098 self.resolve_issue(node).await?
4099 });
4100 }
4101 }
4102 }
4103 let mut read = read.into_iter();
4104 Ok(found
4105 .iter_mut()
4106 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4107 .collect())
4108 }
4109
4110 fn resolved_cache(
4111 &self,
4112 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4113 self.resolved_cache
4114 .lock()
4115 .map_err(|_| SourceError::Unavailable {
4116 message: "resolved item records were left inconsistent; run the command again"
4117 .into(),
4118 })
4119 }
4120
4121 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4122 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4123 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4124 let cached = self.resolved_cache()?.get(id).cloned();
4125 match cached {
4126 Some(item) => Ok(Some(item)),
4127 None => self.item_by_id(id).await,
4128 }
4129 }
4130
4131 /// One board draft by its own id, with the board item it sits in — or `None` when no
4132 /// item of this board is that draft's.
4133 ///
4134 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4135 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4136 /// links a draft to one board item, so the page this read carries is the whole of that
4137 /// connection, and a page that reports more than it holds is refused rather than read
4138 /// as an answer about memberships nobody read.
4139 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4140 let data = self
4141 .graphql(
4142 graphql::DRAFT,
4143 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4144 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4145 )
4146 .await?;
4147 // Gone between the two reads is an answer — the draft is no longer there. Anything
4148 // else than the draft [`Self::reach`] was just told this id is, is not one.
4149 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4150 return Ok(None);
4151 };
4152 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4153 return Err(SourceError::Malformed {
4154 message: format!(
4155 "GitHub answered {} as a draft and then as something else",
4156 id.0
4157 ),
4158 });
4159 }
4160 if required_str(draft, "id")? != id.0 {
4161 return Err(SourceError::Malformed {
4162 message: format!("GitHub answered a different draft for {}", id.0),
4163 });
4164 }
4165 let memberships = draft
4166 .get("projectV2Items")
4167 .ok_or_else(|| SourceError::Malformed {
4168 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4169 })?;
4170 let nodes = memberships
4171 .get("nodes")
4172 .and_then(Value::as_array)
4173 .ok_or_else(|| SourceError::Malformed {
4174 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4175 })?;
4176 let info = memberships
4177 .get("pageInfo")
4178 .ok_or_else(|| SourceError::Malformed {
4179 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4180 })?;
4181 // Read whether or not this board's entry is on the page: a page claiming more than
4182 // the one item GitHub links a draft to is a malformed answer either way.
4183 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4184 return Err(SourceError::Malformed {
4185 message: format!(
4186 "GitHub draft {} reports more board items than the one GitHub links a draft \
4187 to",
4188 id.0
4189 ),
4190 });
4191 }
4192 if let Some(node) = nodes.first()
4193 && node
4194 .pointer("/project/number")
4195 .and_then(Value::as_u64)
4196 .is_none()
4197 {
4198 return Err(SourceError::Malformed {
4199 message: format!(
4200 "GitHub draft {} board item has no numeric project number",
4201 id.0
4202 ),
4203 });
4204 }
4205 let Some(held) = self.board_entry(nodes) else {
4206 return Ok(None);
4207 };
4208 if required_str(
4209 held.get("project").ok_or_else(|| SourceError::Malformed {
4210 message: format!("GitHub draft {} board item has no project", id.0),
4211 })?,
4212 "id",
4213 )? != self.board_fields().await?.id.as_str()
4214 {
4215 return Ok(None);
4216 }
4217 let item = json!({
4218 "id": required_str(held, "id")?,
4219 "project": held.get("project"),
4220 "fieldValues": held.get("fieldValues"),
4221 "content": draft,
4222 });
4223 self.resolve(&item)
4224 }
4225
4226 /// The board's own id and field definitions, for a write whose item does not carry
4227 /// them — never its items.
4228 ///
4229 /// A board this command has already listed supplies them, since it read them beside its
4230 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4231 /// is consulted about which items the board holds: see the module documentation for
4232 /// why a question about one known item is answered by reading that item.
4233 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4234 if let Some(board) = self.board_cache()?.as_ref() {
4235 return Ok(BoardFields {
4236 id: BoardId::parse(&board.id)?,
4237 fields: board.fields.clone(),
4238 });
4239 }
4240 if let Some(held) = self.fields_cache()?.clone() {
4241 return Ok(held);
4242 }
4243 let data = self
4244 .graphql(
4245 graphql::BOARD_FIELDS,
4246 json!({"owner":self.owner,"number":self.project_number,
4247 "nestedFirst":NESTED_PAGE_SIZE}),
4248 )
4249 .await?;
4250 self.fields_read(&data)
4251 }
4252
4253 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4254 /// the rest of this command.
4255 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4256 let board = data
4257 .pointer("/boardFields/projectV2")
4258 .filter(|value| !value.is_null())
4259 .ok_or_else(|| SourceError::Refused {
4260 message: format!(
4261 "GitHub project {}/{} was not found or is not visible to the token",
4262 self.owner, self.project_number
4263 ),
4264 })?;
4265 let read = BoardFields {
4266 id: BoardId::parse(required_str(board, "id")?)?,
4267 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4268 };
4269 *self.fields_cache()? = Some(read.clone());
4270 Ok(read)
4271 }
4272
4273 /// Read what creating an issue in `repository` needs and this command has not read yet —
4274 /// the board's fields and the repository's node id — in one request when it needs both.
4275 ///
4276 /// When either is already known this sends nothing, and the other is read by its own
4277 /// document where it is asked for, so no create reads anything twice.
4278 async fn creation_context(
4279 &self,
4280 repository: &RepositoryTarget,
4281 incoming: &Incoming<'_>,
4282 ) -> Result<(), SourceError> {
4283 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4284 if fields_known || self.repository_cache()?.contains_key(repository) {
4285 return Ok(());
4286 }
4287 let data = self
4288 .graphql(
4289 graphql::CREATION_CONTEXT,
4290 json!({"owner":self.owner,"number":self.project_number,
4291 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4292 "repositoryName":repository.name}),
4293 )
4294 .await?;
4295 self.fields_read(&data)?;
4296 self.repository_read(&data, repository, incoming)?;
4297 Ok(())
4298 }
4299
4300 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4301 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4302 self.fields_cache
4303 .lock()
4304 .map_err(|_| SourceError::Unavailable {
4305 message: "this source's view of the board's fields was left inconsistent by an \
4306 earlier failure; next: run the command again"
4307 .into(),
4308 })
4309 }
4310
4311 /// What a write to `item` needs of the board, read off that item when it says enough and
4312 /// off [`Self::board_fields`] when it does not.
4313 ///
4314 /// A node read of an item names its board and carries the definition of every field it
4315 /// holds a value of — so an item naming its board, holding a value of the origin field,
4316 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4317 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4318 /// of may still be on the board, and a view reading it as absent would refuse a write the
4319 /// board can take or skip a field write the board needs, so such an item — and a create,
4320 /// which has no item yet — takes the board's fields from their own read instead.
4321 async fn fields_for(
4322 &self,
4323 item: Option<&Resolved>,
4324 writes_status: bool,
4325 selects_priority: bool,
4326 ) -> Result<BoardFields, SourceError> {
4327 if let Some(board) = item.and_then(Resolved::carried_board) {
4328 return Ok(board);
4329 }
4330 if let Some(item) = item
4331 && let Some(board_id) = item.named_board()
4332 && item.defines(ORIGIN_FIELD)
4333 && (!writes_status || item.defines("Status"))
4334 && (!selects_priority || item.defines(PRIORITY_FIELD))
4335 {
4336 return Ok(BoardFields {
4337 id: board_id,
4338 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4339 });
4340 }
4341 self.board_fields().await
4342 }
4343
4344 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4345 /// when that id names nothing here with a sub-issue relationship to walk.
4346 ///
4347 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4348 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4349 /// empty vector is a project that holds nothing.
4350 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4351 let mut after: Option<String> = None;
4352 let mut children = Vec::new();
4353 loop {
4354 let asked = self
4355 .graphql(
4356 graphql::SUB_ISSUES,
4357 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4358 "nestedFirst":NESTED_PAGE_SIZE,
4359 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4360 )
4361 .await;
4362 let data = match asked {
4363 Ok(data) => data,
4364 // A string that is not a node id at all is not a failure to report: it is
4365 // the ordinary answer to a selector naming a project by its name.
4366 Err(error) if unresolvable_node(&error) => return Ok(None),
4367 Err(error) => return Err(error),
4368 };
4369 let Some(connection) = data
4370 .pointer("/node/subIssues")
4371 .filter(|value| !value.is_null())
4372 else {
4373 // No such node, or one with no sub-issue relationship — a board draft is
4374 // the one this board can really hold.
4375 return Ok(None);
4376 };
4377 for node in connection
4378 .get("nodes")
4379 .and_then(Value::as_array)
4380 .ok_or_else(|| SourceError::Malformed {
4381 message: "GitHub subIssues.nodes is not an array".into(),
4382 })?
4383 {
4384 if let Some(resolved) = self.resolve_issue(node).await? {
4385 children.push(resolved);
4386 }
4387 }
4388 let info = connection
4389 .get("pageInfo")
4390 .ok_or_else(|| SourceError::Malformed {
4391 message: "GitHub subIssues connection has no pageInfo".into(),
4392 })?;
4393 let next = required_bool(info, "hasNextPage")?
4394 .then(|| required_str(info, "endCursor"))
4395 .transpose()?;
4396 match next {
4397 Some(next) => {
4398 validate_cursor_progress(after.as_deref(), next)?;
4399 after = Some(next.to_owned());
4400 }
4401 None => return Ok(Some(children)),
4402 }
4403 }
4404 }
4405
4406 /// Which issue of this board a project *name* is, or `None` when none is.
4407 ///
4408 /// One bounded query which filters on that name at the server, rather than a walk of
4409 /// every issue the board holds. The name is compared again here: the qualifier narrows
4410 /// what GitHub sends, and this source decides what it names.
4411 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4412 let search = self.board_search(Some(&title_qualifier(name)));
4413 let mut after = None;
4414 loop {
4415 let (candidates, next) = self
4416 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4417 .await?;
4418 if let Some(item) = candidates.into_iter().find(|item| {
4419 item.kind == BoardKind::Work(ItemKind::Project)
4420 && item.title.eq_ignore_ascii_case(name)
4421 }) {
4422 return Ok(Some(item.id));
4423 }
4424 match next {
4425 Some(next) => after = Some(next),
4426 None => return Ok(None),
4427 }
4428 }
4429 }
4430
4431 /// Everything filed under one project of this board: the sub-issues of the issue that
4432 /// project is.
4433 ///
4434 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4435 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4436 /// gains projects, or as another project gains tasks.
4437 ///
4438 /// A qualified id names the issue and is asked for its sub-issues directly: one
4439 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4440 /// read as a project *name*, which costs the one bounded search
4441 /// [`Self::project_by_name`] makes.
4442 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4443 let (project, children) = match self.sub_issues(selector).await? {
4444 Some(children) => (selector.clone(), children),
4445 None => match self.project_by_name(&selector.0).await? {
4446 Some(project) => {
4447 let children = self.sub_issues(&project).await?.unwrap_or_default();
4448 (project, children)
4449 }
4450 None => return Ok(Vec::new()),
4451 },
4452 };
4453 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4454 }
4455
4456 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4457 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4458 ///
4459 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4460 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4461 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4462 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4463 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4464 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4465 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4466 /// rather than silently narrowing a caller's answer.
4467 ///
4468 /// The instant is written to the second, rounded down, which can only widen what the
4469 /// search returns; confirmation against each candidate's own comments is what makes the
4470 /// answer exact. The search is an index that lags a write by a second or two — the module
4471 /// documentation records it — so a caller that asks again from its last instant should
4472 /// overlap the two by more than that.
4473 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4474 let found = self.searched(&updated_qualifier(since)).await?;
4475 self.completed_with_written(found, |_| true)
4476 }
4477
4478 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4479 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4480 ///
4481 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4482 /// own record is the fresher of the two.
4483 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4484 let search = self.board_search(Some(also));
4485 let mut after: Option<String> = None;
4486 let mut found = Vec::new();
4487 loop {
4488 let (page, next) = self
4489 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4490 .await?;
4491 found.extend(page);
4492 match next {
4493 Some(next) => after = Some(next),
4494 None => return Ok(found),
4495 }
4496 }
4497 }
4498
4499 /// A bounded task answer; the versioned cursor carries the connection position, how
4500 /// many rows of the page starting there were already handed out, and the own-write ids
4501 /// already observed, including across a new source instance.
4502 ///
4503 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4504 /// sliced from the pages it needs; why is the module documentation's paging contract.
4505 async fn search_tasks(
4506 &self,
4507 query: &TaskQuery,
4508 page: &PageRequest,
4509 also: &str,
4510 ) -> Result<Page<Task>, SourceError> {
4511 let mut position = match &page.cursor {
4512 None => SearchPosition::default(),
4513 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4514 .ok()
4515 .filter(|position| {
4516 position.version == SEARCH_CURSOR_VERSION
4517 && position.connection.valid_resume(position.offset)
4518 })
4519 .ok_or_else(|| SourceError::Config {
4520 message: "page cursor is invalid".into(),
4521 })?,
4522 };
4523 let search = self.board_search(Some(also));
4524 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4525 let own = self.with_own_writes(Vec::new())?;
4526 for item in &own {
4527 if !position.own.contains(&item.id) {
4528 position.own.push(item.id.clone());
4529 }
4530 }
4531 let mut tasks = Vec::new();
4532 while !position.connection.exhausted() && tasks.len() < limit {
4533 let first = SEARCH_PAGE_SIZE;
4534 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4535 let key =
4536 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4537 .expect("search page key is serializable");
4538 let cached = if query.commented_since.is_none() {
4539 self.narrowed_cache()?.get(&key).cloned()
4540 } else {
4541 None
4542 };
4543 let (found, next) = match cached {
4544 Some(found) => {
4545 let next = self
4546 .search_next
4547 .lock()
4548 .map_err(|_| SourceError::Unavailable {
4549 message:
4550 "search pagination was left inconsistent; run the command again"
4551 .into(),
4552 })?
4553 .get(&key)
4554 .cloned()
4555 .flatten();
4556 (found, next)
4557 }
4558 None => {
4559 let (found, next) = self
4560 .search_page(&search, first, position.connection.after())
4561 .await?;
4562 if query.commented_since.is_none() {
4563 self.search_next
4564 .lock()
4565 .map_err(|_| SourceError::Unavailable {
4566 message:
4567 "search pagination was left inconsistent; run the command again"
4568 .into(),
4569 })?
4570 .insert(key.clone(), next.clone());
4571 self.narrowed_cache()?.insert(key, found.clone());
4572 }
4573 (found, next)
4574 }
4575 };
4576 let rows = found.len();
4577 for mut item in found.into_iter().skip(position.offset) {
4578 if tasks.len() == limit {
4579 break;
4580 }
4581 position.offset += 1;
4582 if position.own.contains(&item.id) {
4583 if position.seen.contains(&item.id) {
4584 continue;
4585 }
4586 position.seen.push(item.id.clone());
4587 let updated_at = item.updated_at;
4588 let Some(written) = self.search_written(&own, &item.id).await? else {
4589 continue;
4590 };
4591 item = written;
4592 item.updated_at = item.updated_at.max(updated_at);
4593 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4594 }
4595 if item.kind == BoardKind::Work(ItemKind::Task) {
4596 let task = item.task()?;
4597 if task_matches(&task, query, &query.project)
4598 && self.commented_since(&item, query.commented_since).await?
4599 {
4600 tasks.push(task);
4601 }
4602 }
4603 }
4604 if position.offset < rows {
4605 continue;
4606 }
4607 position.offset = 0;
4608 position.connection = match next {
4609 Some(after) => SearchConnection::Continuing {
4610 after: Cursor(after),
4611 },
4612 None => SearchConnection::Exhausted {},
4613 };
4614 }
4615 if position.connection.exhausted() {
4616 for id in position.own.clone() {
4617 if position.seen.contains(&id) {
4618 continue;
4619 }
4620 if tasks.len() == limit {
4621 break;
4622 }
4623 position.seen.push(id.clone());
4624 let Some(item) = self.search_written(&own, &id).await? else {
4625 continue;
4626 };
4627 if item.kind == BoardKind::Work(ItemKind::Task) {
4628 let task = item.task()?;
4629 if task_matches(&task, query, &query.project)
4630 && self.commented_since(&item, query.commented_since).await?
4631 {
4632 tasks.push(task);
4633 }
4634 }
4635 }
4636 }
4637 let more = !position.connection.exhausted()
4638 || position.own.iter().any(|id| !position.seen.contains(id));
4639 Ok(Page {
4640 items: tasks,
4641 next: more.then(|| {
4642 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4643 }),
4644 })
4645 }
4646
4647 /// A resumed process has the ids but no write records; resolve only a record the
4648 /// current page needs, by its uncached node read rather than the lagging search index.
4649 async fn search_written(
4650 &self,
4651 own: &[Resolved],
4652 id: &NativeId,
4653 ) -> Result<Option<Resolved>, SourceError> {
4654 match own.iter().find(|item| item.id == *id) {
4655 Some(item) => Ok(Some(item.clone())),
4656 None => self.item_by_id(id).await,
4657 }
4658 }
4659
4660 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4661 /// without enumerating the board — or `None` for a query carrying none of the three, which
4662 /// keeps the reads it always had.
4663 ///
4664 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4665 /// because it names at most a handful of items. Text and metadata are answered by one
4666 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4667 /// further by `updated:>=` when the query also asks for comment activity, since both
4668 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4669 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4670 ///
4671 /// Completed with what this process wrote, its own record winning over the index's copy
4672 /// of the same item: see [`Self::with_own_writes`].
4673 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4674 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4675 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4676 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4677 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4678 None => qualifiers,
4679 }),
4680 (None, None) => return Ok(None),
4681 };
4682 // A question about comment activity is asked afresh every time, as it always was: it
4683 // is the one a caller polls from one source while waiting for the index, and an
4684 // answer held from the first poll would be the answer to every later one.
4685 let key = query.commented_since.is_none().then(|| asked.key());
4686 let cached = match &key {
4687 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4688 None => None,
4689 };
4690 let found = match cached {
4691 Some(found) => found,
4692 None => {
4693 let found = match &asked {
4694 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4695 Narrowing::Search(also) => self.searched(also).await?,
4696 };
4697 if let Some(key) = key {
4698 self.narrowed_cache()?.insert(key, found.clone());
4699 }
4700 found
4701 }
4702 };
4703 self.with_own_writes(found).map(Some)
4704 }
4705
4706 /// The candidates for a project or unscoped document query carrying a searchable text,
4707 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4708 /// which keeps the read it always had.
4709 ///
4710 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4711 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4712 /// costs is the issues that match and never the board. Its answer is held for the command
4713 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4714 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4715 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4716 /// process wrote: see [`Self::with_own_writes`].
4717 async fn text_searched(
4718 &self,
4719 text: Option<&TextQuery>,
4720 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4721 let Some(also) = text_qualifiers(text) else {
4722 return Ok(None);
4723 };
4724 let key = Narrowing::Search(also.clone()).key();
4725 let cached = self.narrowed_cache()?.get(&key).cloned();
4726 let found = match cached {
4727 Some(found) => found,
4728 None => {
4729 let found = self.searched(&also).await?;
4730 self.narrowed_cache()?.insert(key, found.clone());
4731 found
4732 }
4733 };
4734 self.with_own_writes(found).map(Some)
4735 }
4736
4737 /// Every item of this board that may carry `origin` — a superset of those that do — found
4738 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4739 ///
4740 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4741 /// which reads the field every carrier holds, whichever release wrote it — and the
4742 /// board-scoped issue search for the same id as a phrase in the body, where this source
4743 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4744 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4745 /// query's, exactly.
4746 ///
4747 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4748 /// already ended is sent its last cursor again, which answers an empty page, so the one
4749 /// document serves every page of either. What the two leave is stated in the module
4750 /// documentation: a carrier another process added within the last second or two, before
4751 /// either index has it.
4752 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4753 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4754 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4755 let mut items_after: Option<String> = None;
4756 let mut search_after: Option<String> = None;
4757 let mut found: Vec<Resolved> = Vec::new();
4758 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4759 if !found.iter().any(|held| held.id == resolved.id) {
4760 found.push(resolved);
4761 }
4762 };
4763 loop {
4764 let data = self
4765 .graphql(
4766 graphql::ORIGIN_LOOKUP,
4767 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4768 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4769 "itemsAfter":items_after,"searchAfter":search_after,
4770 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4771 "duplicates":true}),
4772 )
4773 .await?;
4774 let items = data
4775 .pointer("/originItems/projectV2/items")
4776 .filter(|value| !value.is_null())
4777 .ok_or_else(|| SourceError::Refused {
4778 message: format!(
4779 "GitHub project {}/{} was not found or is not visible to the token",
4780 self.owner, self.project_number
4781 ),
4782 })?;
4783 for item in optional_nodes(Some(items), "project items")?
4784 .into_iter()
4785 .flatten()
4786 {
4787 // The board's own items list its drafts too, and a draft is not an issue: no
4788 // narrowed read answers with one, whatever its origin field holds.
4789 if let Some(resolved) = self.resolve(item)?
4790 && resolved.content_kind == ContentKind::Issue
4791 {
4792 keep(resolved, &mut found);
4793 }
4794 }
4795 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4796 message: "GitHub search response has no search connection".into(),
4797 })?;
4798 for node in optional_nodes(Some(searched), "search")?
4799 .into_iter()
4800 .flatten()
4801 {
4802 if let Some(resolved) = self.resolve_issue(node).await? {
4803 keep(resolved, &mut found);
4804 }
4805 }
4806 let items_next = resumed(items, items_after.as_deref())?;
4807 let search_next = resumed(searched, search_after.as_deref())?;
4808 if !items_next.has_more() && !search_next.has_more() {
4809 return Ok(found);
4810 }
4811 items_after = items_next.cursor();
4812 search_after = search_next.cursor();
4813 }
4814 }
4815
4816 /// `found`, with every item this process created or wrote in its place, and every one of
4817 /// them the read did not report added.
4818 ///
4819 /// This process's own record wins over the read's copy of the same item, because a read
4820 /// of an item written moments ago can still be behind what was written onto it — the
4821 /// origin field included, which is the one a narrowed read is confirmed against — and a
4822 /// read that still names an item under a predicate this process's write moved it out of
4823 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4824 /// last saw the item change, which is what a comment-activity read rules a candidate out
4825 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4826 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4827 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4828 // A board draft is not an issue, so no narrowed read returns one, and this process
4829 // having written one does not make it an answer either.
4830 let own: Vec<Resolved> = self
4831 .created()?
4832 .iter()
4833 .chain(self.updated()?.iter())
4834 .filter(|own| own.content_kind == ContentKind::Issue)
4835 .cloned()
4836 .collect();
4837 for mut own in own {
4838 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4839 match found.iter_mut().find(|read| read.id == own.id) {
4840 Some(read) => {
4841 own.updated_at = own.updated_at.max(read.updated_at);
4842 *read = own;
4843 }
4844 None => found.push(own),
4845 }
4846 }
4847 Ok(found)
4848 }
4849
4850 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4851 /// there is no instant to hold it to.
4852 ///
4853 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4854 /// or after the instant moved it there: an issue not updated since holds no such comment,
4855 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4856 /// only as far as the first that matches. A board draft is not an issue and has no
4857 /// comments, so it never matches.
4858 async fn commented_since(
4859 &self,
4860 item: &Resolved,
4861 since: Option<DateTime<Utc>>,
4862 ) -> Result<bool, SourceError> {
4863 let Some(since) = since else {
4864 return Ok(true);
4865 };
4866 if item.content_kind == ContentKind::DraftIssue
4867 || item.updated_at.is_some_and(|updated| updated < since)
4868 {
4869 return Ok(false);
4870 }
4871 let query = TaskQuery {
4872 commented_since: Some(since),
4873 ..TaskQuery::default()
4874 };
4875 let mut after: Option<String> = None;
4876 loop {
4877 let data = self
4878 .graphql(
4879 graphql::ISSUE_COMMENTS,
4880 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4881 )
4882 .await?;
4883 let Some(connection) = data
4884 .get("node")
4885 .filter(|value| !value.is_null())
4886 .and_then(|node| node.get("comments"))
4887 .filter(|value| !value.is_null())
4888 else {
4889 // Removed since the search reported it: no longer an issue with comments.
4890 return Ok(false);
4891 };
4892 let comments = optional_nodes(Some(connection), "issue comments")?
4893 .into_iter()
4894 .flatten()
4895 .map(comment_from)
4896 .collect::<Result<Vec<_>, _>>()?;
4897 if query.comments_match(&comments) {
4898 return Ok(true);
4899 }
4900 match next_cursor(connection)? {
4901 Some(next) => {
4902 validate_cursor_progress(after.as_deref(), &next.0)?;
4903 after = Some(next.0);
4904 }
4905 None => return Ok(false),
4906 }
4907 }
4908 }
4909
4910 /// Every item on the board: the union of both enumerations GitHub offers of one.
4911 ///
4912 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4913 /// board **draft** and reads the board's own fields beside its items, and only the search
4914 /// reports an item that connection is behind on. The module documentation is where the lag and the
4915 /// measurements behind it are written down.
4916 ///
4917 /// A search result is admitted on the same terms as any other issue this source reaches
4918 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4919 /// names *this* board — so an issue the index still believes is here after it was taken
4920 /// off is refused rather than reported.
4921 ///
4922 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4923 /// which is what the cache could otherwise have broken.
4924 async fn board(&self) -> Result<Board, SourceError> {
4925 let cached = self.board_cache()?.clone();
4926 let mut board = match cached {
4927 Some(board) => board,
4928 None => {
4929 let read = self.read_board().await?;
4930 *self.board_cache()? = Some(read.clone());
4931 read
4932 }
4933 };
4934 for held in self.searched_issues().await? {
4935 if !board.items.iter().any(|item| item.id == held.id) {
4936 board.items.push(held);
4937 }
4938 }
4939 for own in self.created()?.iter() {
4940 if !board.items.iter().any(|item| item.id == own.id) {
4941 board.items.push(own.clone());
4942 }
4943 }
4944 Ok(board)
4945 }
4946
4947 /// This process's own view of the board, or the refusal a poisoned lock is.
4948 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4949 self.board_cache
4950 .lock()
4951 .map_err(|_| SourceError::Unavailable {
4952 message: "this source's view of the board was left inconsistent by an earlier \
4953 failure; next: run the command again"
4954 .into(),
4955 })
4956 }
4957
4958 /// Bring this process's own view of the board up to an item it has just written.
4959 ///
4960 /// A created item goes to `created`, which is what completes a board read GitHub's own
4961 /// eventual consistency has left behind. An item that was already there is replaced
4962 /// where it sits, so a second write of it in the same command reads its real parent
4963 /// rather than the one it had before the first write.
4964 ///
4965 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4966 /// that wins: an item this same run created is held in `created` and not in the cached
4967 /// board, and `board` completes the cached board *from* `created`, so replacing only
4968 /// the cached copy of such an item replaces nothing and the read still reports the
4969 /// title it was created with. The search is the third, and it is the one an item the
4970 /// board's own projection is behind on sits in *alone* — which is exactly the item this
4971 /// source is least able to re-read, so leaving it out would put the stale title back on
4972 /// the only items the completion in [`Self::board`] exists for.
4973 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4974 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4975 if created {
4976 self.created()?.push(item);
4977 return Ok(());
4978 }
4979 {
4980 let mut own = self.created()?;
4981 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4982 *held = item;
4983 return Ok(());
4984 }
4985 }
4986 {
4987 let mut own = self.updated()?;
4988 match own.iter_mut().find(|held| held.id == item.id) {
4989 Some(held) => *held = item.clone(),
4990 None => own.push(item.clone()),
4991 }
4992 }
4993 if let Some(board) = self.board_cache()?.as_mut()
4994 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4995 {
4996 *held = item.clone();
4997 }
4998 if let Some(found) = self.search_cache()?.as_mut()
4999 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5000 {
5001 *held = item.clone();
5002 }
5003 for found in self.narrowed_cache()?.values_mut() {
5004 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5005 *held = item.clone();
5006 }
5007 }
5008 Ok(())
5009 }
5010
5011 /// Forget one item this process has just deleted, from every half of its own view.
5012 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5013 self.resolved_cache()?.remove(id);
5014 self.created()?.retain(|own| own.id != *id);
5015 self.updated()?.retain(|own| own.id != *id);
5016 if let Some(board) = self.board_cache()?.as_mut() {
5017 board.items.retain(|item| item.id != *id);
5018 }
5019 if let Some(found) = self.search_cache()?.as_mut() {
5020 found.retain(|item| item.id != *id);
5021 }
5022 for found in self.narrowed_cache()?.values_mut() {
5023 found.retain(|item| item.id != *id);
5024 }
5025 Ok(())
5026 }
5027
5028 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5029 fn narrowed_cache(
5030 &self,
5031 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5032 self.narrowed_cache
5033 .lock()
5034 .map_err(|_| SourceError::Unavailable {
5035 message: "this source's view of a narrowed read was left inconsistent by an \
5036 earlier failure; next: run the command again"
5037 .into(),
5038 })
5039 }
5040
5041 /// Every page of the board, read from GitHub.
5042 async fn read_board(&self) -> Result<Board, SourceError> {
5043 let mut after: Option<String> = None;
5044 let mut items = Vec::new();
5045 let mut board;
5046 loop {
5047 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5048 for item in page
5049 .pointer("/items/nodes")
5050 .and_then(Value::as_array)
5051 .ok_or_else(|| SourceError::Malformed {
5052 message: "GitHub project items.nodes is not an array".into(),
5053 })?
5054 {
5055 if let Some(resolved) = self.resolve(item)? {
5056 items.push(resolved);
5057 }
5058 }
5059 let info = page
5060 .pointer("/items/pageInfo")
5061 .ok_or_else(|| SourceError::Malformed {
5062 message: "GitHub project items have no pageInfo".into(),
5063 })?;
5064 let has_next = required_bool(info, "hasNextPage")?;
5065 let next = has_next
5066 .then(|| required_str(info, "endCursor"))
5067 .transpose()?;
5068 board = page.clone();
5069 match next {
5070 Some(next) => {
5071 validate_cursor_progress(after.as_deref(), next)?;
5072 after = Some(next.to_owned());
5073 }
5074 None => break,
5075 }
5076 }
5077 Ok(Board {
5078 id: required_str(&board, "id")?.to_owned(),
5079 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5080 items,
5081 })
5082 }
5083
5084 /// The existing items this source has written, for completing a narrowed read that is
5085 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5086 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5087 self.updated.lock().map_err(|_| SourceError::Unavailable {
5088 message: "this source's record of what it wrote in this run was left inconsistent \
5089 by an earlier failure; next: run the command again"
5090 .into(),
5091 })
5092 }
5093
5094 /// The items this source has created, for completing a board read that is behind.
5095 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5096 self.created.lock().map_err(|_| SourceError::Unavailable {
5097 message: "this source's record of what it created in this run was left \
5098 inconsistent by an earlier failure; next: run the command again"
5099 .into(),
5100 })
5101 }
5102
5103 /// One board item as this source reports it, or `None` for content it ignores.
5104 ///
5105 /// A pull request is neither a project nor a task — it is somebody's change, not a
5106 /// unit of plan — and an item whose content the token cannot see has nothing to
5107 /// report at all.
5108 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5109 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5110 message: "GitHub project item is missing content".into(),
5111 })?;
5112 if content.is_null() {
5113 return Ok(None);
5114 }
5115 let content_kind = match required_str(content, "__typename")? {
5116 "Issue" => ContentKind::Issue,
5117 "DraftIssue" => ContentKind::DraftIssue,
5118 _ => return Ok(None),
5119 };
5120 let field_values = item
5121 .get("fieldValues")
5122 .ok_or_else(|| SourceError::Malformed {
5123 message: "GitHub project item is missing fieldValues".into(),
5124 })?;
5125 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5126 let nodes = field_values
5127 .get("nodes")
5128 .and_then(Value::as_array)
5129 .ok_or_else(|| SourceError::Malformed {
5130 message: "GitHub project item fieldValues.nodes is not an array".into(),
5131 })?;
5132 if let Some(labels) = content.get("labels") {
5133 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5134 }
5135 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5136 let (body, slot) = metadata_body(raw_body.clone())?;
5137 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5138 .map(|id| NativeId(id.to_owned()));
5139 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5140 // to read one from; it is a task, and never a project.
5141 let sub_issues = match content_kind {
5142 ContentKind::Issue => sub_issue_total(content)?,
5143 ContentKind::DraftIssue => 0,
5144 };
5145 let content_id = required_str(content, "id")?;
5146 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5147 message: format!("GitHub issue {content_id}: {message}"),
5148 })?;
5149 let raw_title = required_str(content, "title")?;
5150 // The design prefix is read *first*, before either of the two rules that separate
5151 // a project from a task. A document is not work whatever sub-issues it has and
5152 // whatever marker it carries, and reading the prefix later would make a design
5153 // issue with none of either an empty project.
5154 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5155 BoardKind::Document
5156 } else if parent.is_some() {
5157 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5158 // under a project is that project's task even when it has sub-issues of its
5159 // own.
5160 BoardKind::Work(ItemKind::Task)
5161 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5162 BoardKind::Work(ItemKind::Project)
5163 } else {
5164 BoardKind::Work(ItemKind::Task)
5165 };
5166 // The title a person wrote, which for a document is the one without the prefix —
5167 // the same way `content` above is the body without this source's metadata slot.
5168 let title = match kind {
5169 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5170 BoardKind::Work(_) => raw_title.to_owned(),
5171 };
5172 let own_repository = content
5173 .pointer("/repository/nameWithOwner")
5174 .and_then(Value::as_str)
5175 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5176 .transpose()
5177 .map_err(|message| SourceError::Malformed { message })?;
5178 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5179 Repository::from_metadata(&slot)
5180 .map_err(|message| SourceError::Malformed { message })?
5181 } else {
5182 own_repository.clone().into_iter().collect()
5183 };
5184 let id = NativeId(content_id.to_owned());
5185 // Read only for a task, because only a task has either list: a project or a
5186 // document holding one of these keys holds nothing this source reports, and the
5187 // keys are left out of its caller-visible metadata all the same.
5188 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5189 let listed = |key: &str| {
5190 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5191 .map_err(|message| SourceError::Malformed { message })
5192 };
5193 (
5194 listed(TaskRef::DELIVERS_KEY)?,
5195 listed(TaskRef::DELIVERED_BY_KEY)?,
5196 )
5197 } else {
5198 (Vec::new(), Vec::new())
5199 };
5200 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5201 let priority = self.held_priority(nodes)?;
5202 // Present when the item was reached through its own issue, whose board entry
5203 // names the board; a read of the board's own items has the board already. An
5204 // empty id names nothing a field write could address, so it is read as absent and
5205 // the write goes back to reading the board.
5206 let board_id = item
5207 .pointer("/project/id")
5208 .and_then(Value::as_str)
5209 .filter(|id| !id.is_empty());
5210 let resolved = Resolved {
5211 item_id: required_str(item, "id")?.to_owned(),
5212 id,
5213 content_kind,
5214 kind,
5215 title,
5216 body: body.filter(|value| !value.is_empty()),
5217 raw_body,
5218 status: self.statuses.status(option, closed, reason),
5219 option: option.map(str::to_owned),
5220 priority,
5221 closed,
5222 delivers,
5223 delivered_by,
5224 labels: labels(content)?,
5225 parent,
5226 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5227 number: match content_kind {
5228 ContentKind::Issue => Some(issue_number(content)?),
5229 // A draft is filed in no repository, so nothing ever numbered it:
5230 // `DraftIssue` declares no `number` at all, exactly as it declares no
5231 // `subIssuesSummary` the branch above reads.
5232 ContentKind::DraftIssue => None,
5233 },
5234 url: optional_str(content, "url")?.map(str::to_owned),
5235 created_at: optional_time(content, "createdAt")?,
5236 updated_at: optional_time(content, "updatedAt")?,
5237 own_repository,
5238 repositories,
5239 slot,
5240 board_id: board_id.map(str::to_owned),
5241 fields: field_definitions(nodes),
5242 board_fields: Self::carried_board_fields(content, board_id)?,
5243 blocked_by: carried_blocked_by(content)?,
5244 };
5245 self.resolved_cache()?
5246 .insert(resolved.id.clone(), resolved.clone());
5247 Ok(Some(resolved))
5248 }
5249
5250 /// The field definitions of the board `board_id` names — the project this issue's own
5251 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5252 /// `None` when the read carried none, carried no entry for that board, or the board item
5253 /// named no board, which a write then answers by reading the board's fields itself.
5254 ///
5255 /// Matched by the board's node id and never by its number alone: a project number is
5256 /// unique only within its owner, so another owner's board numbered alike can sit on the
5257 /// same page, and its field and option ids address nothing on this one.
5258 fn carried_board_fields(
5259 content: &Value,
5260 board_id: Option<&str>,
5261 ) -> Result<Option<Value>, SourceError> {
5262 let (Some(nodes), Some(board_id)) = (
5263 content.pointer("/boards/nodes").and_then(Value::as_array),
5264 board_id,
5265 ) else {
5266 return Ok(None);
5267 };
5268 let Some(board) = nodes.iter().find_map(|node| {
5269 let project = node.get("project")?;
5270 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5271 }) else {
5272 return Ok(None);
5273 };
5274 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5275 return Ok(None);
5276 };
5277 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5278 Ok(Some(fields.clone()))
5279 }
5280
5281 /// What one board item's `Priority` field says, through this instance's mapping.
5282 ///
5283 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5284 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5285 /// option the mapping does not name is kept as itself — never read as a level or as
5286 /// `none` — for a read of the task to report by name.
5287 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5288 let Some(mapping) = &self.priorities else {
5289 return Ok(HeldPriority::Read(Priority::None));
5290 };
5291 // A value of the field that names no option — a text field someone called `Priority` —
5292 // is malformed rather than `none`: reading it as no priority would let the next copy
5293 // clear one a person set.
5294 let Some(option) = field_values
5295 .iter()
5296 .find(|value| {
5297 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5298 })
5299 .map(|value| required_str(value, "name"))
5300 .transpose()?
5301 else {
5302 return Ok(HeldPriority::Read(Priority::None));
5303 };
5304 Ok(mapping.priority_of(option).map_or_else(
5305 || HeldPriority::Unmapped(option.to_owned()),
5306 HeldPriority::Read,
5307 ))
5308 }
5309
5310 /// What one board item's status is read from: its `Status` option, whether its issue
5311 /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
5312 /// three into the status it reports.
5313 fn status_parts<'a>(
5314 field_values: &'a [Value],
5315 content: &'a Value,
5316 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5317 let option = field_values
5318 .iter()
5319 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5320 .map(|value| required_str(value, "name"))
5321 .transpose()?;
5322 let closed = optional_str(content, "state")? == Some("CLOSED");
5323 Ok((option, closed, optional_str(content, "stateReason")?))
5324 }
5325
5326 /// The board Status option this write selects, or the refusal that says why not.
5327 ///
5328 /// The mapped option is required for both open and terminal targets. A terminal write
5329 /// validates it before changing either representation, so it can never fall back to
5330 /// closing an issue whose board cannot display the matching status.
5331 ///
5332 /// Answers the field's id, the option's id, and the option's name as the board spells
5333 /// it — which is the name a read of the item reports once it sits there.
5334 fn column_for(
5335 &self,
5336 fields: &Value,
5337 status: &Status,
5338 target: &StatusTarget,
5339 ) -> Result<Option<(String, String, String)>, SourceError> {
5340 let wanted = match target {
5341 StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
5342 StatusTarget::Disabled => return Ok(None),
5343 };
5344 let missing = |detail: &str| SourceError::Refused {
5345 message: format!(
5346 "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",
5347 category_name(status.category),
5348 self.name,
5349 category_name(status.category)
5350 ),
5351 };
5352 let Some(field) = Board::field(fields, "Status")? else {
5353 return Err(missing("this board has no Status field"));
5354 };
5355 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5356 return Err(missing(
5357 "this board's Status field is not a single-select field",
5358 ));
5359 }
5360 let option = field
5361 .get("options")
5362 .and_then(Value::as_array)
5363 .and_then(|options| {
5364 options.iter().find(|option| {
5365 option
5366 .get("name")
5367 .and_then(Value::as_str)
5368 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5369 })
5370 });
5371 match option {
5372 None => Err(missing("this board does not have it")),
5373 Some(option) => Ok(Some((
5374 required_str(field, "id")?.to_owned(),
5375 required_str(option, "id")?.to_owned(),
5376 required_str(option, "name")?.to_owned(),
5377 ))),
5378 }
5379 }
5380
5381 /// The refusal a status that closes an issue is answered with over a board draft.
5382 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5383 SourceError::Refused {
5384 message: format!(
5385 "status {} of source {} closes the item's issue, and GitHub draft items have \
5386 no open or closed state",
5387 category_name(category),
5388 self.name
5389 ),
5390 }
5391 }
5392
5393 /// What a status write to one item needs of the board: the board's id and the
5394 /// definition of its `Status` field, read off the item when the item says both.
5395 ///
5396 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5397 /// and its `Status` value carries that field's definition, options and all. An item that
5398 /// does not say — no board id, or no `Status` value to read the field off — takes them
5399 /// from [`Self::board_fields`], which reads no item.
5400 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5401 if let Some(board) = item.carried_board() {
5402 return Ok(board);
5403 }
5404 if item.defines("Status")
5405 && let Some(board_id) = item.named_board()
5406 {
5407 return Ok(BoardFields {
5408 id: board_id,
5409 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5410 });
5411 }
5412 self.board_fields().await
5413 }
5414
5415 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5416 async fn set_status(
5417 &self,
5418 id: &NativeId,
5419 category: StatusCategory,
5420 ) -> Result<Option<Status>, SourceError> {
5421 // Refused before anything is read, in the words a write of the same status is.
5422 let target = self.resolved_target(category)?;
5423 let Some(mut item) = self
5424 .bound_item(id)
5425 .await?
5426 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5427 else {
5428 return Ok(None);
5429 };
5430 let board = self.status_board(&item).await?;
5431 let wanted = Status {
5432 category,
5433 name: category_name(category).to_owned(),
5434 };
5435 let (field, option, name) = self
5436 .column_for(&board.fields, &wanted, &target)?
5437 .ok_or_else(|| SourceError::Malformed {
5438 message: format!(
5439 "status {} of source {} names no board Status option",
5440 category_name(category),
5441 self.name
5442 ),
5443 })?;
5444 if item.status.category == category && item.option.as_deref() == Some(&name) {
5445 return Ok(Some(item.status));
5446 }
5447 match &target {
5448 StatusTarget::Terminal(_, reason) => {
5449 if item.content_kind == ContentKind::DraftIssue {
5450 return Err(self.closes_a_draft(category));
5451 }
5452 self.set_item_field(
5453 board.id.as_str(),
5454 &item.item_id,
5455 &field,
5456 json!({"singleSelectOptionId": option}),
5457 )
5458 .await?;
5459 self.update_content(
5460 ContentKind::Issue,
5461 &item.id,
5462 json!({"stateInput": state_input(Some(&target))}),
5463 )
5464 .await?;
5465 item.closed = true;
5466 item.status = self
5467 .statuses
5468 .status(Some(&name), true, Some(reason.reason()));
5469 item.option = Some(name);
5470 }
5471 StatusTarget::Column(_) => {
5472 // An option is what an open item's status is, so a closed issue is reopened
5473 // first — sitting closed in the column, it would read back as closed. A draft has
5474 // no state to reopen.
5475 if item.content_kind == ContentKind::Issue && item.closed {
5476 self.update_content(
5477 ContentKind::Issue,
5478 &item.id,
5479 json!({"stateInput": state_input(Some(&target))}),
5480 )
5481 .await?;
5482 item.closed = false;
5483 }
5484 self.set_item_field(
5485 board.id.as_str(),
5486 &item.item_id,
5487 &field,
5488 json!({"singleSelectOptionId": option}),
5489 )
5490 .await?;
5491 item.status = self.statuses.status(Some(&name), false, None);
5492 item.option = Some(name);
5493 }
5494 StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5495 }
5496 let status = item.status.clone();
5497 self.remember_written(item, false)?;
5498 Ok(Some(status))
5499 }
5500
5501 /// Replace one task's `delivered_by` and nothing else; see
5502 /// [`TaskSource::set_delivered_by`].
5503 ///
5504 /// One update of the body, which differs from the body GitHub holds only inside the
5505 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5506 async fn replace_delivered_by(
5507 &self,
5508 id: &NativeId,
5509 delivered_by: &[TaskRef],
5510 ) -> Result<Option<()>, SourceError> {
5511 let entries = TaskRef::listed(
5512 TaskRef::DELIVERED_BY_KEY,
5513 id,
5514 Some(&self.name),
5515 delivered_by.to_vec(),
5516 )
5517 .map_err(|message| SourceError::Refused { message })?;
5518 let Some(mut item) = self
5519 .bound_item(id)
5520 .await?
5521 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5522 else {
5523 return Ok(None);
5524 };
5525 let mut slot = item.slot.clone();
5526 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5527 self.write_slot(&mut item, &slot).await?;
5528 item.delivered_by = entries;
5529 self.remember_written(item, false)?;
5530 Ok(Some(()))
5531 }
5532
5533 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5534 /// see [`TaskSource::set_task_metadata`].
5535 ///
5536 /// `None` when this board holds no item by that id, or holds one of another kind. The
5537 /// answer is the item as this source now reads it, so what a caller is told the key
5538 /// holds is what the slot holds.
5539 ///
5540 /// A key already holding the value is answered without a write, compared as JSON rather
5541 /// than as the body's bytes: a slot a person spelled with other whitespace would
5542 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5543 async fn set_slot_key(
5544 &self,
5545 id: &NativeId,
5546 kind: BoardKind,
5547 key: &MetadataKey,
5548 value: &Value,
5549 ) -> Result<Option<Resolved>, SourceError> {
5550 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5551 return Ok(None);
5552 };
5553 if item.slot.get(key.as_str()) == Some(value) {
5554 return Ok(Some(item));
5555 }
5556 let mut slot = item.slot.clone();
5557 slot.insert(key.as_str().to_owned(), value.clone());
5558 self.write_slot(&mut item, &slot).await?;
5559 self.remember_written(item.clone(), false)?;
5560 Ok(Some(item))
5561 }
5562
5563 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5564 /// `item` up to what that write left.
5565 ///
5566 /// The body sent differs from the body GitHub holds only inside the slot — see
5567 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5568 /// the mutation the item's content takes, so a board draft's body is written with
5569 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5570 async fn write_slot(
5571 &self,
5572 item: &mut Resolved,
5573 slot: &BTreeMap<String, Value>,
5574 ) -> Result<(), SourceError> {
5575 let held = item.raw_body.clone().unwrap_or_default();
5576 let body = with_slot(&held, slot)?;
5577 if body != held {
5578 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5579 .await?;
5580 }
5581 let (visible, slot) = metadata_body(Some(body.clone()))?;
5582 item.body = visible.filter(|value| !value.is_empty());
5583 item.raw_body = Some(body);
5584 item.slot = slot;
5585 Ok(())
5586 }
5587
5588 /// This instance's target for a category, refusing one it has disabled.
5589 ///
5590 /// Nothing here mutates the board's option set to make room for a status. GitHub
5591 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5592 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5593 /// field and every item's status.
5594 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5595 let target = self.statuses.target(category).clone();
5596 if target != StatusTarget::Disabled {
5597 return Ok(target);
5598 }
5599 Err(SourceError::Refused {
5600 message: if category == StatusCategory::Draft {
5601 format!(
5602 "status draft is disabled for source {}: draft is incompatible with this \
5603 integration because GitHub draft issues cannot have sub-issues, and this \
5604 source stores a project's tasks as its issue's sub-issues",
5605 self.name
5606 )
5607 } else if category == StatusCategory::Unknown {
5608 format!(
5609 "status {} is disabled for source {}; set status_mapping.{} of this source \
5610 to one board Status option name; every word classified unknown is written \
5611 to that one option",
5612 category_name(category),
5613 self.name,
5614 category_name(category)
5615 )
5616 } else {
5617 format!(
5618 "status {} is disabled for source {}; set status_mapping.{} of this source \
5619 to a board Status option name",
5620 category_name(category),
5621 self.name,
5622 category_name(category)
5623 )
5624 },
5625 })
5626 }
5627
5628 /// What writing `priority` does to one item's `Priority` field on this board, or the
5629 /// refusal naming what the board lacks.
5630 ///
5631 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5632 /// none already, or of an item not created yet. Every other priority selects the option
5633 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5634 /// without that option, is refused rather than given one: reads and writes never create
5635 /// a field or an option.
5636 fn priority_write(
5637 &self,
5638 fields: &Value,
5639 existing: Option<&Resolved>,
5640 priority: Priority,
5641 ) -> Result<Option<PriorityWrite>, SourceError> {
5642 let Some(mapping) = &self.priorities else {
5643 return Err(self.holds_no_priority());
5644 };
5645 let Some(wanted) = mapping.option(priority) else {
5646 if !existing.is_some_and(Resolved::holds_priority) {
5647 return Ok(None);
5648 }
5649 let field =
5650 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5651 message: format!(
5652 "an item holding a {PRIORITY_FIELD} value was read without that field"
5653 ),
5654 })?;
5655 return Ok(Some(PriorityWrite::Clear {
5656 field: required_str(field, "id")?.to_owned(),
5657 }));
5658 };
5659 let missing = |detail: &str| SourceError::Refused {
5660 message: format!(
5661 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5662 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5663 it, or point priority_mapping.{priority} of this source at an option the board \
5664 has",
5665 self.name, self.name
5666 ),
5667 };
5668 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5669 return Err(missing(&format!(
5670 "this board has no {PRIORITY_FIELD} field"
5671 )));
5672 };
5673 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5674 return Err(missing(&format!(
5675 "this board's {PRIORITY_FIELD} field is not a single-select field"
5676 )));
5677 }
5678 // An options list that is absent or not a list is an answer this source cannot read,
5679 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5680 let option = field
5681 .get("options")
5682 .and_then(Value::as_array)
5683 .ok_or_else(|| SourceError::Malformed {
5684 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5685 })?
5686 .iter()
5687 .find(|option| {
5688 option
5689 .get("name")
5690 .and_then(Value::as_str)
5691 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5692 })
5693 .ok_or_else(|| missing("this board does not have it"))?;
5694 Ok(Some(PriorityWrite::Select {
5695 field: required_str(field, "id")?.to_owned(),
5696 option: required_str(option, "id")?.to_owned(),
5697 }))
5698 }
5699
5700 /// Apply one priority write to one board item.
5701 async fn write_priority(
5702 &self,
5703 board_id: &str,
5704 item_id: &str,
5705 write: &PriorityWrite,
5706 ) -> Result<(), SourceError> {
5707 match write {
5708 PriorityWrite::Select { field, option } => {
5709 self.set_item_field(
5710 board_id,
5711 item_id,
5712 field,
5713 json!({"singleSelectOptionId": option}),
5714 )
5715 .await
5716 }
5717 PriorityWrite::Clear { field } => {
5718 let data = self
5719 .graphql(
5720 graphql::CLEAR_FIELD,
5721 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5722 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5723 )
5724 .await?;
5725 let returned = data
5726 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5727 .ok_or_else(|| SourceError::Malformed {
5728 message: "GitHub field clear returned no project item".into(),
5729 })?;
5730 if required_str(returned, "id")? != item_id {
5731 return Err(SourceError::Malformed {
5732 message: "GitHub field clear returned the wrong project item".into(),
5733 });
5734 }
5735 Ok(())
5736 }
5737 }
5738 }
5739
5740 /// The refusal a priority is answered with by an instance configured with no
5741 /// `priority_mapping`, which holds none.
5742 fn holds_no_priority(&self) -> SourceError {
5743 SourceError::Refused {
5744 message: format!(
5745 "source {} holds no task priority: its configuration sets no priority_mapping; \
5746 next: set priority_mapping on this source, then run `onetaskgraph sources \
5747 fields {} --apply` to set its board up",
5748 self.name, self.name
5749 ),
5750 }
5751 }
5752
5753 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5754 ///
5755 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5756 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5757 async fn set_priority(
5758 &self,
5759 id: &NativeId,
5760 priority: Priority,
5761 ) -> Result<Option<Priority>, SourceError> {
5762 if self.priorities.is_none() {
5763 return Err(self.holds_no_priority());
5764 }
5765 let Some(mut item) = self
5766 .bound_item(id)
5767 .await?
5768 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5769 else {
5770 return Ok(None);
5771 };
5772 if priority == Priority::None && !item.holds_priority() {
5773 return Ok(Some(priority));
5774 }
5775 // The item's own read carries the field's definition whenever it holds a value of
5776 // it, which a clear always does; a select onto an item holding none reads the board.
5777 let board = match (item.carried_board(), item.named_board()) {
5778 (Some(board), _) => board,
5779 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5780 id,
5781 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5782 },
5783 _ => self.board_fields().await?,
5784 };
5785 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5786 return Ok(Some(priority));
5787 };
5788 let (document, root, input) = match write {
5789 PriorityWrite::Select { field, option } => (
5790 graphql::UPDATE_FIELD,
5791 "updateProjectV2ItemFieldValue",
5792 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5793 ),
5794 PriorityWrite::Clear { field } => (
5795 graphql::CLEAR_FIELD,
5796 "clearProjectV2ItemFieldValue",
5797 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5798 ),
5799 };
5800 let data = self
5801 .graphql(
5802 document,
5803 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5804 )
5805 .await?;
5806 let returned = data
5807 .get(root)
5808 .and_then(|value| value.get("projectV2Item"))
5809 .ok_or_else(|| SourceError::Malformed {
5810 message: "GitHub priority write returned no project item".into(),
5811 })?;
5812 if required_str(returned, "id")? != item.item_id {
5813 return Err(SourceError::Malformed {
5814 message: "GitHub priority write returned the wrong project item".into(),
5815 });
5816 }
5817 let value = returned
5818 .get("fieldValueByName")
5819 .ok_or_else(|| SourceError::Malformed {
5820 message: "GitHub priority write returned no priority read-back".into(),
5821 })?;
5822 if !value.is_null()
5823 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5824 {
5825 return Err(SourceError::Malformed {
5826 message: "GitHub priority read-back is not a Priority field value".into(),
5827 });
5828 }
5829 let values = if value.is_null() {
5830 Vec::new()
5831 } else {
5832 vec![value.clone()]
5833 };
5834 item.priority = self.held_priority(&values)?;
5835 let answer = item.task()?.priority;
5836 self.remember_written(item, false)?;
5837 Ok(Some(answer))
5838 }
5839
5840 /// Replace one task's visible body and nothing else; see
5841 /// [`TaskSource::set_task_content`].
5842 ///
5843 /// One update of the body, which differs from the body GitHub holds only outside the
5844 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5845 /// this source keeps there reads back as it was. A body that would not change is not
5846 /// sent at all.
5847 async fn replace_content(
5848 &self,
5849 id: &NativeId,
5850 content: &str,
5851 ) -> Result<Option<()>, SourceError> {
5852 let Some(mut item) = self
5853 .bound_item(id)
5854 .await?
5855 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5856 else {
5857 return Ok(None);
5858 };
5859 let held = item.raw_body.clone().unwrap_or_default();
5860 let body = with_content(&held, content)?;
5861 // Checked before anything is sent: content ending in what this source reads as its own
5862 // metadata slot would read back as metadata rather than as the content it was.
5863 let (visible, slot) = metadata_body(Some(body.clone()))?;
5864 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5865 return Err(SourceError::Refused {
5866 message: format!(
5867 "this content ends in what source {} reads as its own metadata slot \
5868 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5869 as content; next: remove that trailing block from the content",
5870 self.name
5871 ),
5872 });
5873 }
5874 if body != held {
5875 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5876 .await?;
5877 }
5878 item.body = visible.filter(|value| !value.is_empty());
5879 item.raw_body = Some(body);
5880 item.slot = slot;
5881 self.remember_written(item, false)?;
5882 Ok(Some(()))
5883 }
5884
5885 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5886 ///
5887 /// One read of the item — which carries the board's field definitions and the issue's
5888 /// `blockedBy`, so neither is read again — and then only what differs from it: the
5889 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5890 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5891 /// the title, the body — visible content and metadata slot together — and a state change.
5892 /// So an update naming any of title, body, metadata, status and priority is one read and
5893 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5894 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5895 /// as a whole write does; an open one selects its option and then reopens. The origin
5896 /// field is never written: an update is of an item that already exists, whose origin is
5897 /// what it is.
5898 ///
5899 /// The task answered is the item as those writes left it, built from the read and what was
5900 /// sent rather than read again — the same record a later read in this run answers from.
5901 async fn targeted_update(
5902 &self,
5903 id: &NativeId,
5904 update: &TaskUpdate,
5905 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5906 // Everything this source can refuse without reading the item is refused first, in the
5907 // words a whole write of the same fields is refused with.
5908 update.consistent()?;
5909 if update
5910 .title
5911 .as_deref()
5912 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5913 {
5914 return Err(SourceError::Refused {
5915 message: format!(
5916 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5917 spells a document, so it would read back as one rather than as a task; \
5918 retitle it",
5919 self.name
5920 ),
5921 });
5922 }
5923 if let Some(delivers) = &update.delivers {
5924 TaskRef::listed(
5925 TaskRef::DELIVERS_KEY,
5926 id,
5927 Some(&self.name),
5928 delivers.clone(),
5929 )
5930 .map_err(|message| SourceError::Refused { message })?;
5931 }
5932 if self.priorities.is_none()
5933 && update
5934 .priority
5935 .is_some_and(|priority| priority != Priority::None)
5936 {
5937 return Err(self.holds_no_priority());
5938 }
5939 let target = update
5940 .status
5941 .as_ref()
5942 .map(|status| self.resolved_target(status.category))
5943 .transpose()?;
5944 let Some(mut item) = self
5945 .bound_item(id)
5946 .await?
5947 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5948 else {
5949 return Ok(None);
5950 };
5951 let before = item.task()?;
5952
5953 let mut status_move = None;
5954 if let (Some(status), Some(target)) = (&update.status, target) {
5955 let board = self.status_board(&item).await?;
5956 let (field, option, name) = self
5957 .column_for(&board.fields, status, &target)?
5958 .ok_or_else(|| SourceError::Malformed {
5959 message: format!(
5960 "status {} of source {} names no board Status option",
5961 category_name(status.category),
5962 self.name
5963 ),
5964 })?;
5965 let terminal = matches!(target, StatusTarget::Terminal(_, _));
5966 if terminal && item.content_kind == ContentKind::DraftIssue {
5967 return Err(self.closes_a_draft(status.category));
5968 }
5969 let landed = match &target {
5970 StatusTarget::Terminal(_, reason) => {
5971 self.statuses
5972 .status(Some(&name), true, Some(reason.reason()))
5973 }
5974 _ => self.statuses.status(Some(&name), false, None),
5975 };
5976 let option_moves = item
5977 .option
5978 .as_deref()
5979 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5980 let state_moves = item.content_kind == ContentKind::Issue
5981 && (item.closed != terminal || (terminal && item.status != landed));
5982 if let Some(moves) = Moves::of(option_moves, state_moves) {
5983 status_move = Some(StatusMove {
5984 board: board.id,
5985 field,
5986 option,
5987 name,
5988 target,
5989 landed,
5990 moves,
5991 });
5992 }
5993 }
5994
5995 let mut priority_move = None;
5996 if let Some(priority) = update.priority
5997 && self.priorities.is_some()
5998 && item.priority != HeldPriority::Read(priority)
5999 {
6000 let board = match (item.carried_board(), item.named_board()) {
6001 (Some(board), _) => board,
6002 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6003 id: board,
6004 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6005 },
6006 _ => self.board_fields().await?,
6007 };
6008 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6009 priority_move = Some((board.id, write, priority));
6010 }
6011 }
6012
6013 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6014 // recorded in the slot, and the slot travels in the one body update below.
6015 let edges = match &update.depends_on {
6016 Some(edges) => Some(
6017 self.partition_edges(
6018 BoardKind::Work(ItemKind::Task),
6019 item.content_kind,
6020 item.blocked_by.as_deref(),
6021 edges,
6022 )
6023 .await?,
6024 ),
6025 None => None,
6026 };
6027
6028 let mut slot = item.slot.clone();
6029 for (key, value) in &update.metadata_set {
6030 slot.insert(key.as_str().to_owned(), value.clone());
6031 }
6032 for key in &update.metadata_remove {
6033 slot.remove(key.as_str());
6034 }
6035 if let Some(delivers) = &update.delivers {
6036 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6037 }
6038 if let Some((_, recorded)) = &edges {
6039 record_edges(&mut slot, recorded);
6040 }
6041 let held = item.raw_body.clone().unwrap_or_default();
6042 let content = match &update.content {
6043 Some(content) => with_content(&held, content)?,
6044 None => held.clone(),
6045 };
6046 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6047 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6048 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6049 let body = if slot == item.slot {
6050 content
6051 } else {
6052 with_slot(&content, &slot)?
6053 };
6054 // Checked before anything is sent, as a content write checks it: content ending in
6055 // what this source reads as its own slot would read back as metadata.
6056 let (visible, read) = metadata_body(Some(body.clone()))?;
6057 let wanted = update.content.as_deref().or(item.body.as_deref());
6058 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6059 return Err(SourceError::Refused {
6060 message: format!(
6061 "this content ends in what source {} reads as its own metadata slot \
6062 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6063 as content; next: remove that trailing block from the content",
6064 self.name
6065 ),
6066 });
6067 }
6068 let recorded_moves =
6069 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6070
6071 // One `updateIssue` carries all three, because every mutation spends the secondary
6072 // limiter and the title, body and state are one mutation's inputs.
6073 let mut fields = serde_json::Map::new();
6074 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6075 fields.insert("title".to_owned(), json!(title));
6076 }
6077 if body != held {
6078 fields.insert("body".to_owned(), json!(body));
6079 }
6080 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6081 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6082 }
6083 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6084 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6085 // without undoing an earlier field when a later one fails — so a body written before a
6086 // board field the board then refused would be left changed. Written after every other
6087 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6088 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6089 // first, together in one request — a terminal option selected before the issue
6090 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6091 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6092 let mut clear: Option<(&BoardId, &str)> = None;
6093 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6094 board_writes.push((
6095 &moving.board,
6096 (
6097 moving.field.clone(),
6098 json!({"singleSelectOptionId": moving.option}),
6099 ),
6100 ));
6101 }
6102 match &priority_move {
6103 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6104 board,
6105 (field.clone(), json!({"singleSelectOptionId": option})),
6106 )),
6107 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6108 None => {}
6109 }
6110 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6111 boards.extend(clear.map(|(board, _)| board));
6112 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6113 for board in boards {
6114 let writes = board_writes
6115 .iter()
6116 .filter(|(on, _)| on.as_str() == board.as_str())
6117 .map(|(_, write)| write.clone())
6118 .collect::<Vec<_>>();
6119 let cleared = clear
6120 .filter(|(on, _)| on.as_str() == board.as_str())
6121 .map(|(_, field)| field);
6122 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6123 .await?;
6124 }
6125 let mut blocked_by_moved = false;
6126 if let Some((native, _)) = &edges
6127 && item.content_kind == ContentKind::Issue
6128 {
6129 blocked_by_moved = self
6130 .reconcile_blocked_by(
6131 &item.id,
6132 native,
6133 Issue::Existing(item.blocked_by.as_deref()),
6134 )
6135 .await?;
6136 }
6137 if !fields.is_empty() {
6138 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6139 .await?;
6140 }
6141
6142 if let Some(title) = &update.title {
6143 item.title.clone_from(title);
6144 }
6145 item.body = visible.filter(|value| !value.is_empty());
6146 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6147 item.slot = slot;
6148 if let Some(delivers) = &update.delivers {
6149 item.delivers.clone_from(delivers);
6150 }
6151 if let Some(moving) = status_move {
6152 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6153 && item.content_kind == ContentKind::Issue;
6154 item.status = moving.landed;
6155 item.option = Some(moving.name);
6156 }
6157 if let Some((_, _, priority)) = priority_move {
6158 item.priority = HeldPriority::Read(priority);
6159 }
6160 let task = item.task()?;
6161 let mut written = update.changed(&before, &task);
6162 if blocked_by_moved || recorded_moves {
6163 written.insert(UpdatedField::DependsOn);
6164 }
6165 self.remember_written(item, false)?;
6166 Ok(Some(TaskUpdateOutcome {
6167 task,
6168 written,
6169 delivers_before: before.delivers,
6170 }))
6171 }
6172
6173 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6174 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6175 ///
6176 /// One update of the body: the content outside the slot, and inside it that one entry,
6177 /// every other entry kept as it was. This source keeps no template answers — an issue has
6178 /// no room beside itself that is not its body, and answers written there would duplicate
6179 /// what the content already says and count against GitHub's body limit — so `answers`
6180 /// reaches nothing here. A body that would not change is not sent at all.
6181 async fn replace_rendering(
6182 &self,
6183 id: &NativeId,
6184 kind: BoardKind,
6185 content: &str,
6186 provenance: &Value,
6187 ) -> Result<Option<()>, SourceError> {
6188 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6189 return Ok(None);
6190 };
6191 let held = item.raw_body.clone().unwrap_or_default();
6192 let mut slot = item.slot.clone();
6193 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6194 let body = with_slot(&with_content(&held, content)?, &slot)?;
6195 // Checked before anything is sent, as a content write checks it.
6196 let (visible, read) = metadata_body(Some(body.clone()))?;
6197 if visible.as_deref().unwrap_or_default() != content || read != slot {
6198 return Err(SourceError::Refused {
6199 message: format!(
6200 "this content ends in what source {} reads as its own metadata slot \
6201 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6202 as content; next: remove that trailing block from the template",
6203 self.name
6204 ),
6205 });
6206 }
6207 if body != held {
6208 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6209 .await?;
6210 }
6211 item.body = visible.filter(|value| !value.is_empty());
6212 item.raw_body = Some(body);
6213 item.slot = read;
6214 self.remember_written(item, false)?;
6215 Ok(Some(()))
6216 }
6217
6218 async fn set_item_field(
6219 &self,
6220 board_id: &str,
6221 item_id: &str,
6222 field_id: &str,
6223 value: Value,
6224 ) -> Result<(), SourceError> {
6225 let data = self
6226 .graphql(
6227 graphql::UPDATE_FIELD,
6228 json!({"input":{
6229 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6230 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6231 )
6232 .await?;
6233 let returned = data
6234 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6235 .ok_or_else(|| SourceError::Malformed {
6236 message: "GitHub field update returned no project item".into(),
6237 })?;
6238 if required_str(returned, "id")? != item_id {
6239 return Err(SourceError::Malformed {
6240 message: "GitHub field update returned the wrong project item".into(),
6241 });
6242 }
6243 Ok(())
6244 }
6245
6246 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6247 /// one request. Every returned item id is checked, including optional aliases.
6248 async fn set_item_fields(
6249 &self,
6250 board: &str,
6251 item: &str,
6252 fields: &[(String, Value)],
6253 clear: Option<&str>,
6254 ) -> Result<(), SourceError> {
6255 if fields.len() <= 1 && clear.is_none() {
6256 if let Some((field, value)) = fields.first() {
6257 self.set_item_field(board, item, field, value.clone())
6258 .await?;
6259 }
6260 return Ok(());
6261 }
6262 if fields.is_empty() {
6263 if let Some(field) = clear {
6264 self.write_priority(
6265 board,
6266 item,
6267 &PriorityWrite::Clear {
6268 field: field.to_owned(),
6269 },
6270 )
6271 .await?;
6272 }
6273 return Ok(());
6274 }
6275 let input = |index: usize| {
6276 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6277 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6278 };
6279 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6280 "input":input(0),"second":input(1),"third":input(2),
6281 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6282 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6283 })).await?;
6284 for alias in [
6285 Some("updateProjectV2ItemFieldValue"),
6286 (fields.len() > 1).then_some("second"),
6287 (fields.len() > 2).then_some("third"),
6288 clear.map(|_| "cleared"),
6289 ]
6290 .into_iter()
6291 .flatten()
6292 {
6293 let returned = data
6294 .get(alias)
6295 .and_then(|value| value.get("projectV2Item"))
6296 .ok_or_else(|| SourceError::Malformed {
6297 message: format!("GitHub field update {alias} returned no project item"),
6298 })?;
6299 if required_str(returned, "id")? != item {
6300 return Err(SourceError::Malformed {
6301 message: format!("GitHub field update {alias} returned the wrong project item"),
6302 });
6303 }
6304 }
6305 Ok(())
6306 }
6307
6308 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6309 let mut after: Option<String> = None;
6310 let mut ids = Vec::new();
6311 loop {
6312 let data = self
6313 .graphql(
6314 graphql::ISSUE_DEPENDENCIES,
6315 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6316 )
6317 .await?;
6318 let connection =
6319 data.pointer("/node/blockedBy")
6320 .ok_or_else(|| SourceError::Malformed {
6321 message: "GitHub dependency response has no blockedBy connection".into(),
6322 })?;
6323 ids.extend(
6324 connection
6325 .get("nodes")
6326 .and_then(Value::as_array)
6327 .ok_or_else(|| SourceError::Malformed {
6328 message: "GitHub dependency response nodes is not an array".into(),
6329 })?
6330 .iter()
6331 .map(|value| required_str(value, "id").map(str::to_owned))
6332 .collect::<Result<Vec<_>, _>>()?,
6333 );
6334 let next = next_cursor(connection)?;
6335 if let Some(next) = &next {
6336 validate_cursor_progress(after.as_deref(), &next.0)?;
6337 }
6338 after = next.map(|cursor| cursor.0);
6339 if after.is_none() {
6340 return Ok(ids);
6341 }
6342 }
6343 }
6344
6345 async fn dependencies(
6346 &self,
6347 id: &NativeId,
6348 near_kind: ItemKind,
6349 direction: Direction,
6350 page: &PageRequest,
6351 ) -> Result<Page<DependencyEdge>, SourceError> {
6352 validate_page(page)?;
6353 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6354 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6355 let recorded = recorded_offset(cursor, direction)?;
6356 // What this issue is blocked by, when a read of it by its own id in this command
6357 // already carried the whole connection — a copy reads the item it writes before it
6358 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6359 // after it. Answered from that read, in the shape the dependency read answers in;
6360 // anything else is asked of GitHub.
6361 let carried = match direction {
6362 Direction::DependsOn => self
6363 .resolved_cache()?
6364 .get(id)
6365 .filter(|item| item.content_kind == ContentKind::Issue)
6366 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6367 Direction::DependedOnBy => None,
6368 }
6369 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6370 // Asked for even in the recorded phase, whose page reads nothing from the
6371 // connection: `__typename` is what says whether this item has a native
6372 // relationship at all, and that is what decides which far ends the reserved key is
6373 // allowed to hold.
6374 let data = match carried {
6375 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6376 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6377 None => {
6378 self.graphql(
6379 graphql::ISSUE_DEPENDENCIES,
6380 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6381 "after":if recorded.is_some() {None} else {cursor}}),
6382 )
6383 .await?
6384 }
6385 };
6386 let node =
6387 data.get("node")
6388 .filter(|v| !v.is_null())
6389 .ok_or_else(|| SourceError::Refused {
6390 message: format!(
6391 "GitHub item {} was not found or does not support dependencies",
6392 id.0
6393 ),
6394 })?;
6395 let connection_name = match direction {
6396 Direction::DependsOn => "blockedBy",
6397 Direction::DependedOnBy => "blocking",
6398 };
6399 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6400 // named natively and the reserved key may hold any far end. An issue's connections
6401 // hold issues, and this source reads them at the near item's own level.
6402 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6403 if let Some(offset) = recorded {
6404 return Ok(recorded_page(
6405 self.recorded_edges(id, near_kind, direction, natively_names, node)
6406 .await?,
6407 offset,
6408 limit,
6409 ));
6410 }
6411 if natively_names.is_none() {
6412 return Ok(recorded_page(
6413 self.recorded_edges(id, near_kind, direction, natively_names, node)
6414 .await?,
6415 0,
6416 limit,
6417 ));
6418 }
6419 let connection = node
6420 .get(connection_name)
6421 .ok_or_else(|| SourceError::Malformed {
6422 message: "GitHub dependency response is missing its connection".into(),
6423 })?;
6424 let nodes = connection
6425 .get("nodes")
6426 .and_then(Value::as_array)
6427 .ok_or_else(|| SourceError::Malformed {
6428 message: "GitHub dependency response nodes is not an array".into(),
6429 })?;
6430 // `from` depends on `to`, always. GitHub spells the same relationship from either
6431 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6432 // it — so the near item is `from` in one direction and `to` in the other.
6433 let items = nodes
6434 .iter()
6435 .map(|value| {
6436 let related = NativeId(required_str(value, "id")?.into());
6437 let related_kind = related_kind(value)?;
6438 let (from, to) = match direction {
6439 Direction::DependsOn => (
6440 DependencyEndpoint::from_native(id.clone(), near_kind),
6441 DependencyEndpoint::from_native(related, related_kind),
6442 ),
6443 Direction::DependedOnBy => (
6444 DependencyEndpoint::from_native(related, related_kind),
6445 DependencyEndpoint::from_native(id.clone(), near_kind),
6446 ),
6447 };
6448 Ok(DependencyEdge {
6449 from,
6450 to,
6451 kind: DependencyKind::Blocks,
6452 })
6453 })
6454 .collect::<Result<Vec<_>, SourceError>>()?;
6455 let mut next = next_cursor(connection)?;
6456 if let Some(next) = &next {
6457 validate_cursor_progress(cursor, &next.0)?;
6458 }
6459 if next.is_none()
6460 && !self
6461 .recorded_edges(id, near_kind, direction, natively_names, node)
6462 .await?
6463 .is_empty()
6464 {
6465 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6466 }
6467 Ok(Page { items, next })
6468 }
6469
6470 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6471 /// a far end in another source has to live: no GitHub issue relationship can name one.
6472 ///
6473 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6474 /// source never writes one down.
6475 ///
6476 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6477 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6478 /// request beyond the read already made, and reading the board for them would be a
6479 /// walk of every item for one field of one. A draft has no body in that answer, because
6480 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6481 /// listing of the board, which can be behind on the very item asked about.
6482 async fn recorded_edges(
6483 &self,
6484 id: &NativeId,
6485 near_kind: ItemKind,
6486 direction: Direction,
6487 natively_names: Option<ItemKind>,
6488 node: &Value,
6489 ) -> Result<Vec<DependencyEdge>, SourceError> {
6490 if direction != Direction::DependsOn {
6491 return Ok(Vec::new());
6492 }
6493 let slot = match node.get("body") {
6494 Some(body) if natively_names.is_some() => {
6495 metadata_body(body.as_str().map(str::to_owned))?.1
6496 }
6497 _ => {
6498 let Some(item) = self.bound_item(id).await? else {
6499 return Ok(Vec::new());
6500 };
6501 item.slot
6502 }
6503 };
6504 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6505 .map_err(|message| SourceError::Malformed { message })
6506 }
6507
6508 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6509 self.repository
6510 .as_ref()
6511 .ok_or_else(|| SourceError::Refused {
6512 message: format!(
6513 "source {} has no repository configured, and a GitHub Projects board has no \
6514 repository of its own to create an issue in; set repository: owner/name on \
6515 this source",
6516 self.name
6517 ),
6518 })
6519 }
6520
6521 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6522 /// states.
6523 ///
6524 /// The fallback is demanded first, whichever arm answers: a write without a configured
6525 /// repository is refused naming the field exactly as it was before the rule existed,
6526 /// so a source that could not write before cannot write now, rather than writing for
6527 /// the one item whose own field happens to decide it.
6528 ///
6529 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6530 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6531 /// entry owned by someone other than the owner of the parent issue's repository —
6532 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6533 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6534 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6535 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6536 /// and is visible to the token is checked where its node id is resolved, still before
6537 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6538 /// looked up in a listing of the board, which can be minutes behind an issue its own
6539 /// `projectItems` already places on it — and that read answers first from this process's
6540 /// own record, so a project created moments ago in this command answers though GitHub
6541 /// has not caught up.
6542 async fn creation_target(
6543 &self,
6544 incoming: &Incoming<'_>,
6545 ) -> Result<RepositoryTarget, SourceError> {
6546 let fallback = self.configured_repository()?;
6547 let what = |incoming: &Incoming<'_>| {
6548 format!(
6549 "{} {:?}",
6550 incoming.written.kind().describes(),
6551 incoming.title
6552 )
6553 };
6554 let parent = match incoming.parent {
6555 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6556 SourceError::Refused {
6557 message: format!(
6558 "GitHub project issue {} was not found on the board of source {}, so {} \
6559 cannot be filed under it",
6560 parent.0,
6561 self.name,
6562 what(incoming)
6563 ),
6564 }
6565 })?),
6566 None => None,
6567 };
6568 let parents_repository = parent
6569 .as_ref()
6570 .map(|parent| {
6571 // A draft is on the board and so is found, but it has no repository to
6572 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6573 // would refuse the task only once `createIssue` had made it.
6574 if parent.content_kind == ContentKind::DraftIssue {
6575 return Err(SourceError::Refused {
6576 message: format!(
6577 "GitHub project item {} on the board of source {} is a draft, \
6578 which cannot have sub-issues, so {} cannot be filed under it",
6579 parent.id.0,
6580 self.name,
6581 what(incoming)
6582 ),
6583 });
6584 }
6585 // An issue's repository is where a sub-issue is placed and whose owner it
6586 // is compared against, so a parent whose repository this source cannot
6587 // spell as `owner/name` — GitHub's login grammar is wider than this
6588 // source's floor — is one nothing can be filed under.
6589 parent
6590 .own_repository
6591 .as_ref()
6592 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6593 .ok_or_else(|| SourceError::Malformed {
6594 message: format!(
6595 "GitHub project issue {} on the board of source {} is in {}, which \
6596 is not a {}/owner/name repository this source can place {} in",
6597 parent.id.0,
6598 self.name,
6599 parent
6600 .own_repository
6601 .as_ref()
6602 .map_or("no repository", Repository::as_str),
6603 RepositoryTarget::HOST,
6604 what(incoming)
6605 ),
6606 })
6607 })
6608 .transpose()?;
6609 match incoming.repositories {
6610 [named] => {
6611 let target =
6612 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6613 message: format!(
6614 "{} names repository {}, which is not a {}/owner/name repository \
6615 source {} can create an issue in; name one that is, or name none",
6616 what(incoming),
6617 named.as_str(),
6618 RepositoryTarget::HOST,
6619 self.name
6620 ),
6621 })?;
6622 if let Some(parents) = &parents_repository
6623 && parents.owner != target.owner
6624 {
6625 return Err(SourceError::Refused {
6626 message: format!(
6627 "{} names repository {}, owned by {}, but its project's issue is in \
6628 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6629 of the same owner as its parent issue; name a repository of {}, or \
6630 name none",
6631 what(incoming),
6632 target.slug(),
6633 target.owner,
6634 parents.slug(),
6635 parents.owner,
6636 parents.owner
6637 ),
6638 });
6639 }
6640 Ok(target)
6641 }
6642 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6643 }
6644 }
6645
6646 /// The node id of the repository `incoming` is being created in, or the refusal naming
6647 /// the item and the repository the token cannot see.
6648 ///
6649 /// Resolved once per command per repository; see [`Self::repository_cache`].
6650 async fn repository_id(
6651 &self,
6652 repository: &RepositoryTarget,
6653 incoming: &Incoming<'_>,
6654 ) -> Result<String, SourceError> {
6655 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6656 return Ok(id);
6657 }
6658 let data = self
6659 .graphql(
6660 graphql::REPOSITORY,
6661 json!({"owner":repository.owner,"name":repository.name}),
6662 )
6663 .await?;
6664 self.repository_read(&data, repository, incoming)
6665 }
6666
6667 /// The repository's node id out of an answer carrying the `repository` root, held for
6668 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6669 fn repository_read(
6670 &self,
6671 data: &Value,
6672 repository: &RepositoryTarget,
6673 incoming: &Incoming<'_>,
6674 ) -> Result<String, SourceError> {
6675 let node = data
6676 .get("repository")
6677 .filter(|value| !value.is_null())
6678 .ok_or_else(|| SourceError::Refused {
6679 message: format!(
6680 "GitHub repository {} was not found or is not visible to the token, so {} \
6681 {:?} cannot be created in it",
6682 repository.slug(),
6683 incoming.written.kind().describes(),
6684 incoming.title
6685 ),
6686 })?;
6687 let id = required_str(node, "id")?.to_owned();
6688 self.repository_cache()?
6689 .insert(repository.clone(), id.clone());
6690 Ok(id)
6691 }
6692
6693 fn repository_cache(
6694 &self,
6695 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6696 self.repository_cache
6697 .lock()
6698 .map_err(|_| SourceError::Unavailable {
6699 message: "this source's record of the destination repository was left \
6700 inconsistent by an earlier failure; next: run the command again"
6701 .into(),
6702 })
6703 }
6704
6705 /// Create or update one board item, whichever kind it is.
6706 async fn write_item(
6707 &self,
6708 incoming: &Incoming<'_>,
6709 target: Option<&NativeId>,
6710 depends_on: &[DependencyEdge],
6711 ) -> Result<NativeId, SourceError> {
6712 // Refused before anything is read or written: a task or a project titled the way
6713 // this board spells a document would land as an issue this same source reads back
6714 // as a document, so the field this destination cannot carry is named rather than
6715 // written and silently reclassified.
6716 if let Written::Work(kind, _) = incoming.written
6717 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6718 {
6719 return Err(SourceError::Refused {
6720 message: format!(
6721 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6722 spells a document, so it would read back as one rather than as a {}; \
6723 retitle it, or copy it as a document",
6724 kind.marker(),
6725 self.name,
6726 kind.marker()
6727 ),
6728 });
6729 }
6730 // The destination is read by its own id, and whether this board holds it is decided
6731 // by that read — its own `projectItems` — rather than by whether a listing of the
6732 // board happens to include it yet. See the module documentation.
6733 let existing = match target {
6734 Some(target) => {
6735 Some(
6736 self.bound_item(target)
6737 .await?
6738 .ok_or_else(|| SourceError::Refused {
6739 message: format!("GitHub destination item {} was not found", target.0),
6740 })?,
6741 )
6742 }
6743 None => None,
6744 };
6745 let existing = existing.as_ref();
6746 // An existing issue is never moved; a new one is created where the rule says — and
6747 // knowing where is what lets the board's fields and that repository's id be read
6748 // together, before anything below needs either.
6749 let creation_target = match existing {
6750 Some(_) => None,
6751 None => {
6752 let target = self.creation_target(incoming).await?;
6753 self.creation_context(&target, incoming).await?;
6754 Some(target)
6755 }
6756 };
6757 let board = self
6758 .fields_for(
6759 existing,
6760 incoming.written.status().is_some(),
6761 incoming
6762 .priority
6763 .is_some_and(|priority| priority != Priority::None),
6764 )
6765 .await?;
6766 let status_target = incoming
6767 .written
6768 .status()
6769 .map(|status| self.resolved_target(status.category))
6770 .transpose()?;
6771 let column = match (incoming.written.status(), status_target.as_ref()) {
6772 (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6773 _ => None,
6774 };
6775 // Resolved before anything is created, for the reason the column above is: a
6776 // priority this board has no option for is refused while nothing has been written.
6777 let priority_write = match incoming.priority {
6778 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6779 None => None,
6780 };
6781 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6782 if content_kind == ContentKind::DraftIssue {
6783 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6784 (status_target.as_ref(), incoming.written.status())
6785 {
6786 return Err(self.closes_a_draft(status.category));
6787 }
6788 if incoming.parent.is_some() {
6789 return Err(SourceError::Refused {
6790 message: "GitHub draft items cannot be a project's sub-issue".into(),
6791 });
6792 }
6793 }
6794 match existing {
6795 Some(item) if content_kind == ContentKind::Issue => {
6796 if item.labels != incoming.labels {
6797 return Err(SourceError::Refused {
6798 message: "GitHub issue labels differ from the labels being written".into(),
6799 });
6800 }
6801 }
6802 _ => {
6803 if !incoming.labels.is_empty() {
6804 return Err(SourceError::Refused {
6805 message: "GitHub items created by this destination carry no labels".into(),
6806 });
6807 }
6808 }
6809 }
6810
6811 // The repository the issue really lives in is what the slot below is written against,
6812 // so a single entry that is where the issue is created travels as no key at all, and
6813 // the read side derives it back from the issue.
6814 let own_repository = match (existing, &creation_target) {
6815 (Some(item), _) => item.own_repository.clone(),
6816 (None, Some(target)) => Some(
6817 Repository::try_from(target.origin())
6818 .map_err(|message| SourceError::Config { message })?,
6819 ),
6820 (None, None) => None,
6821 };
6822 let (native, fallback) = self
6823 .partition_edges(
6824 incoming.written.kind(),
6825 content_kind,
6826 existing.and_then(|item| item.blocked_by.as_deref()),
6827 depends_on,
6828 )
6829 .await?;
6830 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6831 let body = compose_body(incoming.content, &slot)?;
6832 // Read before anything is created, for the reason the field below is: a value
6833 // this destination cannot store has to refuse, and refusing after `createIssue`
6834 // would leave an issue behind that nothing asked for. The engine writes a
6835 // qualified id here; a caller handing this key anything else is told so rather
6836 // than having it silently stored as no origin at all.
6837 // 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.
6838 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6839 None => "",
6840 Some(Value::String(origin)) => origin.as_str(),
6841 Some(other) => {
6842 return Err(SourceError::Refused {
6843 message: format!(
6844 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6845 is {other}"
6846 ),
6847 });
6848 }
6849 };
6850 // Resolved before anything is created: a board that cannot carry the copy origin
6851 // has to refuse the write, and refusing it after `createIssue` would leave an
6852 // issue behind that nothing asked for.
6853 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6854 Some(field) => {
6855 if required_str(field, "__typename")? != "ProjectV2Field" {
6856 return Err(SourceError::Refused {
6857 message: format!(
6858 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6859 ),
6860 });
6861 }
6862 Some(required_str(field, "id")?.to_owned())
6863 }
6864 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6865 return Err(SourceError::Refused {
6866 message: format!(
6867 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6868 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6869 the board"
6870 ),
6871 });
6872 }
6873 None => None,
6874 };
6875
6876 let Landed {
6877 content_id,
6878 item_id,
6879 url,
6880 number,
6881 } = match existing {
6882 // Its content is written last, below, once everything else has landed.
6883 Some(item) => Landed {
6884 content_id: item.id.clone(),
6885 item_id: item.item_id.clone(),
6886 url: item.url.clone(),
6887 number: item.number,
6888 },
6889 None => {
6890 let target = creation_target
6891 .as_ref()
6892 .ok_or_else(|| SourceError::Malformed {
6893 message: "a new item was decided without a repository to create it in"
6894 .into(),
6895 })?;
6896 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6897 .await?
6898 }
6899 };
6900
6901 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6902 let column = column
6903 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6904 .map(|(field, option, _)| (field, option));
6905 // Creating an item here is several calls — `createIssue`, which files it on the
6906 // board, then its board fields, the parent and the dependencies — and GitHub can fail
6907 // at any of them. Everything this source can refuse *before* the first of those is
6908 // already checked above, so what is left is GitHub itself failing part way. When it
6909 // does over an item this call created, the issue is taken back: a write that
6910 // refused must not leave an item behind that nobody asked for, and one that does
6911 // makes the retry create a second.
6912 // Whether the board-field write carrying a moved origin was answered as landing whole.
6913 // When it was refused, GitHub does not say which of its fields ran before the one that
6914 // failed, so the origin may or may not have moved.
6915 let mut origin_landed = false;
6916 let landed = self
6917 .finish_write(
6918 board.id.as_str(),
6919 incoming,
6920 &content_id,
6921 &item_id,
6922 content_kind,
6923 existing,
6924 origin_field.as_deref(),
6925 origin,
6926 column,
6927 status_target.as_ref(),
6928 priority_write.as_ref(),
6929 &native,
6930 &mut origin_landed,
6931 )
6932 .await;
6933 // An existing item's title, body and state go last, in one `updateIssue`, once its board
6934 // fields and its relationships have landed: a refusal of any of those then leaves its
6935 // body — and the metadata slot inside it — exactly as it stood.
6936 let landed = match (landed, existing) {
6937 (Ok(()), Some(item)) => {
6938 self.update_existing(item, incoming, &body, status_target.as_ref())
6939 .await
6940 }
6941 (landed, _) => landed,
6942 };
6943 if let Err(error) = landed {
6944 match existing {
6945 // Best effort, and the write's own failure is what the caller is told: a
6946 // refusal naming the tidy-up would hide why the write failed at all.
6947 None => {
6948 let _ = self.delete_issue(&content_id).await;
6949 }
6950 // The origin field is the one piece of an existing item's metadata written
6951 // before its body, so a write refused after it puts it back as it was. When
6952 // that is refused too, the write's own failure is still what the caller is
6953 // told — with what it left behind added, because the item's metadata is then
6954 // not as it stood and a caller retrying has to know which key moved.
6955 Some(item) => {
6956 let before = item.origin.as_deref().unwrap_or("");
6957 if let Some(field) = origin_field.as_deref()
6958 && before != origin
6959 && let Err(restore) = self
6960 .set_item_field(
6961 board.id.as_str(),
6962 &item.item_id,
6963 field,
6964 json!({"text": before}),
6965 )
6966 .await
6967 {
6968 let left = if origin_landed {
6969 format!(
6970 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
6971 not be put back to {before:?} ({restore}), so item {} still \
6972 holds {origin:?} there",
6973 item.id.0
6974 )
6975 } else {
6976 format!(
6977 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
6978 {origin:?}, GitHub does not say whether that part of it ran, \
6979 and putting it back to {before:?} was refused ({restore}), so \
6980 item {} holds {origin:?} or {before:?} there",
6981 item.id.0
6982 )
6983 };
6984 return Err(noting(
6985 error,
6986 &format!(
6987 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
6988 run the write again"
6989 ),
6990 ));
6991 }
6992 }
6993 }
6994 return Err(error);
6995 }
6996
6997 let written_status = match (incoming.written.status(), status_target.as_ref()) {
6998 (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6999 self.statuses
7000 .status(written_option.as_deref(), true, Some(reason.reason()))
7001 }
7002 (Some(_), Some(StatusTarget::Column(_))) => {
7003 self.statuses.status(written_option.as_deref(), false, None)
7004 }
7005 (Some(status), _) => status.clone(),
7006 (None, _) => Status {
7007 category: StatusCategory::Unknown,
7008 name: "Open".to_owned(),
7009 },
7010 };
7011
7012 // So the rest of this command reads what it just did rather than what the board
7013 // said before it. See `remember_written` for which half takes it.
7014 let remembered = Resolved {
7015 item_id,
7016 id: content_id.clone(),
7017 content_kind,
7018 kind: incoming.written.kind(),
7019 title: incoming.title.to_owned(),
7020 // The visible half of the body this write composed, split back off it the
7021 // way a read splits it — so what this record reports is what a read of the
7022 // same issue reports, rather than the person's text with the metadata slot
7023 // still on the end of it.
7024 body: metadata_body(body.clone())?.0,
7025 raw_body: body.clone(),
7026 // A document has no status of its own; what it reads back as is whatever
7027 // the issue's own state says, which is what a re-read reports.
7028 status: written_status,
7029 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7030 priority: match incoming.priority {
7031 Some(priority) => HeldPriority::Read(priority),
7032 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7033 item.priority.clone()
7034 }),
7035 },
7036 // What `state_input` asked for: closed for a terminal target, open for any other
7037 // status, and the issue's own state left as it was by a document write.
7038 closed: content_kind == ContentKind::Issue
7039 && match status_target.as_ref() {
7040 Some(StatusTarget::Terminal(_, _)) => true,
7041 Some(_) => false,
7042 None => existing.is_some_and(|item| item.closed),
7043 },
7044 delivers: incoming.delivers.to_vec(),
7045 delivered_by: incoming.delivered_by.to_vec(),
7046 labels: incoming.labels.to_vec(),
7047 parent: incoming.parent.cloned(),
7048 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7049 number,
7050 // In the update path this is the item's own url, read off `existing` where the
7051 // record above was bound, so one expression serves both halves.
7052 url,
7053 created_at: existing.and_then(|item| item.created_at),
7054 updated_at: existing.and_then(|item| item.updated_at),
7055 own_repository,
7056 repositories: incoming.repositories.to_vec(),
7057 slot,
7058 board_id: Some(board.id.as_str().to_owned()),
7059 fields: board
7060 .fields
7061 .get("nodes")
7062 .and_then(Value::as_array)
7063 .cloned()
7064 .unwrap_or_default(),
7065 board_fields: Some(board.fields.clone()),
7066 // What this write left the relationship holding is known by id alone, and a
7067 // later read of its edges needs each far end's kind, so it reads them again.
7068 blocked_by: None,
7069 };
7070 self.remember_written(remembered, existing.is_none())?;
7071 Ok(content_id)
7072 }
7073
7074 /// Everything a write does after the item exists: its board fields, its parent, and
7075 /// its dependencies.
7076 ///
7077 /// Split out of `write_item` so there is one place a failure past the point of no
7078 /// return is caught, rather than a tidy-up repeated at each `?` above.
7079 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7080 // so there is one place a failure past the point of no return is caught, and its
7081 // arguments are exactly the values that tail already had in scope. Bundling them into a
7082 // struct would describe no concept — it would be "the arguments of this function" — and
7083 // would put the whole of `write_item`'s locals behind one more indirection.
7084 #[allow(clippy::too_many_arguments)]
7085 async fn finish_write(
7086 &self,
7087 board_id: &str,
7088 incoming: &Incoming<'_>,
7089 content_id: &NativeId,
7090 item_id: &str,
7091 content_kind: ContentKind,
7092 existing: Option<&Resolved>,
7093 origin_field: Option<&str>,
7094 origin: &str,
7095 column: Option<(String, String)>,
7096 status_target: Option<&StatusTarget>,
7097 priority: Option<&PriorityWrite>,
7098 native: &[String],
7099 origin_landed: &mut bool,
7100 ) -> Result<(), SourceError> {
7101 let mut fields = Vec::new();
7102 if let Some(field_id) = origin_field
7103 && existing.map_or(!origin.is_empty(), |item| {
7104 item.origin.as_deref().unwrap_or("") != origin
7105 })
7106 {
7107 fields.push((field_id.to_owned(), json!({"text":origin})));
7108 }
7109 if let Some((field_id, option_id)) = column {
7110 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7111 }
7112 let clear = match priority {
7113 Some(PriorityWrite::Select { field, option }) => {
7114 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7115 None
7116 }
7117 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7118 None => None,
7119 };
7120 self.set_item_fields(board_id, item_id, &fields, clear)
7121 .await?;
7122 *origin_landed = true;
7123
7124 // An existing issue closes in the `updateIssue` its write ends with; one created just
7125 // now closes here, once its option is selected.
7126 if existing.is_none()
7127 && content_kind == ContentKind::Issue
7128 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7129 {
7130 self.update_content(
7131 ContentKind::Issue,
7132 content_id,
7133 json!({"stateInput":state_input(status_target)}),
7134 )
7135 .await?;
7136 }
7137
7138 if content_kind == ContentKind::Issue {
7139 self.reparent(
7140 existing.and_then(|item| item.parent.clone()),
7141 content_id,
7142 incoming.parent,
7143 )
7144 .await?;
7145 // A document takes part in no dependency graph, so writing one neither reads
7146 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7147 // against the empty list a document write carries would *delete* whatever
7148 // relationships a person had made on that issue, which is a write nobody
7149 // asked for.
7150 if incoming.written.kind() != BoardKind::Document {
7151 let issue = match existing {
7152 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7153 None => Issue::Created,
7154 };
7155 self.reconcile_blocked_by(content_id, native, issue).await?;
7156 }
7157 }
7158 Ok(())
7159 }
7160
7161 /// Delete one issue, which takes its board item with it.
7162 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7163 let data = self
7164 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7165 .await?;
7166 data.pointer("/deleteIssue/repository")
7167 .filter(|value| !value.is_null())
7168 .ok_or_else(|| SourceError::Malformed {
7169 message: "GitHub issue deletion returned no repository".into(),
7170 })?;
7171 self.forget(id)?;
7172 Ok(())
7173 }
7174
7175 /// Remove one item this copy created, so a copy that could not finish leaves the board
7176 /// as it found it.
7177 ///
7178 /// Deleting the issue takes its board item with it, so there is no second mutation to
7179 /// keep in step. An id the board does not hold is not an error: the item is already
7180 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7181 /// by its own id — a listing of the board can still be missing an item it holds, and
7182 /// reading that as *already gone* would leave behind the very item this was asked to
7183 /// take back.
7184 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7185 let Some(item) = self.bound_item(id).await? else {
7186 return Ok(());
7187 };
7188 if item.content_kind == ContentKind::DraftIssue {
7189 return Err(SourceError::Refused {
7190 message: format!(
7191 "GitHub item {} is a draft, and this source removes an item by deleting \
7192 its issue; next: remove it from the board by hand",
7193 id.0
7194 ),
7195 });
7196 }
7197 let data = self
7198 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7199 .await?;
7200 data.pointer("/deleteIssue/repository")
7201 .filter(|value| !value.is_null())
7202 .ok_or_else(|| SourceError::Malformed {
7203 message: "GitHub issue deletion returned no repository".into(),
7204 })?;
7205 self.forget(id)?;
7206 Ok(())
7207 }
7208
7209 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7210 /// task.
7211 ///
7212 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7213 /// read of the task cannot disagree about which ids name one: a project or a document of
7214 /// this board is not a task here either.
7215 ///
7216 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7217 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7218 /// which would read as a task nobody has commented on yet.
7219 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7220 let cached = self.resolved_cache()?.get(task).cloned();
7221 let Some(item) = (match cached {
7222 Some(item) => Some(item),
7223 None => self.item_by_id(task).await?,
7224 })
7225 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7226 return Ok(None);
7227 };
7228 if item.content_kind == ContentKind::DraftIssue {
7229 return Err(self.draft_has_no_comments(task));
7230 }
7231 Ok(Some(item.id))
7232 }
7233
7234 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7235 /// issues, and a draft is not one.
7236 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7237 SourceError::Refused {
7238 message: format!(
7239 "task {} of source {} is a draft item on the board, and GitHub keeps \
7240 comments on issues alone, so a draft has none to read or write; next: \
7241 convert the draft to an issue on the board, then comment on the issue it \
7242 becomes",
7243 task.0, self.name
7244 ),
7245 }
7246 }
7247
7248 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7249 /// request — or `None` when this board holds no task by that id.
7250 ///
7251 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7252 /// is answered with the draft and the refusal, at the price of the draft's own read.
7253 async fn issue_detail(
7254 &self,
7255 id: &NativeId,
7256 page: &PageRequest,
7257 ) -> Result<Option<TaskDetailRead>, SourceError> {
7258 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7259 let asked = self
7260 .graphql(
7261 graphql::ISSUE_DETAIL,
7262 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7263 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7264 "duplicates":true}),
7265 )
7266 .await;
7267 let data = match asked {
7268 Ok(data) => data,
7269 Err(error) if unresolvable_node(&error) => return Ok(None),
7270 Err(error) => return Err(error),
7271 };
7272 // `node` is null for an id that names nothing, and absent only from an answer this
7273 // source cannot read — never the same thing.
7274 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7275 message: format!("GitHub answered the read of {} with no node", id.0),
7276 })?;
7277 self.detail_of(id, node, true, after).await
7278 }
7279
7280 /// Several tasks, each with the first page of its comments when `comments` is set, read
7281 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7282 /// order.
7283 ///
7284 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7285 /// one item at a time, so that id is answered as missing and the others as themselves; any
7286 /// other refusal is every id of that batch's answer.
7287 async fn issue_details(
7288 &self,
7289 ids: &[NativeId],
7290 comments: Option<&PageRequest>,
7291 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7292 let mut read = Vec::with_capacity(ids.len());
7293 for batch in ids.chunks(DETAIL_BATCH) {
7294 match self
7295 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7296 .await
7297 {
7298 Ok(data) => {
7299 for (slot, id) in batch.iter().enumerate() {
7300 // Every alias asked for is answered, null for an id naming nothing;
7301 // one missing is an answer this source cannot read.
7302 let read_one = match data.get(format!("i{slot}")) {
7303 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7304 None => Err(SourceError::Malformed {
7305 message: format!(
7306 "GitHub answered a batch read with no item for {}",
7307 id.0
7308 ),
7309 }),
7310 };
7311 read.push(read_one);
7312 }
7313 }
7314 Err(error) if unresolvable_node(&error) => {
7315 for id in batch {
7316 read.push(match comments {
7317 Some(page) => self.issue_detail(id, page).await,
7318 None => self.task_read(id).await,
7319 });
7320 }
7321 }
7322 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7323 }
7324 }
7325 read
7326 }
7327
7328 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7329 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7330 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7331 task,
7332 comments: None,
7333 }))
7334 }
7335
7336 /// What one node a detail read reached says: the task this board holds by `id`, with the
7337 /// page of comments the node carries when `commented` — or `None` for a node that is no
7338 /// task of this board.
7339 ///
7340 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7341 /// and an item this process created answers from this process's own record, which a node
7342 /// read taken moments after the write can still be behind.
7343 async fn detail_of(
7344 &self,
7345 id: &NativeId,
7346 node: &Value,
7347 commented: bool,
7348 after: Option<&str>,
7349 ) -> Result<Option<TaskDetailRead>, SourceError> {
7350 if node.is_null() {
7351 return Ok(None);
7352 }
7353 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7354 // An issue answered under one id is that id's, or the answer is not one this source
7355 // can report: reporting another issue's task and comments under the qualified id asked
7356 // for would be the one wrong answer here. A draft's own read checks the same.
7357 if !draft
7358 && optional_str(node, "__typename")? == Some("Issue")
7359 && required_str(node, "id")? != id.0
7360 {
7361 return Err(SourceError::Malformed {
7362 message: format!(
7363 "GitHub answered the read of {} with issue {}",
7364 id.0,
7365 required_str(node, "id")?
7366 ),
7367 });
7368 }
7369 let item = if draft {
7370 self.draft_by_id(id).await?
7371 } else {
7372 self.resolve_issue(node).await?
7373 };
7374 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7375 return Ok(None);
7376 };
7377 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7378 let task = own.unwrap_or(item).task()?;
7379 let comments = match (commented, draft) {
7380 (false, _) => None,
7381 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7382 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7383 };
7384 Ok(Some(TaskDetailRead { task, comments }))
7385 }
7386
7387 /// Whether the comment `comment` is one of `issue`'s own.
7388 ///
7389 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7390 /// comment's id and nothing else: a comment id given against the wrong task would
7391 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7392 /// names something that is not an issue comment, is a comment this task does not have —
7393 /// which is what GitHub refusing to resolve it means too.
7394 async fn comment_is_on(
7395 &self,
7396 issue: &NativeId,
7397 comment: &NativeId,
7398 ) -> Result<bool, SourceError> {
7399 let asked = self
7400 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7401 .await;
7402 let data = match asked {
7403 Ok(data) => data,
7404 Err(error) if unresolvable_node(&error) => return Ok(false),
7405 Err(error) => return Err(error),
7406 };
7407 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7408 return Ok(false);
7409 };
7410 if optional_str(node, "__typename")? != Some("IssueComment") {
7411 return Ok(false);
7412 }
7413 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7414 message: format!("GitHub issue comment {} names no issue", comment.0),
7415 })?;
7416 Ok(required_str(on, "id")? == issue.0)
7417 }
7418
7419 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7420 async fn partition_edges(
7421 &self,
7422 near_kind: BoardKind,
7423 near_content: ContentKind,
7424 carried: Option<&[Value]>,
7425 depends_on: &[DependencyEdge],
7426 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7427 let mut native = Vec::new();
7428 let mut fallback = Vec::new();
7429 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7430 .iter()
7431 .map(|edge| {
7432 let same_source = edge
7433 .to
7434 .source()
7435 .is_none_or(|source| source == self.name.as_str());
7436 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7437 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7438 // colons of its own, so the far end is everything after that one separator.
7439 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7440 let far_id = if edge.to.is_qualified() {
7441 edge.to
7442 .id()
7443 .split_once(':')
7444 .map_or(edge.to.id(), |(_, native)| native)
7445 } else {
7446 edge.to.id()
7447 };
7448 // One that already blocks the near issue was answered by that issue's own
7449 // read, which carried each of its blockers' kinds — an issue every one — so it
7450 // is not read again.
7451 let blocking = carried.and_then(|nodes| {
7452 nodes
7453 .iter()
7454 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7455 });
7456 (edge, far_id, same_source, blocking)
7457 })
7458 .collect();
7459 // Every other same-source far end is read by its own id, exactly as the item it is a
7460 // far end of is: whether this board holds it is that read's answer, never a listing's.
7461 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7462 let mut unread: Vec<NativeId> = Vec::new();
7463 for (_, far_id, same_source, blocking) in &far_ends {
7464 let id = NativeId((*far_id).to_owned());
7465 if *same_source && blocking.is_none() && !unread.contains(&id) {
7466 unread.push(id);
7467 }
7468 }
7469 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7470 .iter()
7471 .cloned()
7472 .zip(self.items_by_ids(&unread).await?)
7473 .collect();
7474 for (edge, far_id, same_source, blocking) in far_ends {
7475 let far = match (same_source, blocking) {
7476 (false, _) => None,
7477 (true, Some(node)) => Some(FarEnd {
7478 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7479 BoardKind::Document
7480 } else {
7481 BoardKind::Work(related_kind(node)?)
7482 },
7483 content_kind: ContentKind::Issue,
7484 }),
7485 (true, None) => {
7486 let read = read
7487 .get(&NativeId(far_id.to_owned()))
7488 .cloned()
7489 .flatten()
7490 .ok_or_else(|| SourceError::Refused {
7491 message: format!("GitHub dependency item {far_id} was not found"),
7492 })?;
7493 Some(FarEnd {
7494 kind: read.kind,
7495 content_kind: read.content_kind,
7496 })
7497 }
7498 };
7499 let far = far.as_ref();
7500 // The caller says which kind the far end is, and this board holds the far end
7501 // itself, so a disagreement is settled here rather than stored: recorded, the
7502 // wrong kind would read back as a cross-level edge that never existed; written
7503 // natively, it would name a relationship of a different level than the caller
7504 // asked for.
7505 //
7506 // A far end this board holds as a *document* fails the same comparison and is
7507 // refused by the same sentence: `ItemKind` has no document variant because
7508 // nothing may point at one, so no caller can name it correctly and the refusal
7509 // is the only honest answer.
7510 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7511 return Err(SourceError::Refused {
7512 message: format!(
7513 "GitHub dependency item {far_id} is a {} of this board, and this item \
7514 names it as a {}; record the kind it is",
7515 disagreeing.kind.describes(),
7516 edge.to.kind.marker()
7517 ),
7518 });
7519 }
7520 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7521 // however the far end is spelled — and one classified native here would be
7522 // written nowhere at all, because a draft's native reconciliation never runs.
7523 let native_here = near_content == ContentKind::Issue
7524 && far.is_some_and(|far| {
7525 far.content_kind == ContentKind::Issue
7526 && BoardKind::Work(edge.to.kind) == near_kind
7527 });
7528 if native_here {
7529 native.push(far_id.to_owned());
7530 } else {
7531 fallback.push(edge.clone());
7532 }
7533 }
7534 Ok((native, fallback))
7535 }
7536
7537 async fn update_existing(
7538 &self,
7539 item: &Resolved,
7540 incoming: &Incoming<'_>,
7541 body: &Option<String>,
7542 status_target: Option<&StatusTarget>,
7543 ) -> Result<(), SourceError> {
7544 let title = incoming.written_title();
7545 // A terminal status closes the issue here, in the same mutation as its body: its board
7546 // option was selected before this, so a close never lands on an item whose board cannot
7547 // show it.
7548 let fields = match item.content_kind {
7549 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7550 ContentKind::Issue => json!({"title":title,"body":body,
7551 "stateInput":state_input(status_target)}),
7552 };
7553 self.update_content(item.content_kind, &item.id, fields)
7554 .await
7555 }
7556
7557 /// Update one board item's content with exactly `fields` beside its id, through the
7558 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7559 /// a draft.
7560 ///
7561 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7562 /// is what lets a narrow write carry the one thing it changes and nothing else.
7563 async fn update_content(
7564 &self,
7565 kind: ContentKind,
7566 id: &NativeId,
7567 fields: Value,
7568 ) -> Result<(), SourceError> {
7569 let (operation, id_key, pointer) = match kind {
7570 ContentKind::DraftIssue => (
7571 graphql::UPDATE_DRAFT,
7572 "draftIssueId",
7573 "/updateProjectV2DraftIssue/draftIssue",
7574 ),
7575 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7576 };
7577 let mut input = fields;
7578 input[id_key] = json!(id.0);
7579 let data = self.graphql(operation, json!({"input":input})).await?;
7580 let returned = data
7581 .pointer(pointer)
7582 .ok_or_else(|| SourceError::Malformed {
7583 message: "GitHub item update returned no item".into(),
7584 })?;
7585 if required_str(returned, "id")? != id.0 {
7586 return Err(SourceError::Malformed {
7587 message: "GitHub item update returned the wrong item".into(),
7588 });
7589 }
7590 Ok(())
7591 }
7592
7593 /// Creates one issue, files it on the board, and reports what a read of it would say:
7594 /// its content id, its board item id, and the web address GitHub gave it.
7595 ///
7596 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7597 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7598 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7599 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7600 /// "Content already exists in this project". A terminal status is not written here:
7601 /// `finish_write` selects its option first and closes the issue after, so a close never
7602 /// lands on an item whose board cannot show it.
7603 ///
7604 /// The address and the number come back here because this is the only place either is
7605 /// known before GitHub's own board read catches up — an item this run created answers
7606 /// the reads that follow it out of the record below, and one remembered without them
7607 /// would report no location and no key for the rest of the run.
7608 async fn create_and_file_issue(
7609 &self,
7610 board_id: &str,
7611 repository: &RepositoryTarget,
7612 incoming: &Incoming<'_>,
7613 body: &Option<String>,
7614 ) -> Result<Landed, SourceError> {
7615 let repository_id = self.repository_id(repository, incoming).await?;
7616 let data = self
7617 .graphql(
7618 graphql::CREATE_ISSUE,
7619 json!({"input":{
7620 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7621 }}),
7622 )
7623 .await?;
7624 let created = data
7625 .pointer("/createIssue/issue")
7626 .filter(|value| !value.is_null())
7627 .ok_or_else(|| SourceError::Malformed {
7628 message: "GitHub issue creation returned no issue".into(),
7629 })?;
7630 let content_id = NativeId(required_str(created, "id")?.to_owned());
7631 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7632 // a response without it is not worth failing a landed write over — the item simply
7633 // reports no location until the board read catches up, which is what it did before.
7634 let url = optional_str(created, "url")?.map(str::to_owned);
7635 // The issue exists from here on, so an unreadable number and a refused board
7636 // filing below each try, best effort, to take it back: an issue in the repository
7637 // that is on no board is an item nobody asked for and nothing here would find again.
7638 //
7639 // Its number is optional on the same terms its address is — a landed write is not
7640 // worth failing over a member that came back missing, and such an item reports no
7641 // handle until a board read catches up. A number that is *present* and is not an
7642 // unsigned integer is still a response this source cannot read.
7643 let number = match created_issue_number(created) {
7644 Ok(number) => number,
7645 Err(error) => {
7646 let _ = self.delete_issue(&content_id).await;
7647 return Err(error);
7648 }
7649 };
7650 let added = match self
7651 .graphql(
7652 graphql::ADD_TO_BOARD,
7653 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7654 )
7655 .await
7656 {
7657 Ok(added) => added,
7658 Err(error) => {
7659 let _ = self.delete_issue(&content_id).await;
7660 return Err(error);
7661 }
7662 };
7663 let item = added
7664 .pointer("/addProjectV2ItemById/item")
7665 .filter(|value| !value.is_null())
7666 .ok_or_else(|| SourceError::Malformed {
7667 message: "GitHub board addition returned no project item".into(),
7668 })?;
7669 Ok(Landed {
7670 content_id,
7671 item_id: required_str(item, "id")?.to_owned(),
7672 url,
7673 number,
7674 })
7675 }
7676
7677 /// Move one issue under the project it now belongs to, or out of the one it left.
7678 async fn reparent(
7679 &self,
7680 held: Option<NativeId>,
7681 child: &NativeId,
7682 wanted: Option<&NativeId>,
7683 ) -> Result<(), SourceError> {
7684 if held.as_ref() == wanted {
7685 return Ok(());
7686 }
7687 if let Some(held) = &held {
7688 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7689 .await?;
7690 }
7691 if let Some(wanted) = wanted {
7692 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7693 .await?;
7694 }
7695 Ok(())
7696 }
7697
7698 async fn sub_issue(
7699 &self,
7700 operation: &str,
7701 parent: &NativeId,
7702 child: &NativeId,
7703 root: &str,
7704 ) -> Result<(), SourceError> {
7705 let data = self
7706 .graphql(
7707 operation,
7708 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7709 )
7710 .await?;
7711 let issue =
7712 data.pointer(&format!("/{root}/issue"))
7713 .ok_or_else(|| SourceError::Malformed {
7714 message: "GitHub sub-issue update returned no issue".into(),
7715 })?;
7716 let sub =
7717 data.pointer(&format!("/{root}/subIssue"))
7718 .ok_or_else(|| SourceError::Malformed {
7719 message: "GitHub sub-issue update returned no sub-issue".into(),
7720 })?;
7721 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7722 return Err(SourceError::Malformed {
7723 message: "GitHub sub-issue update returned the wrong issues".into(),
7724 });
7725 }
7726 Ok(())
7727 }
7728
7729 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7730 /// whether there was one.
7731 ///
7732 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7733 /// relationships are not read: there is nothing a read of them could find.
7734 async fn reconcile_blocked_by(
7735 &self,
7736 content_id: &NativeId,
7737 native: &[String],
7738 issue: Issue<'_>,
7739 ) -> Result<bool, SourceError> {
7740 let current = match issue {
7741 Issue::Created => Vec::new(),
7742 Issue::Existing(Some(held)) => held
7743 .iter()
7744 .map(|far| required_str(far, "id").map(str::to_owned))
7745 .collect::<Result<Vec<_>, _>>()?,
7746 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7747 };
7748 let mut changed = false;
7749 for (operation, far_id) in current
7750 .iter()
7751 .filter(|id| !native.contains(id))
7752 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7753 .chain(
7754 native
7755 .iter()
7756 .filter(|id| !current.contains(id))
7757 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7758 )
7759 {
7760 let data = self
7761 .graphql(
7762 operation,
7763 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7764 )
7765 .await?;
7766 let root = if operation == graphql::ADD_BLOCKED_BY {
7767 "addBlockedBy"
7768 } else {
7769 "removeBlockedBy"
7770 };
7771 let issue =
7772 data.pointer(&format!("/{root}/issue"))
7773 .ok_or_else(|| SourceError::Malformed {
7774 message: "GitHub dependency update returned no issue".into(),
7775 })?;
7776 let blocker = data
7777 .pointer(&format!("/{root}/blockingIssue"))
7778 .ok_or_else(|| SourceError::Malformed {
7779 message: "GitHub dependency update returned no blocking issue".into(),
7780 })?;
7781 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7782 {
7783 return Err(SourceError::Malformed {
7784 message: "GitHub dependency update returned the wrong issues".into(),
7785 });
7786 }
7787 changed = true;
7788 }
7789 Ok(changed)
7790 }
7791}
7792
7793/// What a write needs to know of one far end it names: which kind of item it is, and whether
7794/// it is an issue a native relationship can name.
7795struct FarEnd {
7796 kind: BoardKind,
7797 content_kind: ContentKind,
7798}
7799
7800/// Whether the issue one write reconciles was created by that write or was already there.
7801#[derive(Clone, Copy, PartialEq, Eq)]
7802enum Issue<'a> {
7803 /// Created by this write, so it holds no relationships yet.
7804 Created,
7805 /// On the board before this write, holding whatever relationships it holds — the far
7806 /// ends of its whole `blockedBy`, when the read that reached it carried them.
7807 Existing(Option<&'a [Value]>),
7808}
7809
7810/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7811enum Reached {
7812 /// An issue this board holds, resolved into everything this source reports about it.
7813 Held(Box<Resolved>),
7814 /// Nothing this board holds: no such node, or a node on some other board.
7815 Nothing,
7816 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7817 /// again by [`GitHubProjectsSource::draft_by_id`].
7818 Draft,
7819}
7820
7821/// What GitHub says when a string is not a node id it can resolve.
7822///
7823/// Matched because it is the ordinary answer to a project selector naming a project by its
7824/// *name*, and reporting that as a failure would make naming one impossible. It is read
7825/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7826/// does not define the syntax of a GitHub node id and would be wrong about it.
7827const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7828
7829/// `error` with `note` added to the end of what it says, its kind and every other member
7830/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7831/// what that failure left behind.
7832fn noting(error: SourceError, note: &str) -> SourceError {
7833 match error {
7834 SourceError::Config { message } => SourceError::Config {
7835 message: message + note,
7836 },
7837 SourceError::Auth { message } => SourceError::Auth {
7838 message: message + note,
7839 },
7840 SourceError::Refused { message } => SourceError::Refused {
7841 message: message + note,
7842 },
7843 SourceError::RateLimited {
7844 retry_after_seconds,
7845 message,
7846 } => SourceError::RateLimited {
7847 retry_after_seconds,
7848 message: Some(message.unwrap_or_default() + note),
7849 },
7850 SourceError::Unavailable { message } => SourceError::Unavailable {
7851 message: message + note,
7852 },
7853 SourceError::Malformed { message } => SourceError::Malformed {
7854 message: message + note,
7855 },
7856 }
7857}
7858
7859/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7860/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7861/// for them.
7862///
7863/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7864/// is read again at no added price.
7865fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7866 let mut variables = serde_json::Map::new();
7867 for slot in 0..DETAIL_BATCH {
7868 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7869 variables.insert(format!("id{slot}"), json!(id));
7870 }
7871 variables.insert(
7872 "first".to_owned(),
7873 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7874 );
7875 variables.insert("comments".to_owned(), json!(comments.is_some()));
7876 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7877 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7878 variables.insert("duplicates".to_owned(), json!(true));
7879 Value::Object(variables)
7880}
7881
7882/// Whether this refusal is GitHub saying the id names no node at all.
7883fn unresolvable_node(error: &SourceError) -> bool {
7884 matches!(error, SourceError::Refused { message }
7885 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7886}
7887
7888/// One project name, as a search qualifier which filters on it at the server.
7889///
7890/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7891/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7892/// the way it documents. A title matched here is still compared for equality afterwards:
7893/// the qualifier narrows what the server sends, and this source decides what it names.
7894fn title_qualifier(name: &str) -> String {
7895 format!("in:title {}", quoted(name))
7896}
7897
7898/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7899/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7900/// it documents — so a value holding a qualifier's spelling is searched for rather than
7901/// obeyed.
7902fn quoted(value: &str) -> String {
7903 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7904 format!("\"{escaped}\"")
7905}
7906
7907/// The search qualifier for the issues updated at or after `since`.
7908///
7909/// Written to the second, rounded down, which can only widen what the search returns.
7910fn updated_qualifier(since: DateTime<Utc>) -> String {
7911 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7912}
7913
7914/// The search terms that narrow a board-scoped issue search to a task query's text and
7915/// metadata predicates, or `None` when it carries neither.
7916///
7917/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7918/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7919/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7920/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7921/// a metadata value searches both fields for both — wider than asked, never narrower, and
7922/// every candidate is confirmed in process afterwards.
7923///
7924/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7925/// matches whole tokens where a substring rule would match inside a word, so an item holding
7926/// the text only inside a longer word is not returned. A text of nothing but whitespace
7927/// matches every item, so it narrows nothing and is not sent.
7928fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7929 let text = query
7930 .text
7931 .as_ref()
7932 .filter(|text| !text.terms.trim().is_empty());
7933 if text.is_none() && query.metadata.is_empty() {
7934 return None;
7935 }
7936 let (title, body) = match text.map(|text| text.fields) {
7937 None => (false, true),
7938 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7939 Some(TextFields::Content) => (false, true),
7940 Some(TextFields::TitleOrContent) => (true, true),
7941 };
7942 let fields = match (title, body) {
7943 (true, true) => "in:title,body",
7944 (true, false) => "in:title",
7945 _ => "in:body",
7946 };
7947 let phrases = text
7948 .map(|text| text.terms.clone())
7949 .into_iter()
7950 .chain(
7951 query
7952 .metadata
7953 .iter()
7954 .map(|wanted| as_stored(wanted.value())),
7955 )
7956 .map(|phrase| quoted(&phrase))
7957 .collect::<Vec<_>>();
7958 Some(format!("{fields} {}", phrases.join(" ")))
7959}
7960
7961/// The search terms that narrow a board-scoped issue search to a project or document query's
7962/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
7963/// carrying that text alone is sent as by [`narrowing_qualifiers`].
7964fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
7965 narrowing_qualifiers(&TaskQuery {
7966 text: text.cloned(),
7967 ..TaskQuery::default()
7968 })
7969}
7970
7971/// Refuses a project or document query's text GitHub's issue search cannot find, before
7972/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
7973/// query's.
7974fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
7975 refuse_unsearchable(&TaskQuery {
7976 text: text.cloned(),
7977 ..TaskQuery::default()
7978 })
7979}
7980
7981/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7982/// before anything is asked of GitHub.
7983///
7984/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7985/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7986/// left out, the search is every issue of the board. So this source says it cannot answer
7987/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7988/// nothing GitHub could search for, and keeps the board read it always had.
7989fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7990 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7991 letter or digit with a bounded query";
7992 if let Some(text) = &query.text
7993 && !text.terms.trim().is_empty()
7994 && !has_words(&text.terms)
7995 {
7996 return Err(SourceError::Refused {
7997 message: format!(
7998 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7999 text.terms
8000 ),
8001 });
8002 }
8003 if let Some(wanted) = query
8004 .metadata
8005 .iter()
8006 .find(|wanted| !has_words(wanted.value()))
8007 {
8008 return Err(SourceError::Refused {
8009 message: format!(
8010 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8011 wanted.value(),
8012 std::iter::once(wanted.key())
8013 .chain(wanted.path().iter().map(String::as_str))
8014 .collect::<Vec<_>>()
8015 .join("/"),
8016 ),
8017 });
8018 }
8019 Ok(())
8020}
8021
8022/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8023fn has_words(phrase: &str) -> bool {
8024 phrase.chars().any(char::is_alphanumeric)
8025}
8026
8027/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8028///
8029/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8030/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8031/// which GitHub's word match would read as different words.
8032fn as_stored(value: &str) -> String {
8033 let encoded = Value::String(value.to_owned()).to_string();
8034 encoded[1..encoded.len() - 1].to_owned()
8035}
8036
8037/// The one narrower question a task query carrying a text, metadata or origin predicate is
8038/// sent as.
8039enum Narrowing {
8040 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8041 Origin(String),
8042 /// The board-scoped issue search narrowed by these qualifiers.
8043 Search(String),
8044}
8045
8046impl Narrowing {
8047 /// What this question is remembered under for the length of one command.
8048 fn key(&self) -> String {
8049 match self {
8050 Self::Origin(origin) => format!("origin {origin}"),
8051 Self::Search(also) => format!("search {also}"),
8052 }
8053 }
8054}
8055
8056/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8057enum Resumed {
8058 /// It reported another page, which starts after this cursor.
8059 More(String),
8060 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8061 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8062 /// can go on walking the other connection.
8063 Ended(Option<String>),
8064}
8065
8066impl Resumed {
8067 /// Whether the connection has another page.
8068 const fn has_more(&self) -> bool {
8069 matches!(self, Self::More(_))
8070 }
8071
8072 /// The cursor to send this connection next.
8073 fn cursor(self) -> Option<String> {
8074 match self {
8075 Self::More(next) => Some(next),
8076 Self::Ended(last) => last,
8077 }
8078 }
8079}
8080
8081/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8082/// with no cursor to it, or from a cursor that does not advance.
8083fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8084 let info = connection
8085 .get("pageInfo")
8086 .ok_or_else(|| SourceError::Malformed {
8087 message: "GitHub connection has no pageInfo".into(),
8088 })?;
8089 let end = optional_str(info, "endCursor")?;
8090 if required_bool(info, "hasNextPage")? {
8091 let next = end.ok_or_else(|| SourceError::Malformed {
8092 message: "GitHub connection reports another page and no endCursor".into(),
8093 })?;
8094 validate_cursor_progress(after, next)?;
8095 return Ok(Resumed::More(next.to_owned()));
8096 }
8097 Ok(Resumed::Ended(
8098 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8099 ))
8100}
8101
8102/// The board, and every item on it this source reports.
8103#[derive(Clone)]
8104struct Board {
8105 id: String,
8106 fields: Value,
8107 items: Vec<Resolved>,
8108}
8109
8110/// What a write needs of the board and nothing more: its node id and its field
8111/// definitions, in the shape a read of the board's own `fields` gives them.
8112///
8113/// Deliberately no items. A write decides which item it writes, which parent it files
8114/// under and which far ends it names by reading each of them by its own id; this is the
8115/// half of the board those reads cannot carry, and holding no item is what keeps it from
8116/// ever being asked whether an item is there.
8117#[derive(Clone)]
8118struct BoardFields {
8119 id: BoardId,
8120 fields: Value,
8121}
8122
8123/// A board's node id: what a field write and `addProjectV2ItemById` address.
8124///
8125/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8126/// refused where it is read, and one an item names blank is read as not named at all.
8127#[derive(Clone)]
8128struct BoardId(String);
8129
8130/// Where one write left its item, for the record the rest of the command reads it out of.
8131///
8132/// A named record rather than a tuple because the update arm and the create arm each fill
8133/// all four, and two `Option`s of different meaning side by side in a tuple are two
8134/// positions a reader has to count.
8135struct Landed {
8136 /// The issue's own node id, which is the [`NativeId`] this source reports.
8137 content_id: NativeId,
8138 /// The board item's id, which is what a field write addresses.
8139 // 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.
8140 item_id: String,
8141 /// The web address GitHub gave the issue, when it gave one.
8142 // 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.
8143 url: Option<String>,
8144 /// The issue's number on its repository, when GitHub reported one.
8145 number: Option<u64>,
8146}
8147
8148impl BoardId {
8149 fn parse(id: &str) -> Result<Self, SourceError> {
8150 if id.trim().is_empty() {
8151 return Err(SourceError::Malformed {
8152 message: "GitHub named a board with a blank node id".into(),
8153 });
8154 }
8155 Ok(Self(id.to_owned()))
8156 }
8157
8158 fn as_str(&self) -> &str {
8159 &self.0
8160 }
8161}
8162
8163impl Board {
8164 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8165 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8166 let nodes = fields
8167 .get("nodes")
8168 .and_then(Value::as_array)
8169 .ok_or_else(|| SourceError::Malformed {
8170 message: "GitHub project fields.nodes is not an array".into(),
8171 })?;
8172 Ok(nodes
8173 .iter()
8174 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8175 }
8176}
8177
8178/// One board item, resolved into everything this source reports about it.
8179#[derive(Clone)]
8180struct Resolved {
8181 item_id: String,
8182 id: NativeId,
8183 content_kind: ContentKind,
8184 kind: BoardKind,
8185 title: String,
8186 body: Option<String>,
8187 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8188 /// that changes the slot alone has to keep byte for byte outside it.
8189 raw_body: Option<String>,
8190 status: Status,
8191 /// The name of the board `Status` option this item sits in, as the board spells it.
8192 option: Option<String>,
8193 /// What its `Priority` field says, read through this instance's mapping.
8194 priority: HeldPriority,
8195 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8196 closed: bool,
8197 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8198 delivers: Vec<TaskRef>,
8199 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8200 /// task.
8201 delivered_by: Vec<TaskRef>,
8202 labels: Vec<Label>,
8203 parent: Option<NativeId>,
8204 // 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.
8205 origin: Option<String>,
8206 /// The issue's own number on its repository, as GitHub reports it.
8207 ///
8208 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8209 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8210 /// an issue this run created whose creating mutation answered without one, which is a
8211 /// response GitHub's own schema says cannot happen and which a landed write is not
8212 /// worth failing over. An `Issue` read off the board always has one.
8213 number: Option<u64>,
8214 url: Option<String>,
8215 created_at: Option<DateTime<Utc>>,
8216 updated_at: Option<DateTime<Utc>>,
8217 own_repository: Option<Repository>,
8218 repositories: Vec<Repository>,
8219 slot: BTreeMap<String, Value>,
8220 /// The node id of the board this item sits on, when the read that reached it said.
8221 board_id: Option<String>,
8222 /// The definition of every board field this item holds a value of, in the shape a read
8223 /// of the board's own `fields` gives one.
8224 ///
8225 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8226 /// which says nothing about whether the board has it.
8227 fields: Vec<Value>,
8228 /// Every field the board this item sits on defines, as its own read of the board's
8229 /// `fields` gives them — when the read that reached the item carried them, which a read
8230 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8231 /// of the board.
8232 board_fields: Option<Value>,
8233 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8234 /// selects one — when the read that reached it carried the connection to its end, which a
8235 /// read of it by its own id does for any issue blocked by no more than a page. What a
8236 /// write reconciles that relationship against, and what a read of its forward edges in
8237 /// the same command answers with.
8238 blocked_by: Option<Vec<Value>>,
8239}
8240
8241impl Resolved {
8242 /// The board this item's own read names it on, when that read named one this source can
8243 /// address.
8244 fn named_board(&self) -> Option<BoardId> {
8245 self.board_id
8246 .as_deref()
8247 .and_then(|id| BoardId::parse(id).ok())
8248 }
8249
8250 /// The board's id and every field it defines, when the read that reached this item
8251 /// carried both — which a read of it by its own id does.
8252 fn carried_board(&self) -> Option<BoardFields> {
8253 Some(BoardFields {
8254 id: self.named_board()?,
8255 fields: self.board_fields.clone()?,
8256 })
8257 }
8258
8259 /// Whether this item holds a value of the board field called `name`, and so carries
8260 /// that field's definition. `false` says nothing about whether the board has the field.
8261 fn defines(&self, name: &str) -> bool {
8262 self.fields
8263 .iter()
8264 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8265 }
8266
8267 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8268 /// in a field of its own, and none of the five keys that are only an encoding.
8269 ///
8270 /// The two delivery keys are left out for every kind, not only for a task: they are
8271 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8272 /// document carrying one holds nothing a caller's own metadata could mean by it.
8273 fn metadata(&self) -> BTreeMap<String, Value> {
8274 let mut metadata = self.slot.clone();
8275 metadata.remove(Repository::METADATA_KEY);
8276 metadata.remove(DependencyEdge::RECORDED_KEY);
8277 metadata.remove(ItemKind::METADATA_KEY);
8278 metadata.remove(TaskRef::DELIVERS_KEY);
8279 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8280 // The board field is the origin, and the body's copy of it is only a mirror for the
8281 // issue search to find: an item whose field holds none has none, whatever its body
8282 // says, so no reader ever sees two answers.
8283 metadata.remove(ORIGIN_KEY);
8284 if let Some(origin) = &self.origin {
8285 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8286 }
8287 metadata
8288 }
8289
8290 /// Where this item is, as a link a reader can open.
8291 ///
8292 /// A board is a hosted place and every issue on it has a web address, so that address
8293 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8294 /// of place it is, so a reader knows to open it rather than to read a file out. It
8295 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8296 /// reported before, and this says what that address *is*.
8297 ///
8298 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8299 /// rather than a third variant, which is the contract's "the source did not say". An
8300 /// issue this run created is not one of those: its address comes back from the
8301 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8302 /// rather than from whenever the board read catches up.
8303 fn location(&self) -> Option<Location> {
8304 self.url.clone().map(Location::Url)
8305 }
8306
8307 /// The short handle this board's backend shows people for a task: the issue's number
8308 /// alone, as a decimal string.
8309 ///
8310 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8311 /// value for this backend. A draft has no number and so no handle, which is the
8312 /// contract's *absent* rather than a handle of some other shape — and the native
8313 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8314 /// derives from.
8315 fn key(&self) -> Option<String> {
8316 self.number.map(|number| number.to_string())
8317 }
8318
8319 /// Whether its `Priority` field holds a value at all, mapped or not.
8320 fn holds_priority(&self) -> bool {
8321 self.priority != HeldPriority::Read(Priority::None)
8322 }
8323
8324 /// The task this item is.
8325 ///
8326 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8327 /// reading that as a level would be a guess, and reading it as `none` would let the next
8328 /// copy clear a priority a person set.
8329 fn task(&self) -> Result<Task, SourceError> {
8330 let priority = match &self.priority {
8331 HeldPriority::Read(priority) => *priority,
8332 HeldPriority::Unmapped(option) => {
8333 return Err(SourceError::Malformed {
8334 message: format!(
8335 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8336 this source's priority_mapping does not name, so its priority cannot be \
8337 read; next: name {option:?} under priority_mapping, or move the item to \
8338 a mapped option",
8339 self.id,
8340 self.number
8341 .map(|number| format!(" (#{number})"))
8342 .unwrap_or_default()
8343 ),
8344 });
8345 }
8346 };
8347 Ok(Task {
8348 id: self.id.clone(),
8349 key: self.key(),
8350 title: self.title.clone(),
8351 content: self.body.clone(),
8352 status: self.status.clone(),
8353 priority,
8354 labels: self.labels.clone(),
8355 project: self.parent.clone(),
8356 url: self.url.clone(),
8357 location: self.location(),
8358 created_at: self.created_at,
8359 updated_at: self.updated_at,
8360 metadata: self.metadata(),
8361 repositories: self.repositories.clone(),
8362 delivers: self.delivers.clone(),
8363 delivered_by: self.delivered_by.clone(),
8364 })
8365 }
8366
8367 fn project(&self) -> Project {
8368 Project {
8369 id: self.id.clone(),
8370 title: self.title.clone(),
8371 content: self.body.clone(),
8372 status: self.status.clone(),
8373 labels: self.labels.clone(),
8374 url: self.url.clone(),
8375 location: self.location(),
8376 created_at: self.created_at,
8377 updated_at: self.updated_at,
8378 metadata: self.metadata(),
8379 repositories: self.repositories.clone(),
8380 }
8381 }
8382
8383 /// The same issue as a document: the project it is filed under, and no status and no
8384 /// dependencies, because a document is not work.
8385 fn document(&self) -> Document {
8386 Document {
8387 id: self.id.clone(),
8388 title: self.title.clone(),
8389 content: self.body.clone(),
8390 project: self.parent.clone(),
8391 labels: self.labels.clone(),
8392 url: self.url.clone(),
8393 location: self.location(),
8394 created_at: self.created_at,
8395 updated_at: self.updated_at,
8396 metadata: self.metadata(),
8397 repositories: self.repositories.clone(),
8398 }
8399 }
8400}
8401
8402/// Where one targeted update moves an item's status, and which of its two halves move.
8403struct StatusMove {
8404 /// The board the item's `Status` field is on.
8405 board: BoardId,
8406 /// The `Status` field's id.
8407 field: String,
8408 /// The option's id.
8409 option: String,
8410 /// The option's name, as the board spells it.
8411 name: String,
8412 /// What the status asks of the issue's state.
8413 target: StatusTarget,
8414 /// The status the item reads as once it is there.
8415 landed: Status,
8416 /// Which of the status's two halves differ from what the item holds.
8417 moves: Moves,
8418}
8419
8420/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8421/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8422/// and is not a value of this type.
8423#[derive(Clone, Copy, PartialEq, Eq)]
8424enum Moves {
8425 /// The option alone.
8426 Option,
8427 /// The issue's state alone: open, closed, or closed with another reason.
8428 State,
8429 /// Both.
8430 Both,
8431}
8432
8433impl Moves {
8434 /// What differs, or `None` when nothing does.
8435 const fn of(option: bool, state: bool) -> Option<Self> {
8436 match (option, state) {
8437 (true, true) => Some(Self::Both),
8438 (true, false) => Some(Self::Option),
8439 (false, true) => Some(Self::State),
8440 (false, false) => None,
8441 }
8442 }
8443
8444 /// Whether the option moves.
8445 const fn option(self) -> bool {
8446 matches!(self, Self::Option | Self::Both)
8447 }
8448
8449 /// Whether the issue's state moves.
8450 const fn state(self) -> bool {
8451 matches!(self, Self::State | Self::Both)
8452 }
8453}
8454
8455/// What one write is, and the status that comes with being it.
8456///
8457/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8458/// status and a task or a project always has one, so "a document carrying a status" and
8459/// "a task carrying none" are states a write cannot be in rather than states every use
8460/// site below has to defend against.
8461enum Written<'a> {
8462 /// A document, which is not work and so has no status at all.
8463 Document,
8464 /// A task or a project, and the status it is being written with.
8465 Work(ItemKind, &'a Status),
8466}
8467
8468impl Written<'_> {
8469 /// Which of the board's three kinds this write is.
8470 const fn kind(&self) -> BoardKind {
8471 match self {
8472 Self::Document => BoardKind::Document,
8473 Self::Work(kind, _) => BoardKind::Work(*kind),
8474 }
8475 }
8476
8477 /// The status this write carries. A document carries none, so a write of one says
8478 /// nothing about the issue's open or closed state and selects no board `Status`
8479 /// option.
8480 const fn status(&self) -> Option<&Status> {
8481 match self {
8482 Self::Document => None,
8483 Self::Work(_, status) => Some(status),
8484 }
8485 }
8486}
8487
8488/// The item being written, in the one shape all three write methods reach.
8489struct Incoming<'a> {
8490 written: Written<'a>,
8491 /// The title a person wrote. A document's goes onto the issue with
8492 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8493 title: &'a str,
8494 content: Option<&'a str>,
8495 labels: &'a [Label],
8496 metadata: &'a BTreeMap<String, Value>,
8497 repositories: &'a [Repository],
8498 parent: Option<&'a NativeId>,
8499 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8500 /// what keeps either key out of their slot.
8501 delivers: &'a [TaskRef],
8502 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8503 delivered_by: &'a [TaskRef],
8504 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8505 /// project, a document, and every write to an instance with no `priority_mapping` —
8506 /// which is what keeps such a write's requests exactly what they were before.
8507 priority: Option<Priority>,
8508}
8509
8510/// What one write does to an item's `Priority` field.
8511enum PriorityWrite {
8512 /// Select this option of this field.
8513 Select {
8514 /// The `Priority` field's id.
8515 field: String,
8516 /// The mapped option's id.
8517 option: String,
8518 },
8519 /// Clear the field's value, which is what `none` is.
8520 Clear {
8521 /// The `Priority` field's id.
8522 field: String,
8523 },
8524}
8525
8526impl Incoming<'_> {
8527 /// The title this write puts on the issue.
8528 fn written_title(&self) -> String {
8529 match self.written {
8530 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8531 Written::Work(..) => self.title.to_owned(),
8532 }
8533 }
8534}
8535
8536#[derive(Clone, Copy, PartialEq, Eq)]
8537enum ContentKind {
8538 DraftIssue,
8539 Issue,
8540}
8541
8542/// What one board issue is: a document, or the work an [`ItemKind`] names.
8543///
8544/// A type of this source's own rather than an `ItemKind` with a third variant, because
8545/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8546/// document — the contract keeps a document out of that enum deliberately. Holding the
8547/// board's three answers in one value is what makes every place that asks "which is this?"
8548/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8549/// two thirds of the board.
8550#[derive(Clone, Copy, PartialEq, Eq)]
8551enum BoardKind {
8552 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8553 Document,
8554 /// Every other issue, and every draft.
8555 Work(ItemKind),
8556}
8557
8558impl BoardKind {
8559 /// How a refusal names this kind to the person reading it.
8560 const fn describes(self) -> &'static str {
8561 match self {
8562 Self::Document => "document",
8563 Self::Work(kind) => kind.marker(),
8564 }
8565 }
8566}
8567
8568/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8569///
8570/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8571/// the shared cross-source journeys assert one answer to one question, so two sources
8572/// that disagree about what "carries the label bug" means fail them.
8573fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8574 let holds = |name: &String| {
8575 labels
8576 .iter()
8577 .any(|label| label.name.eq_ignore_ascii_case(name))
8578 };
8579 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8580 && filter.all_of.iter().all(holds)
8581 && !filter.none_of.iter().any(holds)
8582}
8583
8584/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8585/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8586fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8587 statuses.is_empty() || statuses.contains(&category)
8588}
8589
8590/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8591///
8592/// `content` is the item's own prose — the body with this source's trailing metadata
8593/// comment already taken off — so a search never matches an encoding the author of the
8594/// issue never wrote.
8595fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8596 let terms = query.terms.to_lowercase();
8597 let in_title = title.to_lowercase().contains(&terms);
8598 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8599 match query.fields {
8600 TextFields::Title => in_title,
8601 TextFields::Content => in_content,
8602 TextFields::TitleOrContent => in_title || in_content,
8603 }
8604}
8605
8606/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8607///
8608/// The project predicate is passed separately because a read narrowed to one project has
8609/// already answered it by asking *that project* for its own items — and re-applying it
8610/// there would compare the caller's selector, which may be a project's **name**, against
8611/// the id of the project that name resolved to, and keep nothing. Every other read passes
8612/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8613/// source really does apply.
8614fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8615 labels_match(&task.labels, &query.labels)
8616 && status_matches(task.status.category, &query.statuses)
8617 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8618 && match project {
8619 ProjectFilter::Any => true,
8620 ProjectFilter::Orphans => task.project.is_none(),
8621 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8622 }
8623 && query
8624 .text
8625 .as_ref()
8626 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8627 // Against the parsed metadata slot, and against the origin field, which is where
8628 // `Resolved::metadata` reads each of them from.
8629 && query.metadata_matches(&task.metadata)
8630 && query.origin_matches(&task.metadata)
8631}
8632
8633fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8634 labels_match(&project.labels, &query.labels)
8635 && status_matches(project.status.category, &query.statuses)
8636 && query
8637 .text
8638 .as_ref()
8639 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8640}
8641
8642/// The same three predicates a task query carries, minus the status filter.
8643///
8644/// A document is not work, so it has no status for one to compare against and the query
8645/// type carries none. The project predicate is the same one — a design issue filed under a
8646/// project issue is in that project, and one filed under nothing is in none — so it is
8647/// spelled the same way here rather than answered differently.
8648fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8649 labels_match(&document.labels, &query.labels)
8650 && match project {
8651 ProjectFilter::Any => true,
8652 ProjectFilter::Orphans => document.project.is_none(),
8653 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8654 }
8655 && query
8656 .text
8657 .as_ref()
8658 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8659}
8660
8661#[async_trait::async_trait]
8662impl TaskSource for GitHubProjectsSource {
8663 fn kind(&self) -> &'static str {
8664 KIND
8665 }
8666 fn capabilities(&self) -> Capabilities {
8667 Capabilities {
8668 projects: Support::Native,
8669 documents: Support::Native,
8670 comments: Support::Native,
8671 priority: if self.priorities.is_some() {
8672 Support::Native
8673 } else {
8674 Support::Unsupported
8675 },
8676 filter_by_priority: Support::Native,
8677 filter_by_comment_activity: Support::Native,
8678 filter_by_metadata: Support::Native,
8679 filter_by_origin: Support::Native,
8680 orphan_tasks: Support::Native,
8681 filter_by_label: Support::Native,
8682 filter_by_status: Support::Native,
8683 search_title: Support::Native,
8684 search_content: Support::Native,
8685 task_dependencies: DependencySupport::BothDirections,
8686 project_dependencies: DependencySupport::BothDirections,
8687 max_page_size: MAX_PAGE_SIZE,
8688 }
8689 }
8690 async fn health(&self) -> Result<Health, SourceError> {
8691 let board = self.board_page(None, 1).await?;
8692 Ok(Health {
8693 reachable: true,
8694 detail: Some(format!(
8695 "reading GitHub project {}/{} ({})",
8696 self.owner,
8697 self.project_number,
8698 required_str(&board, "title")?
8699 )),
8700 })
8701 }
8702 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8703 self.item_by_id(id)
8704 .await?
8705 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8706 .map(|item| item.task())
8707 .transpose()
8708 }
8709 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8710 Ok(self
8711 .item_by_id(id)
8712 .await?
8713 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8714 .map(|item| item.project()))
8715 }
8716 async fn query_tasks(
8717 &self,
8718 query: &TaskQuery,
8719 page: &PageRequest,
8720 ) -> Result<Page<Task>, SourceError> {
8721 validate_page(page)?;
8722 refuse_unsearchable(query)?;
8723 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8724 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8725 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8726 (Some(also), None) => Some(also),
8727 (None, Some(since)) => Some(updated_qualifier(since)),
8728 (None, None) => None,
8729 };
8730 if let Some(also) = qualifiers {
8731 return self.search_tasks(query, page, &also).await;
8732 }
8733 }
8734
8735 // A read narrowed to one project asks that project for its own tasks, so nothing
8736 // about it costs what the rest of the board holds. A read carrying a text, metadata
8737 // or origin predicate asks GitHub the narrower question those predicates are, and a
8738 // read narrowed to comment activity alone asks the board's own issue search for the
8739 // issues updated since, which is every issue a comment could have been written or
8740 // edited on since. Every other task read is a question about the whole board and is
8741 // answered by reading it.
8742 let (held, membership) = match (&query.project, query.commented_since) {
8743 (ProjectFilter::Is(project), _) => (
8744 self.project_children(project).await?,
8745 // Answered by where these items came from; see `task_matches`.
8746 &ProjectFilter::Any,
8747 ),
8748 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8749 match (self.narrowed(query).await?, since) {
8750 (Some(narrowed), _) => (narrowed, &query.project),
8751 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8752 (None, None) => (self.board().await?.items, &query.project),
8753 }
8754 }
8755 };
8756 // Filtered before paged: a page of a filtered result is a page of the survivors,
8757 // never the survivors of a page.
8758 let mut tasks = Vec::new();
8759 for item in held
8760 .iter()
8761 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8762 {
8763 let task = item.task()?;
8764 if task_matches(&task, query, membership)
8765 && self.commented_since(item, query.commented_since).await?
8766 {
8767 tasks.push(task);
8768 }
8769 }
8770 Ok(offset_page(
8771 tasks,
8772 numeric_cursor(page.cursor.as_ref())?,
8773 page.limit.min(MAX_PAGE_SIZE) as usize,
8774 ))
8775 }
8776 async fn query_projects(
8777 &self,
8778 query: &ProjectQuery,
8779 page: &PageRequest,
8780 ) -> Result<Page<Project>, SourceError> {
8781 validate_page(page)?;
8782 refuse_unsearchable_text(query.text.as_ref())?;
8783 // The projects a board holds are found by an issue search scoped to that board,
8784 // never by walking the board's own item connection: what tells a project from a
8785 // task is the `parent` each issue carries, which costs nothing to read. A query
8786 // carrying a text asks that search for the text too, so it reads the issues that
8787 // hold it rather than every issue of the board.
8788 let held = match self.text_searched(query.text.as_ref()).await? {
8789 Some(searched) => searched,
8790 None => self.board_issues().await?,
8791 };
8792 let projects = held
8793 .iter()
8794 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8795 .map(Resolved::project)
8796 .filter(|project| project_matches(project, query))
8797 .collect();
8798 Ok(offset_page(
8799 projects,
8800 numeric_cursor(page.cursor.as_ref())?,
8801 page.limit.min(MAX_PAGE_SIZE) as usize,
8802 ))
8803 }
8804 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8805 Ok(self
8806 .item_by_id(id)
8807 .await?
8808 .filter(|item| item.kind == BoardKind::Document)
8809 .map(|item| item.document()))
8810 }
8811 async fn query_documents(
8812 &self,
8813 query: &DocumentQuery,
8814 page: &PageRequest,
8815 ) -> Result<Page<Document>, SourceError> {
8816 validate_page(page)?;
8817 // Narrowed to one project, this is the same sub-issue read a task list scoped to
8818 // that project makes — a document filed under a project is a sub-issue of it too,
8819 // and which of them come back is the kind this caller asked for. Unscoped, a query
8820 // carrying a text asks the board-scoped issue search for it, as a task query does,
8821 // and only one carrying none reads the board.
8822 let (held, membership) = match &query.project {
8823 ProjectFilter::Is(project) => (
8824 self.project_children(project).await?,
8825 // Answered by where these items came from; see `task_matches`.
8826 &ProjectFilter::Any,
8827 ),
8828 ProjectFilter::Any | ProjectFilter::Orphans => {
8829 refuse_unsearchable_text(query.text.as_ref())?;
8830 match self.text_searched(query.text.as_ref()).await? {
8831 Some(searched) => (searched, &query.project),
8832 None => (self.board().await?.items, &query.project),
8833 }
8834 }
8835 };
8836 // Filtered before paged, exactly as a task read is: a page of a filtered result is
8837 // a page of the survivors, never the survivors of a page.
8838 let documents = held
8839 .iter()
8840 .filter(|item| item.kind == BoardKind::Document)
8841 .map(Resolved::document)
8842 .filter(|document| document_matches(document, query, membership))
8843 .collect();
8844 Ok(offset_page(
8845 documents,
8846 numeric_cursor(page.cursor.as_ref())?,
8847 page.limit.min(MAX_PAGE_SIZE) as usize,
8848 ))
8849 }
8850 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8851 validate_page(page)?;
8852 let offset = numeric_cursor(page.cursor.as_ref())?;
8853 let mut labels = self
8854 .board()
8855 .await?
8856 .items
8857 .into_iter()
8858 .flat_map(|item| item.labels)
8859 .fold(Vec::new(), |mut all, label| {
8860 if !all.iter().any(|x: &Label| x.id == label.id) {
8861 all.push(label);
8862 }
8863 all
8864 });
8865 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8866 Ok(offset_page(
8867 labels,
8868 offset,
8869 page.limit.min(MAX_PAGE_SIZE) as usize,
8870 ))
8871 }
8872 async fn task_dependencies(
8873 &self,
8874 id: &NativeId,
8875 direction: Direction,
8876 page: &PageRequest,
8877 ) -> Result<Page<DependencyEdge>, SourceError> {
8878 self.dependencies(id, ItemKind::Task, direction, page).await
8879 }
8880 async fn project_dependencies(
8881 &self,
8882 id: &NativeId,
8883 direction: Direction,
8884 page: &PageRequest,
8885 ) -> Result<Page<DependencyEdge>, SourceError> {
8886 self.dependencies(id, ItemKind::Project, direction, page)
8887 .await
8888 }
8889
8890 fn writes(&self) -> WriteSupport {
8891 WriteSupport::Supported
8892 }
8893
8894 /// Create or update one task.
8895 ///
8896 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8897 /// neither may name the task itself or name one task twice — and land in the body's
8898 /// metadata slot under their reserved keys, in place of any caller metadata of those
8899 /// names.
8900 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8901 let near = write.target.as_ref().unwrap_or(&write.item.id);
8902 for (key, entries) in [
8903 (TaskRef::DELIVERS_KEY, &write.item.delivers),
8904 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8905 ] {
8906 TaskRef::listed(key, near, Some(&self.name), entries.clone())
8907 .map_err(|message| SourceError::Refused { message })?;
8908 }
8909 if self.priorities.is_none() && write.item.priority != Priority::None {
8910 return Err(self.holds_no_priority());
8911 }
8912 self.write_item(
8913 &Incoming {
8914 written: Written::Work(ItemKind::Task, &write.item.status),
8915 title: &write.item.title,
8916 content: write.item.content.as_deref(),
8917 labels: &write.item.labels,
8918 metadata: &write.item.metadata,
8919 repositories: &write.item.repositories,
8920 parent: write.item.project.as_ref(),
8921 delivers: &write.item.delivers,
8922 delivered_by: &write.item.delivered_by,
8923 priority: self.priorities.as_ref().map(|_| write.item.priority),
8924 },
8925 write.target.as_ref(),
8926 &write.depends_on,
8927 )
8928 .await
8929 }
8930
8931 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8932 self.write_item(
8933 &Incoming {
8934 written: Written::Work(ItemKind::Project, &write.item.status),
8935 title: &write.item.title,
8936 content: write.item.content.as_deref(),
8937 labels: &write.item.labels,
8938 metadata: &write.item.metadata,
8939 repositories: &write.item.repositories,
8940 parent: None,
8941 delivers: &[],
8942 delivered_by: &[],
8943 priority: None,
8944 },
8945 write.target.as_ref(),
8946 &write.depends_on,
8947 )
8948 .await
8949 }
8950
8951 /// Create or update one document, which is one issue titled the way this board spells
8952 /// a document.
8953 ///
8954 /// Everything else is exactly a task write: caller metadata goes to the same canonical
8955 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8956 /// or a field this board cannot carry is refused by name rather than dropped, a target
8957 /// naming an issue this board does not hold is refused rather than created, and an
8958 /// issue this call created is taken back when the rest of the write fails.
8959 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8960 // A document takes part in no dependency graph, so there is no far end to write
8961 // natively and none to record: a caller naming one is told so rather than having it
8962 // stored under the reserved key, where a later read would report an edge the
8963 // contract says cannot exist.
8964 if !write.depends_on.is_empty() {
8965 return Err(SourceError::Refused {
8966 message: format!(
8967 "this write names {} dependencies for a document, and a document takes \
8968 part in no dependency graph; next: put the dependency on the task or \
8969 project the document is about",
8970 write.depends_on.len()
8971 ),
8972 });
8973 }
8974 self.write_item(
8975 &Incoming {
8976 written: Written::Document,
8977 title: &write.item.title,
8978 content: write.item.content.as_deref(),
8979 labels: &write.item.labels,
8980 metadata: &write.item.metadata,
8981 repositories: &write.item.repositories,
8982 parent: write.item.project.as_ref(),
8983 delivers: &[],
8984 delivered_by: &[],
8985 priority: None,
8986 },
8987 write.target.as_ref(),
8988 &[],
8989 )
8990 .await
8991 }
8992
8993 /// Set one task's status alone.
8994 ///
8995 /// An open target reopens a closed issue with an `updateIssue` carrying only its
8996 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8997 /// terminal target selects its mapped option, then closes with its fixed reason. No
8998 /// request carries a title, a body or a label. The status
8999 /// answered is what [`StatusMapping::status`] reads off the state just written, which is
9000 /// what a re-read reports.
9001 async fn set_task_status(
9002 &self,
9003 id: &NativeId,
9004 category: StatusCategory,
9005 ) -> Result<Option<Status>, SourceError> {
9006 self.set_status(id, category).await
9007 }
9008
9009 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9010 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9011 /// for `none`. Refused by an instance with no `priority_mapping`.
9012 async fn set_task_priority(
9013 &self,
9014 id: &NativeId,
9015 priority: Priority,
9016 ) -> Result<Option<Priority>, SourceError> {
9017 self.set_priority(id, priority).await
9018 }
9019
9020 /// Replace one task's content with a single body update that keeps the metadata slot
9021 /// byte for byte.
9022 async fn set_task_content(
9023 &self,
9024 id: &NativeId,
9025 content: &str,
9026 ) -> Result<Option<()>, SourceError> {
9027 self.replace_content(id, content).await
9028 }
9029
9030 /// Replace one task issue's content and its provenance slot entry with a single body
9031 /// update. The answers are not kept: see `replace_rendering`.
9032 async fn set_task_rendering(
9033 &self,
9034 id: &NativeId,
9035 content: &str,
9036 provenance: &Value,
9037 _answers: &BTreeMap<String, Value>,
9038 ) -> Result<Option<()>, SourceError> {
9039 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9040 .await
9041 }
9042
9043 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9044 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9045 async fn set_document_rendering(
9046 &self,
9047 id: &NativeId,
9048 content: &str,
9049 provenance: &Value,
9050 _answers: &BTreeMap<String, Value>,
9051 ) -> Result<Option<()>, SourceError> {
9052 self.replace_rendering(id, BoardKind::Document, content, provenance)
9053 .await
9054 }
9055
9056 /// Apply a targeted update with one read of the item and a write only for what differs:
9057 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9058 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9059 async fn update_task(
9060 &self,
9061 id: &NativeId,
9062 update: &TaskUpdate,
9063 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9064 self.targeted_update(id, update).await
9065 }
9066
9067 /// Replace one task's `delivered_by` with a single body update that changes the
9068 /// metadata slot and nothing outside it.
9069 async fn set_delivered_by(
9070 &self,
9071 id: &NativeId,
9072 delivered_by: &[TaskRef],
9073 ) -> Result<Option<()>, SourceError> {
9074 self.replace_delivered_by(id, delivered_by).await
9075 }
9076
9077 /// Set one key of one task issue's metadata with a single body update that changes the
9078 /// metadata slot and nothing outside it — no title, label, state or board field request —
9079 /// and sends nothing when the task already holds that value under the key.
9080 async fn set_task_metadata(
9081 &self,
9082 id: &NativeId,
9083 key: &MetadataKey,
9084 value: &Value,
9085 ) -> Result<Option<Task>, SourceError> {
9086 Ok(self
9087 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9088 .await?
9089 .map(|item| item.task())
9090 .transpose()?)
9091 }
9092
9093 /// Set one key of one project issue's metadata, on exactly the terms of
9094 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9095 async fn set_project_metadata(
9096 &self,
9097 id: &NativeId,
9098 key: &MetadataKey,
9099 value: &Value,
9100 ) -> Result<Option<Project>, SourceError> {
9101 Ok(self
9102 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9103 .await?
9104 .map(|item| item.project()))
9105 }
9106
9107 /// Set one key of one design-document issue's metadata, on exactly the terms of
9108 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9109 async fn set_document_metadata(
9110 &self,
9111 id: &NativeId,
9112 key: &MetadataKey,
9113 value: &Value,
9114 ) -> Result<Option<Document>, SourceError> {
9115 Ok(self
9116 .set_slot_key(id, BoardKind::Document, key, value)
9117 .await?
9118 .map(|item| item.document()))
9119 }
9120
9121 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9122 self.delete_item(id).await
9123 }
9124
9125 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9126 self.delete_item(id).await
9127 }
9128
9129 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9130 self.delete_item(id).await
9131 }
9132
9133 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9134 ///
9135 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9136 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9137 ///
9138 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9139 /// board is the read of its comments. A draft this process already resolved is refused
9140 /// without one.
9141 async fn task_comments(
9142 &self,
9143 task: &NativeId,
9144 page: &PageRequest,
9145 ) -> Result<Option<Page<Comment>>, SourceError> {
9146 validate_page(page)?;
9147 let cached = self.resolved_cache()?.get(task).cloned();
9148 if let Some(item) = cached {
9149 if item.kind != BoardKind::Work(ItemKind::Task) {
9150 return Ok(None);
9151 }
9152 if item.content_kind == ContentKind::DraftIssue {
9153 return Err(self.draft_has_no_comments(task));
9154 }
9155 }
9156 match self.issue_detail(task, page).await? {
9157 Some(TaskDetailRead {
9158 comments: Some(comments),
9159 ..
9160 }) => comments,
9161 _ => Ok(None),
9162 }
9163 }
9164
9165 /// Every id's task, with the first page of its comments when `comments` names it:
9166 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9167 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9168 async fn get_task_details(
9169 &self,
9170 ids: &[NativeId],
9171 comments: Option<&PageRequest>,
9172 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9173 if let Some(page) = comments
9174 && let Err(error) = validate_page(page)
9175 {
9176 return ids.iter().map(|_| Err(error.clone())).collect();
9177 }
9178 match (ids, comments) {
9179 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9180 ([id], None) => vec![self.task_read(id).await],
9181 _ => self.issue_details(ids, comments).await,
9182 }
9183 }
9184
9185 /// Add one comment to the task's issue, as the account the token belongs to.
9186 ///
9187 /// The author is refused before anything is sent — not even the task is read — because
9188 /// no answer GitHub could give would make posting under another name than the one asked
9189 /// for the right outcome.
9190 async fn add_comment(
9191 &self,
9192 task: &NativeId,
9193 comment: &NewComment,
9194 ) -> Result<Option<Comment>, SourceError> {
9195 if let Some(author) = &comment.author {
9196 return Err(SourceError::Refused {
9197 message: format!(
9198 "source {} cannot post a comment as {author:?}: GitHub records the account \
9199 the token signs in as the author of every comment; next: leave --author \
9200 out, and the comment is posted as that account",
9201 self.name
9202 ),
9203 });
9204 }
9205 let Some(issue) = self.commented_issue(task).await? else {
9206 return Ok(None);
9207 };
9208 let data = self
9209 .graphql(
9210 graphql::ADD_COMMENT,
9211 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9212 )
9213 .await?;
9214 let subject = data
9215 .pointer("/addComment/subject")
9216 .filter(|value| !value.is_null())
9217 .ok_or_else(|| SourceError::Malformed {
9218 message: "GitHub comment addition returned no subject".into(),
9219 })?;
9220 if required_str(subject, "id")? != issue.0 {
9221 return Err(SourceError::Malformed {
9222 message: "GitHub comment addition answered about another issue".into(),
9223 });
9224 }
9225 let added = data
9226 .pointer("/addComment/commentEdge/node")
9227 .filter(|value| !value.is_null())
9228 .ok_or_else(|| SourceError::Malformed {
9229 message: "GitHub comment addition returned no comment".into(),
9230 })?;
9231 comment_from(added).map(Some)
9232 }
9233
9234 async fn edit_comment(
9235 &self,
9236 task: &NativeId,
9237 comment: &NativeId,
9238 body: &CommentBody,
9239 ) -> Result<Option<Comment>, SourceError> {
9240 let Some(issue) = self.commented_issue(task).await? else {
9241 return Ok(None);
9242 };
9243 if !self.comment_is_on(&issue, comment).await? {
9244 return Ok(None);
9245 }
9246 let data = self
9247 .graphql(
9248 graphql::UPDATE_COMMENT,
9249 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9250 )
9251 .await?;
9252 let edited = data
9253 .pointer("/updateIssueComment/issueComment")
9254 .filter(|value| !value.is_null())
9255 .ok_or_else(|| SourceError::Malformed {
9256 message: "GitHub comment update returned no comment".into(),
9257 })?;
9258 let edited = comment_from(edited)?;
9259 if edited.id != *comment {
9260 return Err(SourceError::Malformed {
9261 message: "GitHub comment update returned the wrong comment".into(),
9262 });
9263 }
9264 Ok(Some(edited))
9265 }
9266
9267 async fn delete_comment(
9268 &self,
9269 task: &NativeId,
9270 comment: &NativeId,
9271 ) -> Result<Option<NativeId>, SourceError> {
9272 let Some(issue) = self.commented_issue(task).await? else {
9273 return Ok(None);
9274 };
9275 if !self.comment_is_on(&issue, comment).await? {
9276 return Ok(None);
9277 }
9278 let data = self
9279 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9280 .await?;
9281 // The payload says nothing about the comment it removed, so what is checked is that
9282 // GitHub answered the mutation at all rather than leaving it unanswered.
9283 data.get("deleteIssueComment")
9284 .filter(|value| !value.is_null())
9285 .ok_or_else(|| SourceError::Malformed {
9286 message: "GitHub comment deletion returned no payload".into(),
9287 })?;
9288 Ok(Some(comment.clone()))
9289 }
9290
9291 /// Every request this source has recorded, and what each of GitHub's two budgets was
9292 /// attributed — read off the same accounting the session report is rendered from, so
9293 /// the two cannot count one request two ways.
9294 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9295 Ok(Some(self.ledger.snapshot().metering()))
9296 }
9297}
9298
9299/// One issue comment as the contract carries it.
9300///
9301/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9302/// and when it answers an actor with no login, because either way the source did not say who
9303/// wrote it — which is what an absent author means, rather than an author called nothing.
9304fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9305 Ok(Comment {
9306 id: NativeId(required_str(value, "id")?.to_owned()),
9307 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9308 .map(str::to_owned),
9309 created_at: optional_time(value, "createdAt")?,
9310 updated_at: optional_time(value, "updatedAt")?,
9311 body: required_str(value, "body")?.to_owned(),
9312 url: optional_str(value, "url")?.map(str::to_owned),
9313 })
9314}
9315
9316/// The page of comments one issue node carries, resumed from `after`.
9317fn comment_page(
9318 node: &Value,
9319 issue: &str,
9320 after: Option<&str>,
9321) -> Result<Page<Comment>, SourceError> {
9322 let connection = node
9323 .get("comments")
9324 .filter(|value| !value.is_null())
9325 .ok_or_else(|| SourceError::Malformed {
9326 message: format!("GitHub issue {issue} answered with no comments connection"),
9327 })?;
9328 let items = optional_nodes(Some(connection), "issue comments")?
9329 .into_iter()
9330 .flatten()
9331 .map(comment_from)
9332 .collect::<Result<Vec<_>, _>>()?;
9333 let next = next_cursor(connection)?;
9334 if let Some(next) = &next {
9335 validate_cursor_progress(after, &next.0)?;
9336 }
9337 Ok(Page { items, next })
9338}
9339
9340/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9341/// end — `None` when it carried none, or a page with more past it.
9342fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9343 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9344 return Ok(None);
9345 };
9346 if next_cursor(connection)?.is_some() {
9347 return Ok(None);
9348 }
9349 Ok(Some(
9350 optional_nodes(Some(connection), "blocked-by issues")?
9351 .into_iter()
9352 .flatten()
9353 .cloned()
9354 .collect(),
9355 ))
9356}
9357
9358/// Where the recorded tail of a dependency walk resumes; see
9359/// [`GitHubProjectsSource::recorded_edges`].
9360const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9361
9362/// The board text field this source keeps a copy's origin in.
9363///
9364/// Named after the key it holds, and held to that name by the guard below rather than by
9365/// a reader noticing.
9366const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9367
9368/// The metadata key that field holds.
9369///
9370/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9371/// constructs or interprets the qualified id it carries. This source names it only to
9372/// route it — a short, typed value belongs in a typed field rather than in the body slot
9373/// a caller's own prose shares.
9374///
9375/// Restated rather than imported, because no plugin crate may depend on the engine. What
9376/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9377/// target in `check`: it reads the engine's own literal and fails naming the file and the
9378/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9379/// that creates a second item every run instead of finding the one it wrote — and that is
9380/// too late to learn it.
9381const ORIGIN_KEY: &str = "onetaskgraph.origin";
9382
9383/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9384///
9385/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9386/// is derived from the far end, never written down on the near item — so only a forward
9387/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9388/// it did not come from, and it is told so rather than answered with an empty page that
9389/// reads as a walk which ended.
9390fn recorded_offset(
9391 cursor: Option<&str>,
9392 direction: Direction,
9393) -> Result<Option<usize>, SourceError> {
9394 cursor
9395 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9396 .map(|offset| {
9397 if direction != Direction::DependsOn {
9398 return Err(SourceError::Config {
9399 message: format!(
9400 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9401 reverse dependency read never issues; resume it in the direction \
9402 that reported it"
9403 ),
9404 });
9405 }
9406 offset.parse().map_err(|_| SourceError::Config {
9407 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9408 })
9409 })
9410 .transpose()
9411}
9412
9413fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9414 let mut page = offset_page(edges, offset, limit.max(1));
9415 page.next = page
9416 .next
9417 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9418 page
9419}
9420
9421/// The kind of one issue reached through a dependency connection.
9422///
9423/// The same questions the board scan asks, over the fields the dependency document
9424/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9425/// then anything with sub-issues or the marker is a project.
9426///
9427/// # Errors
9428///
9429/// A far end this board holds as a document is refused rather than reported. The two
9430/// answers that are not refusals would both be wrong: reporting it as a task names an id
9431/// no task read of this source can find, and reporting it as a project names one no
9432/// project read can. There is no third value to return — `ItemKind` has no document
9433/// variant, because nothing may point at a document — so the relationship itself is what
9434/// the person is told about.
9435fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9436 let id = required_str(value, "id")?;
9437 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9438 return Err(SourceError::Refused {
9439 message: format!(
9440 "GitHub issue {id} is a document of this board — its title begins \
9441 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9442 on by one; next: remove that issue's blocking relationship on this board"
9443 ),
9444 });
9445 }
9446 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9447 if parent.is_some() {
9448 return Ok(ItemKind::Task);
9449 }
9450 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9451 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9452 message: format!("GitHub issue {id}: {message}"),
9453 })?;
9454 let sub_issues = sub_issue_total(value)?;
9455 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9456 ItemKind::Project
9457 } else {
9458 ItemKind::Task
9459 })
9460}
9461
9462/// The `IssueStateUpdateInput` one status target asks for.
9463///
9464/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9465/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9466/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9467/// would report a change forever. A document has no status at all, and asks for neither.
9468fn state_input(target: Option<&StatusTarget>) -> Value {
9469 match target {
9470 Some(StatusTarget::Terminal(_, reason)) => {
9471 json!({"value":"CLOSED","stateReason":reason.reason()})
9472 }
9473 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
9474 // A document has no status, so a write of one says nothing about the issue's open
9475 // or closed state rather than forcing it open: `stateInput` is what carries that
9476 // instruction, and an explicit null asks for no change to it.
9477 None => Value::Null,
9478 }
9479}
9480
9481/// The metadata one write stores in the item's body slot.
9482///
9483/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9484/// rather than carried: the kind marker so an empty project stays readable, the
9485/// repository list only when it is not exactly the issue's own repository, and the far
9486/// ends no relationship here can name.
9487///
9488/// The copy origin is the one typed field that is also mirrored here, and only as a
9489/// mirror: it lands in the board's origin field as well, which stays the one every reader
9490/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9491/// and catches up with a write in seconds rather than minutes — can find the item by it.
9492/// A reader of the release before this one drops the slot's copy and reads the field, so an
9493/// item written here still reads with exactly one origin there.
9494fn slot_metadata(
9495 incoming: &Incoming<'_>,
9496 own_repository: Option<&Repository>,
9497 fallback: &[DependencyEdge],
9498) -> BTreeMap<String, Value> {
9499 let mut metadata = incoming.metadata.clone();
9500 match metadata.remove(ORIGIN_KEY) {
9501 Some(Value::String(origin)) if !origin.is_empty() => {
9502 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9503 }
9504 _ => {}
9505 }
9506 match incoming.written.kind() {
9507 BoardKind::Work(kind) => metadata.insert(
9508 ItemKind::METADATA_KEY.to_owned(),
9509 Value::String(kind.marker().to_owned()),
9510 ),
9511 // A document is told by its title, so it carries no kind marker: that key names
9512 // what a dependency endpoint points at, and nothing may point at a document.
9513 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9514 };
9515 let derivable = own_repository
9516 .map(|own| incoming.repositories == [own.clone()])
9517 .unwrap_or(incoming.repositories.is_empty());
9518 if derivable {
9519 metadata.remove(Repository::METADATA_KEY);
9520 } else {
9521 metadata.insert(
9522 Repository::METADATA_KEY.to_owned(),
9523 Value::Array(
9524 incoming
9525 .repositories
9526 .iter()
9527 .map(|repository| Value::String(repository.as_str().to_owned()))
9528 .collect(),
9529 ),
9530 );
9531 }
9532 // The typed lists are what land, whatever the caller's own metadata held under their
9533 // keys: a key of either name travelling beside the field would otherwise be a second
9534 // answer to the same question, and the field is the one the contract names.
9535 for (key, entries) in [
9536 (TaskRef::DELIVERS_KEY, incoming.delivers),
9537 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9538 ] {
9539 set_task_list(&mut metadata, key, entries);
9540 }
9541 record_edges(&mut metadata, fallback);
9542 metadata
9543}
9544
9545/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9546/// one slot's metadata, or no such key when there are none.
9547fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9548 if fallback.is_empty() {
9549 metadata.remove(DependencyEdge::RECORDED_KEY);
9550 } else {
9551 metadata.insert(
9552 DependencyEdge::RECORDED_KEY.to_owned(),
9553 Value::Array(
9554 fallback
9555 .iter()
9556 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9557 .collect(),
9558 ),
9559 );
9560 }
9561}
9562
9563/// Every label one item carries, from its content's own connection and nowhere else.
9564///
9565/// There is no second place to read one from: no document this source sends selects the
9566/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9567/// cannot carry one at all. The module documentation records the three schema facts that
9568/// settle it.
9569fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9570 optional_nodes(content.get("labels"), "content labels")?
9571 .into_iter()
9572 .flatten()
9573 .map(|v| {
9574 Ok(Label {
9575 id: NativeId(required_str(v, "id")?.to_owned()),
9576 name: required_str(v, "name")?.to_owned(),
9577 color: optional_str(v, "color")?.map(str::to_owned),
9578 })
9579 })
9580 .collect()
9581}
9582
9583/// The definition of each board field one item's values are values of, in the shape a read
9584/// of the board's own `fields` gives one.
9585///
9586/// A value names its field through a fragment on that field's own type, so the type is
9587/// known from which kind of value it is: a single-select value's field is a
9588/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9589/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9590fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9591 field_values
9592 .iter()
9593 .filter_map(|value| {
9594 let field = value.get("field")?.as_object()?;
9595 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9596 let typename = if value.get("text").is_some() {
9597 "ProjectV2Field"
9598 } else if value.get("name").is_some() {
9599 "ProjectV2SingleSelectField"
9600 } else {
9601 return None;
9602 };
9603 let mut defined = field.clone();
9604 defined.insert("__typename".to_owned(), json!(typename));
9605 Some(Value::Object(defined))
9606 })
9607 .collect()
9608}
9609
9610fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9611 let Some(node) = field_values
9612 .iter()
9613 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9614 else {
9615 return Ok(None);
9616 };
9617 Ok(optional_str(node, "text")?.map(str::to_owned))
9618}
9619
9620fn valid_github_owner(owner: &str) -> bool {
9621 !owner.is_empty()
9622 && owner.len() <= 39
9623 && !owner.starts_with('-')
9624 && !owner.ends_with('-')
9625 && !owner.contains("--")
9626 && owner
9627 .bytes()
9628 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9629}
9630
9631/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9632/// neither of the two names a path segment already means.
9633fn valid_github_repository_name(name: &str) -> bool {
9634 !name.is_empty()
9635 && name.len() <= 100
9636 && name != "."
9637 && name != ".."
9638 && name
9639 .bytes()
9640 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9641}
9642
9643fn valid_environment_name(name: &str) -> bool {
9644 let mut bytes = name.bytes();
9645 bytes
9646 .next()
9647 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9648 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9649}
9650
9651/// How many sub-issues one issue has.
9652///
9653/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9654/// absent or non-integer one is a response this source cannot read — and reading it as
9655/// zero would classify a project as a task, which is exactly the mistake the marker
9656/// exists to keep from happening quietly.
9657fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9658 let summary = issue
9659 .get("subIssuesSummary")
9660 .ok_or_else(|| SourceError::Malformed {
9661 message: "GitHub issue is missing subIssuesSummary".into(),
9662 })?;
9663 summary
9664 .get("total")
9665 .and_then(Value::as_u64)
9666 .ok_or_else(|| SourceError::Malformed {
9667 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9668 })
9669}
9670
9671/// One issue's own `number`.
9672///
9673/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9674/// an issue in this module asks for it. So a read of one that comes back without it, or
9675/// with something that is not an unsigned integer, is a response this source cannot read —
9676/// absence here is **not** "this issue has no number". A draft is the content that has
9677/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9678/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9679fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9680 issue
9681 .get("number")
9682 .and_then(Value::as_u64)
9683 .ok_or_else(|| SourceError::Malformed {
9684 message: "GitHub issue number is missing or is not an unsigned integer".into(),
9685 })
9686}
9687
9688/// The `number` a creating mutation answered with, and `None` when it answered without one;
9689/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9690fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9691 match created.get("number") {
9692 None | Some(Value::Null) => Ok(None),
9693 Some(value) => value
9694 .as_u64()
9695 .map(Some)
9696 .ok_or_else(|| SourceError::Malformed {
9697 message: "GitHub created issue number is not an unsigned integer".into(),
9698 }),
9699 }
9700}
9701
9702fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9703 value
9704 .get(field)
9705 .and_then(Value::as_str)
9706 .ok_or_else(|| SourceError::Malformed {
9707 message: format!("GitHub response is missing string field {field}"),
9708 })
9709}
9710
9711fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9712 let found = required_str(value, field)?;
9713 if found.trim().is_empty() {
9714 return Err(SourceError::Malformed {
9715 message: format!("GitHub response has blank string field {field}"),
9716 });
9717 }
9718 Ok(found)
9719}
9720
9721/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9722/// needs one — Linear spells them too, in its own description field.
9723///
9724/// Restated rather than shared, because a plugin crate depends on the contract crate and
9725/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9726/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9727/// source round-trips its own writes perfectly well under its own spelling.
9728const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9729const METADATA_CLOSE: &str = "\n-->";
9730
9731/// What the composer puts between a non-empty visible body and the slot, and the one thing
9732/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9733/// other trailing byte of the body comes back as it was written.
9734// 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.
9735const METADATA_SEPARATOR: &str = "\n\n";
9736
9737/// The visible body and the metadata slot at the end of it.
9738///
9739/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9740/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9741/// own content and is left alone. The visible body is everything before the slot less the
9742/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9743fn metadata_body(
9744 body: Option<String>,
9745) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9746 let Some(body) = body else {
9747 return Ok((None, BTreeMap::new()));
9748 };
9749 let Some(slot) = slot_span(&body)? else {
9750 return Ok((Some(body), BTreeMap::new()));
9751 };
9752 let metadata =
9753 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9754 SourceError::Malformed {
9755 message: format!(
9756 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9757 ),
9758 }
9759 })?;
9760 let before = &body[..slot.start];
9761 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9762 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9763}
9764
9765/// Where the metadata slot sits in one body, as byte offsets into it.
9766struct SlotSpan {
9767 /// Where [`METADATA_OPEN`] begins.
9768 start: usize,
9769 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9770 encoded_start: usize,
9771 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9772 encoded_end: usize,
9773 /// Just past [`METADATA_CLOSE`].
9774 end: usize,
9775}
9776
9777/// The slot at the very end of `body`, or `None` when it has none.
9778///
9779/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9780/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9781/// slot.
9782fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9783 let Some(start) = body.rfind(METADATA_OPEN) else {
9784 return Ok(None);
9785 };
9786 let encoded_start = start + METADATA_OPEN.len();
9787 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9788 return Err(SourceError::Malformed {
9789 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9790 });
9791 };
9792 let encoded_end = encoded_start + relative_end;
9793 let end = encoded_end + METADATA_CLOSE.len();
9794 if !body[end..].trim().is_empty() {
9795 return Ok(None);
9796 }
9797 Ok(Some(SlotSpan {
9798 start,
9799 encoded_start,
9800 encoded_end,
9801 end,
9802 }))
9803}
9804
9805/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9806/// slot as it was.
9807///
9808/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9809/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9810/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9811/// or alone in an empty body — and a body with no slot that is given no metadata is
9812/// returned as it is.
9813fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9814 let encoded = if metadata.is_empty() {
9815 None
9816 } else {
9817 Some(
9818 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9819 message: error.to_string(),
9820 })?,
9821 )
9822 };
9823 Ok(match (slot_span(body)?, encoded) {
9824 (Some(slot), Some(encoded)) => format!(
9825 "{}{encoded}{}",
9826 &body[..slot.encoded_start],
9827 &body[slot.encoded_end..]
9828 ),
9829 (Some(slot), None) => {
9830 let before = &body[..slot.start];
9831 format!(
9832 "{}{}",
9833 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
9834 &body[slot.end..]
9835 )
9836 }
9837 (None, None) => body.to_owned(),
9838 (None, Some(encoded)) if body.is_empty() => {
9839 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9840 }
9841 (None, Some(encoded)) => {
9842 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9843 }
9844 })
9845}
9846
9847/// `body` with everything before its metadata slot replaced by `content`, and the slot
9848/// itself kept byte for byte.
9849///
9850/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
9851/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
9852/// `content` is empty — so a read of the result reports `content` as the visible body and
9853/// the slot's metadata exactly as it was.
9854fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
9855 let Some(slot) = slot_span(body)? else {
9856 return Ok(content.to_owned());
9857 };
9858 let kept = &body[slot.start..];
9859 Ok(if content.is_empty() {
9860 kept.to_owned()
9861 } else {
9862 format!("{content}{METADATA_SEPARATOR}{kept}")
9863 })
9864}
9865
9866/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
9867fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
9868 if entries.is_empty() {
9869 metadata.remove(key);
9870 } else {
9871 metadata.insert(
9872 key.to_owned(),
9873 Value::Array(
9874 entries
9875 .iter()
9876 .map(|entry| Value::String(entry.as_str().to_owned()))
9877 .collect(),
9878 ),
9879 );
9880 }
9881}
9882
9883fn compose_body(
9884 content: Option<&str>,
9885 metadata: &BTreeMap<String, Value>,
9886) -> Result<Option<String>, SourceError> {
9887 let visible = content.unwrap_or_default();
9888 if metadata.is_empty() {
9889 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9890 }
9891 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9892 message: error.to_string(),
9893 })?;
9894 Ok(Some(if visible.is_empty() {
9895 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9896 } else {
9897 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9898 }))
9899}
9900
9901fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9902 value
9903 .get(field)
9904 .and_then(Value::as_bool)
9905 .ok_or_else(|| SourceError::Malformed {
9906 message: format!("GitHub response is missing boolean field {field}"),
9907 })
9908}
9909fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9910 match value.get(field) {
9911 None | Some(Value::Null) => Ok(None),
9912 Some(value) => value
9913 .as_str()
9914 .map(Some)
9915 .ok_or_else(|| SourceError::Malformed {
9916 message: format!("GitHub response field {field} is not a string or null"),
9917 }),
9918 }
9919}
9920fn optional_nodes<'a>(
9921 connection: Option<&'a Value>,
9922 name: &str,
9923) -> Result<Option<&'a Vec<Value>>, SourceError> {
9924 match connection {
9925 None | Some(Value::Null) => Ok(None),
9926 Some(value) => value
9927 .get("nodes")
9928 .and_then(Value::as_array)
9929 .map(Some)
9930 .ok_or_else(|| SourceError::Malformed {
9931 message: format!("GitHub {name}.nodes is not an array"),
9932 }),
9933 }
9934}
9935fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9936 let page_info = connection
9937 .get("pageInfo")
9938 .ok_or_else(|| SourceError::Malformed {
9939 message: format!("GitHub {name} has no pageInfo"),
9940 })?;
9941 if required_bool(page_info, "hasNextPage")? {
9942 return Err(SourceError::Malformed {
9943 message: format!(
9944 "GitHub {name} exceeds the supported nested connection size of {size}"
9945 ),
9946 });
9947 }
9948 Ok(())
9949}
9950fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9951 optional_str(value, field)?
9952 .map(|timestamp| {
9953 timestamp.parse().map_err(|error| SourceError::Malformed {
9954 message: format!("GitHub response field {field} is not a timestamp: {error}"),
9955 })
9956 })
9957 .transpose()
9958}
9959fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9960 if page.limit == 0 {
9961 Err(SourceError::Config {
9962 message: "page limit must be at least 1".into(),
9963 })
9964 } else {
9965 Ok(())
9966 }
9967}
9968fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9969 let page = connection
9970 .get("pageInfo")
9971 .filter(|value| value.is_object())
9972 .ok_or_else(|| SourceError::Malformed {
9973 message: "GitHub connection is missing pageInfo".into(),
9974 })?;
9975 if required_bool(page, "hasNextPage")? {
9976 let cursor = required_str(page, "endCursor")?;
9977 validate_cursor_progress(None, cursor)?;
9978 Ok(Some(Cursor(cursor.into())))
9979 } else {
9980 Ok(None)
9981 }
9982}
9983fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9984 if next.is_empty() || previous == Some(next) {
9985 Err(SourceError::Malformed {
9986 message: "GitHub pagination cursor is empty or did not advance".into(),
9987 })
9988 } else {
9989 Ok(())
9990 }
9991}
9992/// The version of this plugin's opaque narrowing-search cursor.
9993pub const SEARCH_CURSOR_VERSION: u32 = 4;
9994
9995#[derive(Serialize, Deserialize)]
9996#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9997enum SearchConnection {
9998 Initial {},
9999 Continuing { after: Cursor },
10000 Exhausted {},
10001}
10002impl SearchConnection {
10003 fn after(&self) -> Option<&str> {
10004 match self {
10005 Self::Continuing { after } => Some(&after.0),
10006 _ => None,
10007 }
10008 }
10009 fn exhausted(&self) -> bool {
10010 matches!(self, Self::Exhausted { .. })
10011 }
10012 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10013 /// plugin could have handed out: a page is resumed only part of the way through it — an
10014 /// offset of a whole page or more would skip rows nobody was given — an initial page
10015 /// only once some of it was handed out, and an exhausted connection has no page to be
10016 /// part of the way through.
10017 fn valid_resume(&self, offset: usize) -> bool {
10018 let within = offset < SEARCH_PAGE_SIZE as usize;
10019 match self {
10020 Self::Initial { .. } => offset > 0 && within,
10021 Self::Continuing { after } => !after.0.is_empty() && within,
10022 Self::Exhausted { .. } => offset == 0,
10023 }
10024 }
10025}
10026
10027/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10028#[derive(Serialize, Deserialize)]
10029#[serde(deny_unknown_fields)]
10030struct SearchPosition {
10031 version: u32,
10032 connection: SearchConnection,
10033 /// How many rows of the page `connection` starts were already handed out.
10034 #[serde(default, skip_serializing_if = "is_zero")]
10035 offset: usize,
10036 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10037 seen: Vec<NativeId>,
10038 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10039 own: Vec<NativeId>,
10040}
10041impl Default for SearchPosition {
10042 fn default() -> Self {
10043 Self {
10044 version: SEARCH_CURSOR_VERSION,
10045 connection: SearchConnection::Initial {},
10046 offset: 0,
10047 seen: Vec::new(),
10048 own: Vec::new(),
10049 }
10050 }
10051}
10052
10053fn is_zero(offset: &usize) -> bool {
10054 *offset == 0
10055}
10056
10057fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10058 cursor.map_or(Ok(0), |c| {
10059 c.0.parse().map_err(|_| SourceError::Config {
10060 message: "page cursor is invalid".into(),
10061 })
10062 })
10063}
10064fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10065 if offset > items.len() {
10066 return Page::last(vec![]);
10067 }
10068 let tail = items.split_off(offset);
10069 let mut selected = tail;
10070 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10071 selected.truncate(limit);
10072 Page {
10073 items: selected,
10074 next,
10075 }
10076}