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, in the shared grammar
85//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
86//! option of the board's one `Status` field for a task and for a project: a bare name is
87//! the option for both kinds, `null` disables the category for both, and `{task, project}`
88//! names it per kind. A category the mapping does not mention keeps its shipped default for
89//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
90//! gets the shipped default for the kind it leaves out. Two categories one kind would read
91//! back from one option are refused as the configuration is read, ignoring case, while one
92//! option may stand for different categories of the two kinds. Writes go by the kind of the
93//! item written: a status that kind has no option for, or whose option the board lacks, is
94//! refused before any mutation, naming the source, the kind, the category and the key
95//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
96//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
97//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
98//! before selecting its option. Reads give a closed issue's reason precedence over its
99//! option, while an open issue's option decides its category through its own kind's
100//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
101//! The guarded [`GitHubProjectsSource::status_options`] and
102//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
103//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
104//! existing option id and verify the field and item assignments immediately afterwards.
105//! They ask for both kinds' options, counting a terminal category's mapped option as
106//! configured because a terminal write refuses without it. No ordinary source read or
107//! write calls that mutation, whose
108//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
109//! and a mistake destroys every item's status. A status this board cannot represent is a
110//! refusal naming the status and the instance instead.
111//!
112//! `unknown` has no shipped option because this source cannot preserve an open-ended
113//! status word: it writes an existing board option and never
114//! creates an option. An operator may map `unknown` to one existing option, in which case
115//! every unknown word lands on that option and reads back as `unknown` under the option's
116//! name. This differs from `local-md`, which writes and reads the original word itself.
117//!
118//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
119//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
120//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
121//! finished tasks were only moved to a "Done" column would read 0% complete forever.
122// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
123//!
124//! # What this source declares, field by field
125//!
126//! One verdict per field of [`Capabilities`], and what `Native` means when this source
127//! says it. *Proven* means a shared journey drives it against the real
128//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
129//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
130//! [`capabilities`](TaskSource::capabilities) from parting.
131//!
132//! | Field | Verdict |
133//! | --- | --- |
134//! | `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. |
135//! | `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. |
136//! | `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. |
137//! | `assets` | **Unsupported — unimplemented.** A copy of a record carrying an image asset into a board is refused, naming the source, the record and the asset, before anything is written for that record. Storing the bytes where the issue renders them is tracked in `docs/follow-ups.md`. |
138//! | `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. |
139//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
140//! | `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 `ProjectV2.items` is never 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 — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
141//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
142//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
143//! | `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` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
144//! | `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. |
145//! | `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. |
146//! | `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. |
147//! | `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. |
148//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
149//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
150//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
151//!
152//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
153//! source has documents and that its tasks have comments, both of which hold — and the three
154//! facts behind the uniform `Native` on the
155//! predicates beside it are recorded below rather than re-derived, because a reader who
156//! takes `Native` to mean *the remote service filters* will read that uniformity as a
157//! lie.
158//!
159//! First, the plugin contract defines `Support::Native` as *the source applies this
160//! predicate itself*, and says nothing about where it applies it. What the declaration
161//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
162//! — so that the engine may push it down and apply nothing of its own.
163//!
164//! Second, this source can keep that promise for every predicate at no additional API
165//! cost, because whichever of the reads below answers a query has already read every
166//! candidate that query will return before it filters anything. Filtering those items is
167//! in-process work over data already in hand.
168//!
169//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
170//! applied in process over what that question returned. A project filter has a relationship — a
171//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
172//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
173//! a search for metadata values, is the board-scoped issue search carrying the text and each
174//! value as quoted phrases; an origin is the board's own field filter over its origin field
175//! beside the same search for the id. **The text search narrows, and that is this source's
176//! declared semantics:** GitHub matches whole words where the substring rule this source and
177//! the local Markdown source confirm with would match inside one, so an item holding the text
178//! only inside a longer word is never a candidate. Every item returned does contain the text.
179//! A project query's text, and a document query's scoped to no project, is that same search
180//! and narrows on the same terms, its candidates confirmed by their kind as well.
181//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
182//! so those three are applied in process over the candidates, and a query carrying none of
183//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
184//! engine compensate for work this source has already done, and declaring `projects` native
185//! while ignoring the filter (which this source once did) silently returns another project's
186//! tasks, because the engine trusts the declaration and applies nothing locally.
187//!
188//! # The three ways this source reaches an item, and what each costs
189//!
190//! A board read is charged for what its *nested* connections could return rather than for
191//! what was asked, so one whole-board read costs the same whether the question was about
192//! one project or about all of them. That is why a question about one project is never
193//! answered by reading the board:
194//!
195//! | The question | What is sent | What it costs |
196//! | --- | --- | --- |
197//! | 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 |
198//! | 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 |
199//! | 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 |
200//! | 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 |
201//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
202//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
203//! | 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 |
204//! | 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 |
205//! | 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 |
206//! | 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 |
207//! | 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 |
208//! | 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 |
209//!
210//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
211//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
212//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
213//! equal to declared points equal to the row. They include the origin lookup and the
214//! field/repository discovery a create needs. A bound re-copy changes status, priority,
215//! content and metadata; comment recount means a subsequent detail read. Each request here
216//! costs one declared point. A membership beyond the embedded page can additionally require
217//! the one-point membership recovery described above. A bound re-copy of a task filed under a
218//! project adds one read, the engine confirming that project's link by its own id once per
219//! command; and the same-source far ends a write newly names — those that do not already block
220//! the item, whose own read answered for them — are read together by their own ids,
221//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
222//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
223//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
224//!
225//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
226//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
227//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
228//! holds both halves.
229//!
230//! **An existing item is written body last.** A bound re-copy and a `task update` send its
231//! board fields first — the `Status` option and the `Priority` together, in one request — then
232//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
233//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
234//! undoing an earlier field when a later one fails, so that order is what makes a write
235//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
236//! the one piece of metadata written before the body, an origin a copy re-points, is put back
237//! when a later write is refused — and when putting it back is refused too, the write's own
238//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
239//! of those writes in turn, whole and as one aliased field failing after the one before it.
240//!
241//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
242//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
243//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
244//!
245//! - **A board is accepted at creation but its item is not answered, so a create still files
246//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
247//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
248//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
249//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
250//! against a real board on 2026-10-01 with a create sending the board there and reading the
251//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
252//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
253//! refused "Content already exists in this project" — GitHub had filed the issue after
254//! answering, and refuses a second filing rather than answering with the item it holds. A
255//! create therefore sends no `projectV2Ids` and files the issue with
256//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
257//! real is the read before it: the board's fields and the repository's id together, in
258//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
259//! - **A comment still reads its target first, so a comment is 2 requests.**
260//! `AddCommentInput.subjectId: ID!` is declared there with
261//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
262//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
263//! project's issue, a document's issue, an issue on no board of this source and a pull
264//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
265//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
266//! refuses those by name.
267//!
268//! | Verb | Requests / points | Documents |
269//! | --- | --- | --- |
270//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
271//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
272//! | 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 |
273//! | 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 |
274//! | 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 |
275//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
276//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
277//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
278//! | recount | 1 | ISSUE_DETAIL |
279//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
280//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
281//! | content | 2 | ISSUE, UPDATE_ISSUE |
282//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
283//! | 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 |
284//! | record only | 1 | ISSUE |
285//!
286//! <!-- github-search-paging:start -->
287//! Board-scoped text, metadata, project-name and comment-activity searches send every
288//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
289//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
290//! needs rows. A page is never resized to the rows still needed: GitHub orders one
291//! search differently at different page sizes, so one fixed size makes a paged walk
292//! send exactly the requests one whole read sends, and the answer's order is the order
293//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
294//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
295//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
296//! returned and fetched pages: a limit is sliced from the pages it needs, and local
297//! confirmation can require more candidates than matching rows. Walking all pages
298//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
299//! cursor and how far into that page the last answer stopped, and resumes in the same
300//! process or a new one, without duplicates or gaps. It carries no rows: one process
301//! sends each page's search once, and a new process re-reads only the page it resumes
302//! in, then sends a further page once, never as a re-read, only when its limit still
303//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
304//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
305//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
306//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
307//! not required to include the original process's writes still omitted by the index.
308//! <!-- github-search-paging:end -->
309//!
310//! The board half of an issue — its board item's id, its `Status` option and this
311//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
312//! an item reached any of those ways resolves through the same
313//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
314//! same status, the same labels and the same qualified id. That connection comes back a
315//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
316//! on the page in hand and — only if that page reports more of the connection — in the
317//! last row's read of that one issue's memberships, resumed from the page's own cursor and
318//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
319//! report, which is what keeps an id naming another repository's issue from being answered
320//! as an item of this board; and because the page is where the search starts rather than
321//! where it ends, that answer is one about a connection read to exhaustion and never about
322//! an unread page. Nothing costs the extra read but an issue on more boards than a page
323//! holds: an issue this board really does not hold reports no next page, so its
324//! memberships are already exhausted where they arrived.
325//!
326//! **No document here selects the board's own `Labels` field, and nothing is lost by
327//! that.** An item's labels are read from its content alone, wherever that content is
328//! reached: the three documents above select `Issue.labels` on the fragment, and
329//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
330//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
331//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
332//! create one, and `ProjectV2FieldValue` — the whole of what
333//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
334//! it from the content, for every content type it exists on, and there is nothing it can
335//! hold that the content does not already say: for an `Issue` it *is* that issue's own
336//! labels, so selecting it beside them unions a set with itself.
337//!
338//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
339//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
340//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
341//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
342//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
343//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
344//! label set, and that set is the fixture issue's own, by
345//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
346//! item whose content is a draft reports an empty set, by
347//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
348//! selection is held over [`graphql::DOCUMENTS`] by
349//! `no_document_selects_the_boards_own_labels_field`.
350//!
351//! The whole-board row is still the board's own item connection, and deliberately: a
352//! **draft** board item is not an issue, so no search can list one, and the reads that have
353//! to answer for the whole board are the ones whose cost is the board's size anyway.
354//!
355//! **A question about one item this source already names by id never lists the board.**
356//! Whether that item is on this board, and what its board fields are, is answered by reading
357//! that item — its own `Issue.projectItems`, walked to exhaustion by
358//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
359//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
360//! holds. That covers a write's destination, the project a new item is filed under, a
361//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
362//! and the delete that takes back an item a copy made. What such a write needs of the board
363//! and the item does not carry — the board's id, the `Status` and origin field definitions —
364//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
365//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
366//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
367//! minutes. Scanning this host's 842-item board has refused a document copy and an update
368//! even though the items' own reads named that board. A scan there gives the wrong answer
369//! as well as paying for every page. So a `board.items` lookup does not belong on any of
370//! those paths.
371//!
372//! **What a read may return is capped too, and that cap is on the document rather than on
373//! the board.** GitHub limits the number of nodes **one query may return** to
374//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
375//! an error naming the connection the count crossed at, not a slow or a partial result.
376//! Every board this source reads is refused the same way, so no board is too big for these
377//! documents and none is small enough to save one that is over.
378//!
379//! The count is arithmetic over the document's own text: each connection contributes the
380//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
381//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
382//! restate them — `github-graphql-node-count` implements them, and
383//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
384//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
385//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
386//! same text on every run and fails naming any that reaches the limit — so a connection
387//! added to a shared fragment is caught there rather than by GitHub.
388//!
389//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
390//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
391//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
392//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
393//! effectively squared there, which is why it is the one the limit is most sensitive to.
394//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
395//! page of memberships misses is recovered by one further read rather than refused, so it
396//! buys a bound every read pays for at the price of a request only a multi-board issue
397//! pays.
398//!
399//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
400//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
401//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
402//! `cost` is rate-limit points, metered per hour across everything one credential does; it
403//! is what the two limiters [`Limiter`] tells apart meter, and a document under
404//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
405//! that second number, and `tests/point_cost.rs` pins every document in
406//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
407//! one under, the pin itself is the check. The credentialed lane reconciles both figures
408//! against GitHub's own, off a probe it already sends.
409//!
410//! **What is pinned that way is a per-document price and never a session's.** The record in
411//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
412//! **requests** and **worst-case nodes** — and neither is points. What one whole session
413//! consumes of the hourly point allowance is observable only from a credentialed run's own
414//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
415//! and what `tests/live.rs` prints at the end of every run.
416//!
417//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
418//!
419//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
420//! enumerations of a board can supply one alone.** Resolving a node id is strongly
421//! consistent, so a read by id and a project's own sub-issues are already current. The
422//! other two are not, and they are behind by different amounts and in different directions:
423//!
424//! - GitHub's **issue search** is an index and answers a write made moments ago with the
425//! value from before it — usually for a second or two.
426//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
427//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
428//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
429//! for *minutes*, while `Issue.projectItems` names the same membership at once.
430//!
431//! That second one is a measurement rather than a caution. This repository's own
432//! credentialed journey writes a project and waits for the board to report it, then writes a
433//! task and waits for the same thing seconds later on the same board: the project wait is
434//! answered through the search and converged in two or three attempts in each of three runs,
435//! and the task wait is answered through `ProjectV2.items` and converged in none of them
436//! inside thirty. Separately, an item added to a second and larger board was read back by
437//! `Issue.projectItems` on that board's own id while every one of that connection's nine
438//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
439//! through the lagging one alone is what had a board read deny an issue that had certainly
440//! landed on it.
441//!
442//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
443//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
444//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
445//! search reports what the projection is behind on. What closes the last
446//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
447//! source answers is completed with what this process itself wrote, so an item created
448//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
449//! nothing is written down, and the record dies with the process. **A wait that has to
450//! observe GitHub's own data cannot be answered from that record** — which is why the
451//! credentialed journey asks through a source built afresh, and why the union above rather
452//! than a longer wait is what makes such a wait converge.
453//!
454//! **A narrowed read is the same bargain, stated for each of the three predicates it
455//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
456//! than walking the board, and every such answer is completed with what this process wrote —
457//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
458//! each filtered by the same predicates as the rest — so an item this command wrote a moment
459//! ago is returned by a query that matches it whether or not the index has caught up. An item
460//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
461//! consistent. What is left is stated rather than papered over:
462//!
463//! | Read | Finds | Behind by |
464//! | --- | --- | --- |
465//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
466//! | 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 |
467//! | 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 |
468//! | origin, third read | this process's own writes | nothing |
469//!
470//! So an origin carrier another process added within the last second or two, before either
471//! index has it, can be missing from an origin query, and one written by the release before
472//! this one — its origin in the field alone — can be missing for as long as the board's own
473//! item connection is behind on it. A copy that must not duplicate its own earlier write
474//! relies on the link it records, not on either index. **A board draft is not an issue**, so
475//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
476//! lists one, the origin lookup drops any the board's own field filter names, and one this
477//! process wrote is not added back either.
478//!
479//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
480//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
481//! body's metadata slot, so the issue search can find it in seconds. The field is
482//! authoritative: this source reads an item's origin from the field alone, so a slot that
483//! disagrees with it, or holds one where the field holds none, is never read as a second
484//! origin — and the release before this one reads the slot, drops that key's copy for the
485//! field's, and sees the same one origin.
486//!
487//! Filtering happens before paging, so a page of a filtered result is a page of the
488//! survivors rather than the survivors of a page. Label matching and the substring rule a
489//! text candidate is confirmed by answer the same question the same way the local Markdown
490//! source's do; which candidates a text search has to confirm is GitHub's word match, which
491//! is the one place the two sources can answer the same text differently.
492//!
493//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
494//! has one source, `capabilities`, and the note above is the reasoning behind it rather
495//! than a second copy of it: without the three facts recorded here a reader takes the
496//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
497//! crate's own capabilities test, which pins every field of it against a fully spelled-out
498//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
499//! fails to compile there rather than going unasserted. -->
500//! The fixture-server tests above run wherever this crate is selected; the credentialed
501//! lane runs in the same required check, beside them, and can fail it — it verifies the
502//! 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,
503//! one filed under neither, a label on one of the three and a closed status on another —
504//! because that shape is what tells an honoured predicate from an ignored one: a board
505//! holding a single project answers a project filter the same way whether or not this
506//! source applies it, which is exactly how the defect above went unseen.
507//!
508//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
509//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
510//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
511//! session or request, independently of the live demand. Fix that variable in the machine's
512//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
513//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
514//! policy crate; absent credentials or nominations otherwise skip.
515//!
516//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
517//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
518//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
519//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
520//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
521//! after its run reads back as `completed`, immediately before the listed-artifact batch.
522//! Its separate legacy pass reaches only pre-cutover machine residue in the core
523//! repository, after fully paginated non-completed `ci.yml` listings prove every run was
524//! created strictly after the stamp plus ten minutes, refreshed before each batch.
525//! Failed or incomplete ownership reads preserve residue. One 24-hour waiting period
526//! applies to both passes and delays legacy startup until cutover plus that period;
527//! it is a margin, never the ownership authorisation. The legacy limitation is that no
528//! evidence covers a development-machine run that remains alive a day after its stamp.
529//! Neither pass removes the board's `onetaskgraph.origin` field.
530//!
531//! # What a session of requests costs, and where the report is
532//!
533//! This source records **every** request it sends into [`accounting::Accounting`], at
534//! `send_once` — the one place a request leaves this crate, which is why a read path added
535//! later is counted without anybody remembering to count it. That is the whole of what this
536//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
537//! session's spend is arrived at, and what it deliberately does not know are set out.
538//!
539//! What one whole session of the live journey costs, counted that way against this crate's
540//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
541//! reduction it came out of, and with what it does and does not say about rate-limit points.
542//!
543//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
544//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
545//! code path — no environment variable, no feature, no build configuration — because an
546//! instrument nobody switches on measures nothing, and
547//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
548//! source's counts the whole session rather than this source's share. The credentialed lane
549//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
550//! passed or failed.
551//!
552//! **A live session refuses to start unless the account can afford it.** Before it does any
553//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
554//! GitHub documents as not counting against the REST rate limit and which answers both of
555//! its budgets at once — and starts only if, for each of them, what remains minus this
556//! session's estimated cost is still at least
557//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
558//! allowance. A session that cannot **declines**: it did not run, so it is
559//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
560//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
561//! waiting for the budget to come back. The estimate is derived offline from
562//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
563//! which is also where the published rule that model rests on is cited; the accounting
564//! above records the gate's own read like any other request, and
565//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
566//!
567//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
568//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
569//! text, which is what lets it run on every platform and on a pull request from a fork with
570//! no credential — and that is what actually stops a regression merging. But an offline
571//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
572//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
573//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
574//! number of nodes this query may return"* and whose `cost` is what that document would
575//! spend, both for a document **without executing it**, and the lane asks it for every query
576//! document this source sends, under the largest bindings this source sends, and fails when
577//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
578//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
579//! one; the offline pins still cover it. It records what those calls reported about the
580//! account's own allowance, because whether asking is free is a thing to observe rather than
581//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
582//! and `cost` is metered against an hourly allowance the accounting above reads off a
583//! credentialed run's own response headers.
584//!
585//! **GitHub has two rate limiters and this source is refused by both, so nothing here
586//! treats them as one thing.** The primary budget is the hourly allowance `gh api
587//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
588//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
589//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
590//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
591//! one part of.
592#![deny(missing_docs)]
593
594use std::collections::BTreeMap;
595use std::sync::{Arc, Mutex};
596use std::time::{Duration, Instant};
597
598use chrono::{DateTime, Utc};
599use onetaskgraph_plugin_api::{
600 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
601 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
602 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
603 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
604 SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
605 TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
606 UnmappedStatus, UpdatedField, WriteSupport,
607};
608use reqwest::{Client, StatusCode, Url};
609use schemars::{Schema, schema_for};
610use secrecy::{ExposeSecret, SecretString};
611use serde::{Deserialize, Serialize};
612use serde_json::{Value, json};
613
614pub mod accounting;
615
616use accounting::Accounting;
617
618/// The registry name for this plugin.
619pub const KIND: &str = "github-projects";
620/// GitHub's maximum connection page size.
621pub const MAX_PAGE_SIZE: u32 = 100;
622/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
623/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
624/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
625pub const SEARCH_PAGE_SIZE: u32 = 20;
626/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
627/// its comments: the largest batch the node-count model prices at one point.
628///
629/// Each aliased item is resolved once, and what GitHub charges for it is the connections
630/// under it — its labels, its page of board memberships, the field values of each of those
631/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
632/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
633/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
634/// one point and fails if one item more would still be priced at one.
635pub const DETAIL_BATCH: usize = 24;
636
637/// The most nodes any one document this source sends may be asked to return.
638///
639/// GitHub's own published per-query ceiling, taken from
640/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
641/// workspace cannot hold a stale copy of somebody else's number. A query above it is
642/// **refused before it is executed**, whoever is asking and whatever board they are
643/// asking about — so this is a bound on the documents rather than a budget that runs out.
644///
645/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
646/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
647/// everything the credential does — two numbers against two limits, and this constant
648/// bounds only the first. The second is computed offline too, per document:
649/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
650/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
651/// lane. There is no constant like this one to hold a price under, because points are an
652/// hourly allowance rather than a per-call bound.
653///
654/// Neither is a session's price. What `session-cost.md` records of a whole session is its
655/// **requests** and its **worst-case nodes**; what a whole session spends in points is
656/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
657/// [`accounting`]. The module section on the three ways this source reaches an item says how
658/// the count is arrived at, and which of the page sizes below decide it.
659pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
660
661/// Nested connection size for the connections that hang off one item.
662///
663/// It multiplies through every document that reaches an item under a page — the count
664/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
665/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
666/// every document under these constants and fails naming any that reaches the limit, so
667/// raising this is caught there rather than by GitHub.
668const NESTED_PAGE_SIZE: u32 = 50;
669/// How many of one issue's board memberships are read when an issue is reached directly.
670///
671/// An issue reached through a search or through its own node id carries its board half in
672/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
673/// under a page of issues, so every point of it multiplies through the whole document and
674/// is paid for whether or not any issue is on a second board — which is why it is
675/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
676///
677/// **Three, because what a page misses is now recovered rather than refused**, and the
678/// recovery is what the value is chosen against. An issue whose entry for this board sits
679/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
680/// that page's own cursor — so the value trades a bound every read pays for a request only
681/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
682/// boards would pay that request *per issue*, which is order N against the one page per
683/// hundred issues a read costs today. At three it is only reached by an issue on four or
684/// more boards at once, which keeps the recovery path exceptional rather than routine for
685/// a plausible deployment.
686const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
687/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
688/// its two connections for.
689///
690/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
691/// second is a duplicate a copy already takes the first of. Both connections are walked to
692/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
693/// never what the answer is. It is small because every point of it is paid on every lookup,
694/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
695/// worst-case nodes than the one whole-board read they replaced.
696const ORIGIN_PAGE_SIZE: u32 = 3;
697
698pub use github_graphql_node_count::{NodeCountError, Variables};
699
700/// The largest value this source can bind to each page-size variable its documents name.
701///
702/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
703/// constant above it wherever a caller's own limit could reach it — `$first` at
704/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
705/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
706/// not one configuration of it, which is what makes a bound computed under it a bound on
707/// every read.
708pub fn largest_page_sizes() -> Variables {
709 Variables::from([
710 ("first".to_owned(), MAX_PAGE_SIZE),
711 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
712 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
713 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
714 ])
715}
716
717/// The most nodes `document` could be asked to return, by GitHub's published rules.
718///
719/// Computed offline from the document's own text under [`largest_page_sizes`] — no
720/// network, no credential and no schema — by
721/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
722/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
723/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
724///
725/// # Errors
726///
727/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
728/// no single operation, or binds a page size this source does not name — each of which is
729/// a defect in the document rather than a number.
730pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
731 node_count(document, &largest_page_sizes())
732}
733
734/// The most rate-limit points one call of `document` could spend, by GitHub's published
735/// rules.
736///
737/// Computed offline from the document's own text under [`largest_page_sizes`] — no
738/// network, no credential and no schema — by
739/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
740/// This is `cost`, metered **per hour** against the allowance one credential shares across
741/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
742/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
743/// under, so what `tests/point_cost.rs` does with it is pin every document in
744/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
745/// figures against GitHub's own reported `cost`.
746///
747/// # Errors
748///
749/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
750/// no single operation, or binds a page size this source does not name — each of which is
751/// a defect in the document rather than a number.
752pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
753 github_graphql_node_count::point_cost(document, &largest_page_sizes())
754}
755
756/// The most nodes `document` could be asked to return under `variables`.
757///
758/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
759/// [`accounting`] is this under the bindings one request really sent — one spelling of the
760/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
761/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
762///
763/// # Errors
764///
765/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
766/// no single operation, or binds a page size `variables` does not name.
767pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
768 github_graphql_node_count::node_count(document, variables)
769}
770
771/// The issue-title prefix that makes a board issue a document.
772///
773/// A GitHub Projects board has no document type — it holds issues — so the discriminator
774/// is the title, and this is the whole of it: an issue whose title begins with these bytes
775/// is a document and every other issue is the task or project the sub-issue rule makes it.
776///
777/// It is spelled **once**, here, and read rather than restated everywhere else — including
778/// by the shared journeys, which take it from this constant so a board fixture cannot
779/// drift from what this source reads. `docs/metadata.md` records the two consequences that
780/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
781/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
782/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
783pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
784
785/// Exact GraphQL query documents issued by this plugin.
786///
787/// Keeping the production documents here lets the pinned-schema test validate the same
788/// bytes that are sent to GitHub, rather than a test-only copy which could drift
789/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
790/// field, and its guarded caller always supplies the complete existing option set with ids.
791pub mod graphql {
792 /// The board half of one item: the field values every document here reads it from.
793 ///
794 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
795 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
796 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
797 /// *the same value*, because
798 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
799 /// one path. Three spellings of it is what would drift, so there is one.
800 ///
801 /// The `Status` option and this source's own origin text field are the whole of it. It
802 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
803 /// content, so it holds nothing the content's own `labels` do not already say, and it
804 /// would sit a label connection two page sizes deep.
805 macro_rules! board_item_values {
806 () => {
807 r#"fieldValues(first:$nestedFirst){nodes{
808 ... on ProjectV2ItemFieldSingleSelectValue{name field{
809 ... on ProjectV2SingleSelectField{id name options{id name}}
810 }}
811 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
812 }pageInfo{hasNextPage}}"#
813 };
814 }
815
816 /// Everything this source reads about one issue, wherever it reaches that issue.
817 ///
818 /// A macro rather than a constant so the three documents below can `concat!` it: one
819 /// spelling of these fields is what makes an issue read through the board-scoped
820 /// search, through its own node id, and through its project's sub-issue relationship
821 /// resolve to *the same* item, which is the whole of what
822 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
823 ///
824 /// `projectItems` is what carries the board half of an issue: the board item's own id
825 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
826 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
827 /// issue rather than on the board, which is what makes the cost of a read proportional
828 /// to what was asked for instead of to the board's size.
829 ///
830 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
831 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
832 /// not on that page: a page here is where the search for the entry starts rather than
833 /// where it ends.
834 ///
835 /// It does **not** select the board's `Labels` field value, and that is the whole of
836 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
837 /// a label connection there sits under `fieldValues` under `projectItems` under a page
838 /// of issues, spending `$nestedFirst` twice down one path, and took
839 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
840 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
841 /// above, and that connection is where every label this source reports comes from. No
842 /// document in this module selects the board field any longer, [`BOARD`] included; the
843 /// module documentation records why nothing it could have held is lost.
844 macro_rules! board_issue {
845 () => {
846 concat!(
847 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
848 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
849 projectItems(first:$boardItems){nodes{id project{id number}
850 "#,
851 board_item_values!(),
852 r#"}pageInfo{hasNextPage endCursor}}}"#
853 )
854 };
855 }
856
857 /// Every issue of one board, found by a search scoped to that board.
858 ///
859 /// This is how the projects a board holds are listed, and it selects no `items`
860 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
861 /// container walked page by page, so nothing nested inside a board item is paid for.
862 /// Which of the issues it returns is a project is then read off `parent` — GitHub
863 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
864 /// discriminator has to be applied to the field, which is a scalar on the issue and
865 /// costs nothing.
866 pub const SEARCH_ISSUES: &str = concat!(
867 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
868 search(query:$search,type:$type,first:$first,after:$after){
869 pageInfo{hasNextPage endCursor}
870 nodes{__typename ...BoardIssue}
871 }
872 }"#,
873 board_issue!()
874 );
875
876 /// What a dependency read selects of each far end: enough to say which kind of item it
877 /// is, its body included for the kind marker.
878 macro_rules! related_issue {
879 () => {
880 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
881 };
882 }
883
884 /// One issue by its own node id, which is what a qualified id names here — with what a
885 /// write of it needs and the issue does not carry in `board_issue!`: the field
886 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
887 ///
888 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
889 /// answers a write made moments ago with the value from before it, and resolving a node
890 /// id does not.
891 ///
892 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
893 /// reads it by its own id, and with them that one read answers everything the write
894 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
895 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
896 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
897 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
898 /// they sit under one item, and this read is still one point.
899 pub const ISSUE: &str = concat!(
900 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
901 node(id:$id){__typename ...BoardIssue ... on Issue{
902 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
903 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
904 ... on ProjectV2Field{__typename id name}
905 }pageInfo{hasNextPage}}}}}
906 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
907 }}
908 }"#,
909 board_issue!(),
910 related_issue!()
911 );
912
913 /// One project's tasks: the sub-issues of the issue that project is.
914 ///
915 /// The work this costs is the project's own size. Nothing about it grows as the board
916 /// gains projects, or as those projects gain tasks.
917 pub const SUB_ISSUES: &str = concat!(
918 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
919 node(id:$id){__typename
920 ... on Issue{subIssues(first:$first,after:$after){
921 pageInfo{hasNextPage endCursor}
922 nodes{__typename ...BoardIssue}
923 }}}
924 }"#,
925 board_issue!()
926 );
927
928 /// What a read of the board's own `items` selects of each item's content.
929 ///
930 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
931 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
932 /// content by one spelling.
933 macro_rules! board_item_content {
934 () => {
935 r#" content{
936 ... 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}}}
937 ... on PullRequest{__typename id}
938 ... on DraftIssue{__typename id title body createdAt updatedAt}
939 }"#
940 };
941 }
942
943 /// Reads the board's fields and one page of its items.
944 pub const BOARD: &str = concat!(
945 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
946 owner:repositoryOwner(login:$owner){
947 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
948 }
949 } fragment Board on ProjectV2 { id title
950 fields(first:$nestedFirst){nodes{
951 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
952 ... on ProjectV2Field{__typename id name}
953 }pageInfo{hasNextPage}}
954 items(first:$first,after:$after){nodes{id "#,
955 board_item_values!(),
956 board_item_content!(),
957 r#"} pageInfo{hasNextPage endCursor}}
958 }"#
959 );
960
961 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
962 /// the board.
963 ///
964 /// **`originItems`** is the board's own items narrowed by its own field filter —
965 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
966 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
967 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
968 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
969 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
970 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
971 /// fresh `addProjectV2ItemById` the way that connection does.
972 ///
973 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
974 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
975 /// indexes that comment, and the index catches up with a write in a second or two rather
976 /// than in minutes, so it finds a carrier another process wrote that the first read is
977 /// still behind on.
978 ///
979 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
980 /// — and resumes from its own cursor; a connection already walked to its end is resumed
981 /// from its last cursor, which answers an empty page. Every candidate either read returns
982 /// is confirmed against its own origin field before it is reported, so a token match of
983 /// the search or anything else the filter admits never is.
984 ///
985 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
986 /// own whole reads counts this one among them.
987 pub const ORIGIN_LOOKUP: &str = concat!(
988 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
989 originItems:repositoryOwner(login:$owner){
990 ... on ProjectV2Owner{projectV2(number:$number){
991 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
992 board_item_values!(),
993 board_item_content!(),
994 r#"} pageInfo{hasNextPage endCursor}}
995 }}
996 }
997 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
998 pageInfo{hasNextPage endCursor}
999 nodes{__typename ...BoardIssue}
1000 }
1001 }"#,
1002 board_issue!()
1003 );
1004
1005 /// The board's own id and field definitions, and not one of its items.
1006 ///
1007 /// What a write needs of the board when the item it writes does not say: the id a field
1008 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1009 /// origin fields. It selects no `items`, so what it costs is the board's field list
1010 /// however many items the board holds — and it decides nothing about which items those
1011 /// are, which is the question a read of one item by its own id answers instead.
1012 ///
1013 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1014 /// board's item reads by their root counts this one among them.
1015 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1016 boardFields:repositoryOwner(login:$owner){
1017 ... on ProjectV2Owner{projectV2(number:$number){id
1018 fields(first:$nestedFirst){nodes{
1019 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1020 ... on ProjectV2Field{__typename id name}
1021 }pageInfo{hasNextPage}}
1022 }}
1023 }
1024 }"#;
1025
1026 /// One board draft by its own node id, with the board item it sits in.
1027 ///
1028 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1029 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1030 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1031 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1032 /// board listing hands it to, and nothing has to list the board to find one.
1033 pub const DRAFT: &str = concat!(
1034 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1035 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1036 projectV2Items(first:$boardItems){nodes{id project{id number}
1037 "#,
1038 board_item_values!(),
1039 r#"}pageInfo{hasNextPage endCursor}}}}
1040 }"#
1041 );
1042
1043 /// One issue's board memberships alone, walked past the page a read of it carried.
1044 ///
1045 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1046 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1047 /// boards than that page holds may have this board's entry past its end. This asks that
1048 /// one issue for its memberships and nothing else — the caller already holds the issue —
1049 /// so an answer of "this board does not hold it" is only ever given about a connection
1050 /// read to exhaustion.
1051 ///
1052 /// It selects the board item's id, its project number and the same
1053 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1054 /// very same resolver: an issue recovered this way reports the same title, the same
1055 /// status, the same labels and the same qualified id as one whose entry was on the
1056 /// page.
1057 ///
1058 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1059 /// multiplies through it and the membership connection can be walked at
1060 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1061 /// further request for any issue a person really keeps.
1062 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1063 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1064 node(id:$id){
1065 ... on Issue{projectItems(first:$first,after:$after){
1066 nodes{id project{id number}
1067 "#,
1068 board_item_values!(),
1069 r#"}
1070 pageInfo{hasNextPage endCursor}}}
1071 }
1072 }"#
1073 );
1074 /// Resolves the configured repository's node id, which creating an issue requires.
1075 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1076 /// What creating an issue needs and has not read yet: the board's own id and field
1077 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1078 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1079 ///
1080 /// Sent at the point a create knows which repository it is for, when neither half is
1081 /// already known to this process; a create needing only one of them sends that one's own
1082 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1083 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1084 /// status.
1085 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1086 boardFields:repositoryOwner(login:$owner){
1087 ... on ProjectV2Owner{projectV2(number:$number){id
1088 fields(first:$nestedFirst){nodes{
1089 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1090 ... on ProjectV2Field{__typename id name}
1091 }pageInfo{hasNextPage}}
1092 }}
1093 }
1094 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1095 }"#;
1096 /// Reads both dependency directions for one issue, with each far end's own kind — and
1097 /// the issue's own body, which is where an edge to another source is recorded, so that
1098 /// half of a dependency read needs no second read of the issue or of the board.
1099 pub const ISSUE_DEPENDENCIES: &str = concat!(
1100 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1101 ... on Issue{body
1102 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1103 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1104 }}}"#,
1105 related_issue!()
1106 );
1107 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1108 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1109 /// answered when it was.
1110 pub const CREATE_ISSUE: &str =
1111 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1112 /// Puts an existing issue on the configured board.
1113 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1114 /// Updates an issue's visible fields and its open or closed state in one call.
1115 pub const UPDATE_ISSUE: &str =
1116 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1117 /// Updates an existing draft's user-visible fields.
1118 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1119 /// Updates a text or single-select value on one project item.
1120 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}}}}}}}}"#;
1121 /// Writes up to three board fields and an optional clear in one ordered mutation.
1122 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}}}"#;
1123 /// Clears one project item's value of one field, which is what a `none` priority is.
1124 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}}}}}}}}"#;
1125 /// Creates one single-select field with its options. Only the guarded field setup may use
1126 /// this document, and only for a field the board lacks.
1127 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1128 /// Replaces a single-select field's options. Only the guarded field setup — the
1129 /// `status-options` and `fields` operations — may use this document, because GitHub
1130 /// treats the input as the complete option list.
1131 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1132 /// A fresh snapshot of the Status field and every board item's assignment.
1133 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}}}}}}"#;
1134 /// Files one issue under another as a sub-issue, which is what project membership is.
1135 pub const ADD_SUB_ISSUE: &str =
1136 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1137 /// Takes one issue back out of its parent.
1138 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1139 /// Adds GitHub's native issue blocked-by relationship.
1140 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1141 /// Removes one native issue blocked-by relationship.
1142 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1143 /// Deletes one issue, which takes its board item with it.
1144 ///
1145 /// The engine sends this in one situation only: undoing a copy that could not finish,
1146 /// over the items that same copy created. Deleting the issue removes the board item
1147 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1148 pub const DELETE_ISSUE: &str =
1149 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1150
1151 /// Everything this source reads about one issue comment, wherever it reaches one.
1152 ///
1153 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1154 /// and a comment just edited are handed to one mapper, so they are selected by one
1155 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1156 /// longer exists, and `login` is the one member every kind of actor carries.
1157 macro_rules! issue_comment {
1158 () => {
1159 "id author{login} createdAt updatedAt body url"
1160 };
1161 }
1162
1163 /// One task's comments: a page of its issue's own `comments` connection.
1164 ///
1165 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1166 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1167 /// list every time somebody edited it; left unordered the connection answers in the order
1168 /// the comments were written, which is the order GitHub documents for the same collection
1169 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1170 /// node count and the caller's own page size is pushed straight down.
1171 pub const ISSUE_COMMENTS: &str = concat!(
1172 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173 issue_comment!(),
1174 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1175 );
1176 /// One issue by its own node id, with a page of its comments: what `task show` and a
1177 /// comment listing read, in one request.
1178 ///
1179 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1180 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1181 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1182 /// connection there would multiply through both of those documents' price, and neither
1183 /// needs one.
1184 pub const ISSUE_DETAIL: &str = concat!(
1185 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1186 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1187 issue_comment!(),
1188 r#"}pageInfo{hasNextPage endCursor}}}}
1189 }"#,
1190 board_issue!()
1191 );
1192
1193 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1194 /// page of its comments when `$comments` asks for them.
1195 macro_rules! issue_details_alias {
1196 ($n:literal) => {
1197 concat!(
1198 "\n i",
1199 stringify!($n),
1200 ":node(id:$id",
1201 stringify!($n),
1202 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1203 issue_comment!(),
1204 "}pageInfo{hasNextPage endCursor}}}}"
1205 )
1206 };
1207 }
1208
1209 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1210 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1211 ///
1212 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1213 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1214 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1215 /// neither — so every connection under it would be priced at nothing and the pin in
1216 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1217 /// one-item read the model already prices, so the batch costs what its aliases cost.
1218 ///
1219 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1220 /// slots it has no item for to the last item it does, and reads that item again; the
1221 /// price is the document's, whatever its variables, so a short batch costs what a full
1222 /// one does and nothing more.
1223 pub const ISSUE_DETAILS: &str = concat!(
1224 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!){"#,
1225 issue_details_alias!(0),
1226 issue_details_alias!(1),
1227 issue_details_alias!(2),
1228 issue_details_alias!(3),
1229 issue_details_alias!(4),
1230 issue_details_alias!(5),
1231 issue_details_alias!(6),
1232 issue_details_alias!(7),
1233 issue_details_alias!(8),
1234 issue_details_alias!(9),
1235 issue_details_alias!(10),
1236 issue_details_alias!(11),
1237 issue_details_alias!(12),
1238 issue_details_alias!(13),
1239 issue_details_alias!(14),
1240 issue_details_alias!(15),
1241 issue_details_alias!(16),
1242 issue_details_alias!(17),
1243 issue_details_alias!(18),
1244 issue_details_alias!(19),
1245 issue_details_alias!(20),
1246 issue_details_alias!(21),
1247 issue_details_alias!(22),
1248 issue_details_alias!(23),
1249 "\n }",
1250 board_issue!()
1251 );
1252
1253 /// Which issue one comment is on, read before that comment is edited or removed.
1254 ///
1255 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1256 /// comment id given against the wrong task would change a comment on another issue.
1257 pub const COMMENT_ISSUE: &str =
1258 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1259 /// Adds one comment to an issue, signed as the account the token belongs to.
1260 pub const ADD_COMMENT: &str = concat!(
1261 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1262 issue_comment!(),
1263 r#"}}}}"#
1264 );
1265 /// Replaces the body of one issue comment.
1266 pub const UPDATE_COMMENT: &str = concat!(
1267 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1268 issue_comment!(),
1269 r#"}}}"#
1270 );
1271 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1272 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1273
1274 /// Every document above, with what this source is doing when it sends one.
1275 ///
1276 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1277 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1278 /// document added later with "talking to GitHub" and never say so.
1279 ///
1280 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1281 /// const` here that this list omits, so the two cannot part — which is the same guard
1282 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1283 pub const DOCUMENTS: [(&str, &str); 33] = [
1284 (SEARCH_ISSUES, "searching this board's issues"),
1285 (ISSUE, "reading one issue"),
1286 (
1287 ISSUE_BOARD_ITEMS,
1288 "reading one issue's board memberships past the page it came with",
1289 ),
1290 (SUB_ISSUES, "reading a project's tasks"),
1291 (BOARD, "reading the board"),
1292 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1293 (BOARD_FIELDS, "reading the board's fields"),
1294 (DRAFT, "reading one draft"),
1295 (REPOSITORY, "reading the destination repository"),
1296 (
1297 CREATION_CONTEXT,
1298 "reading the board's fields and the destination repository",
1299 ),
1300 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1301 (CREATE_ISSUE, "creating an issue"),
1302 (ADD_TO_BOARD, "adding an issue to the board"),
1303 (UPDATE_ISSUE, "updating an issue"),
1304 (UPDATE_DRAFT, "updating a draft item"),
1305 (UPDATE_FIELD, "writing a board field"),
1306 (UPDATE_FIELDS, "writing board fields together"),
1307 (CLEAR_FIELD, "clearing a board field"),
1308 (
1309 CREATE_FIELD,
1310 "creating a board single-select field with its options",
1311 ),
1312 (
1313 STATUS_OPTIONS_SNAPSHOT,
1314 "snapshotting board Status options and assignments",
1315 ),
1316 (
1317 STATUS_OPTIONS_UPDATE,
1318 "safely replacing the board Status option list",
1319 ),
1320 (ADD_SUB_ISSUE, "filing an issue under its project"),
1321 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1322 (ADD_BLOCKED_BY, "recording a dependency"),
1323 (REMOVE_BLOCKED_BY, "removing a dependency"),
1324 (DELETE_ISSUE, "deleting an issue"),
1325 (ISSUE_COMMENTS, "reading a task's comments"),
1326 (ISSUE_DETAIL, "reading one issue with its comments"),
1327 (
1328 ISSUE_DETAILS,
1329 "reading a batch of issues with their comments",
1330 ),
1331 (COMMENT_ISSUE, "reading which issue a comment is on"),
1332 (ADD_COMMENT, "adding a comment"),
1333 (UPDATE_COMMENT, "editing a comment"),
1334 (DELETE_COMMENT, "deleting a comment"),
1335 ];
1336}
1337
1338/// Which of GitHub's two rate limiters refused a request.
1339///
1340/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1341/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1342/// the whole reason this is carried rather than collapsed into "rate limited".
1343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344enum Limiter {
1345 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1346 Primary,
1347 /// The burst limiter over content-generating requests, which nothing reports.
1348 Secondary,
1349}
1350
1351/// The wordings GitHub answers a secondary rate limit with.
1352///
1353/// It sends them under a forbidden status, under a too-many-requests status, and inside
1354/// the `errors` of a *successful* response, which is why the text is what this matches on
1355/// rather than the status. `abuse detection` is the wording GitHub used before the
1356/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1357/// what a burst of content creation is refused with.
1358///
1359/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1360/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1361/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1362/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1363pub const SECONDARY_WORDINGS: [&str; 5] = [
1364 "secondary rate limit",
1365 "temporarily blocked from content creation",
1366 "abuse detection",
1367 "submitted too quickly",
1368 "exceeded a secondary",
1369];
1370
1371/// The wordings GitHub answers an exhausted primary budget with.
1372///
1373/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1374/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1375/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1376/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1377/// two phrases is a substring of it, so without it that answer read as a refusal that will
1378/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1379/// one reason.
1380pub const PRIMARY_WORDINGS: [&str; 4] = [
1381 "api rate limit exceeded",
1382 "api rate limit already exceeded",
1383 "rate limit exceeded",
1384 "rate_limited",
1385];
1386
1387/// What a response *says about itself*, which is the only place a refusal can be read.
1388///
1389/// Deliberately not the whole response body. A board is a place people write about their
1390/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1391/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1392/// reported. So the item data is never read: what is read is GitHub's own REST-style
1393/// `message` envelope, which is what a forbidden status carries, and the `message` and
1394/// `type` of each GraphQL error, which is where a *successful* response says it.
1395///
1396/// A body that is not JSON at all has nothing structured to read, so only a failing
1397/// response's own text is taken — a successful response that is not JSON is malformed
1398/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1399fn refusal_wording(status: StatusCode, body: &str) -> String {
1400 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1401 return if status.is_success() {
1402 String::new()
1403 } else {
1404 body.to_owned()
1405 };
1406 };
1407 let mut said: Vec<&str> = parsed
1408 .get("message")
1409 .and_then(Value::as_str)
1410 .into_iter()
1411 .collect();
1412 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1413 for error in errors {
1414 said.extend(
1415 ["message", "type"]
1416 .into_iter()
1417 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1418 );
1419 }
1420 }
1421 said.join("; ")
1422}
1423
1424impl Limiter {
1425 /// Which limiter refused this response, or `None` when none of them did.
1426 ///
1427 /// The wording is read first and the status only decides what carries none of it,
1428 /// because GitHub answers a secondary limit with a forbidden status far more often
1429 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1430 /// really is a credential this token lacks.
1431 ///
1432 /// A response is a refusal because of its status or its own wording. A spent budget
1433 /// only ever explains one; it never turns an answer into a refusal.
1434 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1435 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1436 if SECONDARY_WORDINGS
1437 .iter()
1438 .any(|wording| normalized.contains(wording))
1439 {
1440 return Some(Self::Secondary);
1441 }
1442 if status == StatusCode::TOO_MANY_REQUESTS {
1443 return Some(Self::Primary);
1444 }
1445 // An exhausted budget *explains* a response that failed; it does not make one that
1446 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1447 // request the budget allowed as well as on the ones it then refuses, so reading
1448 // the header alone threw away a good answer — and, once refusals were retried,
1449 // replayed a request that had already taken effect.
1450 if !status.is_success() && budget_exhausted {
1451 return Some(Self::Primary);
1452 }
1453 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1454 // `errors` of an HTTP 200, where nothing about the status says so at all.
1455 if status.is_success()
1456 && PRIMARY_WORDINGS
1457 .iter()
1458 .any(|wording| normalized.contains(wording))
1459 {
1460 return Some(Self::Primary);
1461 }
1462 None
1463 }
1464
1465 /// What this limiter is called where an operator can look it up.
1466 const fn name(self) -> &'static str {
1467 match self {
1468 Self::Primary => "GitHub's primary API rate limit",
1469 Self::Secondary => "GitHub's secondary rate limit",
1470 }
1471 }
1472
1473 /// What the endpoint an operator would go and check says about this limiter.
1474 const fn where_to_look(self) -> &'static str {
1475 match self {
1476 Self::Primary => {
1477 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1478 comes back."
1479 }
1480 Self::Secondary => {
1481 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1482 primary budget and does not report this one, so budget showing there says \
1483 nothing about this refusal, and every further attempt extends it."
1484 }
1485 }
1486 }
1487
1488 /// The next step this limiter actually calls for.
1489 const fn what_to_do(self) -> &'static str {
1490 match self {
1491 Self::Primary => {
1492 "wait for the reset `gh api rate_limit` reports, then run the command again."
1493 }
1494 Self::Secondary => {
1495 "leave this board alone for a few minutes, then run the command again — or \
1496 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1497 }
1498 }
1499 }
1500}
1501
1502/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1503#[derive(Debug, Clone, Copy)]
1504struct Limited {
1505 limiter: Limiter,
1506 hint: Option<u64>,
1507}
1508
1509impl Limited {
1510 /// What the caller is told once this source has waited as long as it may.
1511 ///
1512 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1513 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1514 /// about *which* limiter it was makes it a different kind of failure. What differs is
1515 /// the operator's next step, and that is what the message carries — a secondary
1516 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1517 /// budget looks fine, and then back to retry the very burst that was refused.
1518 fn exhausted(
1519 self,
1520 doing: &str,
1521 waits: u32,
1522 waited: Duration,
1523 needed: Duration,
1524 budget: Duration,
1525 ) -> SourceError {
1526 SourceError::RateLimited {
1527 retry_after_seconds: self.hint,
1528 message: Some(format!(
1529 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1530 again, and the next wait of {} would take it past the {} one call may spend \
1531 waiting. {} next: {}",
1532 self.limiter.name(),
1533 plural(waits, "refusal"),
1534 seconds(waited),
1535 seconds(needed),
1536 seconds(budget),
1537 self.limiter.where_to_look(),
1538 self.limiter.what_to_do(),
1539 )),
1540 }
1541 }
1542}
1543
1544/// One HTTP attempt's result, with what its response said about the rate limit.
1545///
1546/// The two travel together so the record and the outcome are written from the same place:
1547/// what a response said about the budget is only readable while that response is in hand,
1548/// and what the attempt *meant* is only decidable once its body has been read.
1549struct Attempted {
1550 result: Result<Value, Attempt>,
1551 limits: accounting::RateLimit,
1552 /// GitHub's own reported cost for this call, for a document that asked for it.
1553 reported_cost: Option<u64>,
1554}
1555
1556/// One attempt's outcome: an error to report, or a rate limit to wait out.
1557enum Attempt {
1558 Failed(SourceError),
1559 Limited(Limited),
1560}
1561
1562fn plural(count: u32, thing: &str) -> String {
1563 if count == 1 {
1564 format!("{count} {thing}")
1565 } else {
1566 format!("{count} {thing}s")
1567 }
1568}
1569
1570fn seconds(duration: Duration) -> String {
1571 format!("{:.1}s", duration.as_secs_f64())
1572}
1573
1574/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1575///
1576/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1577/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1578/// header, and neither is what makes a response a refusal — so the whole cost of one this
1579/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1580/// instead. Refusing the response over the header would turn a readable refusal into an
1581/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1582fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1583 value
1584 .and_then(|value| value.to_str().ok())
1585 .and_then(|value| value.trim().parse::<u64>().ok())
1586}
1587
1588/// Every mutation this source sends creates content — an issue, a board item, a field of
1589/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1590/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1591/// and what the keyword says are the same set. That is what makes the keyword a sound test
1592/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1593/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1594fn is_mutation(query: &str) -> bool {
1595 query.trim_start().starts_with("mutation")
1596}
1597
1598/// What this source was doing, for a diagnostic that has to say so.
1599///
1600/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1601/// a document added without a description is caught by that list's own gate instead of
1602/// falling through to the vague arm below.
1603fn operation_description(query: &str) -> &'static str {
1604 graphql::DOCUMENTS
1605 .iter()
1606 .find(|(document, _)| *document == query)
1607 .map_or("talking to GitHub", |(_, doing)| *doing)
1608}
1609
1610/// GitHub's published ceiling on content-generating requests, per minute.
1611///
1612/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1613/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1614/// from it, so a pacing value checked only against itself cannot go stale here.
1615pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1616/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1617/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1618/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1619pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1620/// Shortest interval between two content-creating mutations, in milliseconds.
1621///
1622/// GitHub documents two secondary limits on content-generating requests:
1623/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1624/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1625/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1626/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1627/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1628/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1629/// deliberately *not* what this paces at. An installation that wants the hourly bound
1630/// honoured for a long sequence of copies says so through
1631/// `pacing.min_mutation_interval_ms`.
1632pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1633/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1634///
1635/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1636/// own advice for a secondary limit — wait, and wait longer each time — without spending
1637/// the first minute of a transient refusal doing nothing.
1638pub const RETRY_BACKOFF_MS: u64 = 1_000;
1639/// Total time one call may spend waiting out rate limits before it reports a failure.
1640///
1641/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1642/// short enough that a command an operator is watching returns. The bound is what makes
1643/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1644/// the limiter, not in a process nobody can tell from a wedged one.
1645pub const RETRY_BUDGET_MS: u64 = 120_000;
1646
1647fn default_token_env() -> String {
1648 "GH_PROJECTS_TOKEN".to_owned()
1649}
1650fn default_endpoint() -> String {
1651 "https://api.github.com/graphql".to_owned()
1652}
1653
1654/// The name of a `Status` single-select option on the board.
1655///
1656/// Validated on the way in rather than checked later, so a blank option name — which
1657/// nothing on a board can be — is a state this type cannot hold.
1658#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1659#[serde(try_from = "String")]
1660#[schemars(extend("minLength" = 1))]
1661pub struct ColumnName(String);
1662
1663impl ColumnName {
1664 /// The option name, as the board spells it.
1665 fn as_str(&self) -> &str {
1666 &self.0
1667 }
1668}
1669
1670impl TryFrom<String> for ColumnName {
1671 type Error = String;
1672
1673 fn try_from(name: String) -> Result<Self, Self::Error> {
1674 if name.trim().is_empty() {
1675 return Err("a status_mapping option name cannot be blank".to_owned());
1676 }
1677 Ok(Self(name))
1678 }
1679}
1680
1681/// The two closed states this product can mean.
1682///
1683/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1684/// work nor abandoned work, so nothing here ever writes it.
1685#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1686#[serde(rename_all = "kebab-case")]
1687pub enum ClosedState {
1688 /// `COMPLETED` — precisely done.
1689 Completed,
1690 /// `NOT_PLANNED` — precisely cancelled.
1691 NotPlanned,
1692}
1693
1694impl ClosedState {
1695 const fn reason(self) -> &'static str {
1696 match self {
1697 Self::Completed => "COMPLETED",
1698 Self::NotPlanned => "NOT_PLANNED",
1699 }
1700 }
1701}
1702
1703/// Configuration for one GitHub Projects v2 board.
1704#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1705#[serde(default, deny_unknown_fields)]
1706pub struct GitHubProjectsConfig {
1707 /// Login of the user or organization which owns the board.
1708 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1709 /// The project number shown in the board's GitHub URL.
1710 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1711 // 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.
1712 /// `owner/name` of the repository this source creates an issue in when the item's own
1713 /// `repositories` field does not decide it.
1714 ///
1715 /// An item naming exactly one repository is created there; a task or a document naming
1716 /// none or several is created in its parent project's repository; and a project, or a
1717 /// task or document with no parent, naming none or several is created here. A board
1718 /// has no repository of its own and `createIssue` requires one, so a write without
1719 /// this is refused naming the field. Reads never need it.
1720 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1721 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1722 /// Environment variable containing a fine-grained token with Projects and Issues
1723 /// read/write plus Pull requests read-only access for every repository represented on
1724 /// the board.
1725 #[serde(default = "default_token_env")]
1726 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1727 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1728 #[serde(default = "default_endpoint")]
1729 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1730 /// Per-instance mapping from a status category to the option of the board's one
1731 /// `Status` field it lands on, for a task and for a project.
1732 ///
1733 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1734 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1735 /// where a kind left out leaves the category unmapped for that kind. A category this
1736 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1737 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1738 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1739 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1740 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1741 /// either kind. No two categories may name one option for the same kind, ignoring case.
1742 /// `unknown` may name one existing option; every unknown word then lands on it and
1743 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1744 /// each unknown word because it never creates board options.
1745 #[serde(default)]
1746 pub status_mapping: StatusMapping,
1747 /// Per-instance mapping from a task's priority to an option of this board's
1748 /// single-select field named `Priority`.
1749 ///
1750 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1751 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1752 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1753 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1754 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1755 /// no two levels may name one option. Reads and writes never create the field or an
1756 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1757 /// the board lacks is refused pointing there.
1758 #[serde(default)]
1759 pub priority_mapping: Option<PriorityMappingConfig>,
1760 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1761 ///
1762 /// Every field keeps its shipped default when it is absent, and the defaults are
1763 /// GitHub's own published limits rather than taste. See [`Pacing`].
1764 #[serde(default)]
1765 pub pacing: PacingConfig,
1766}
1767
1768/// Which option of the board's `Priority` field each priority lands on.
1769///
1770/// One member per level rather than a map, so a key that is not a level is refused where
1771/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1772/// value in the field, not an option of it.
1773#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1774#[serde(default, deny_unknown_fields)]
1775pub struct PriorityMappingConfig {
1776 /// The option `urgent` lands on; `Urgent` when absent.
1777 pub urgent: Option<PriorityOptionName>,
1778 /// The option `high` lands on; `High` when absent.
1779 pub high: Option<PriorityOptionName>,
1780 /// The option `medium` lands on; `Medium` when absent.
1781 pub medium: Option<PriorityOptionName>,
1782 /// The option `low` lands on; `Low` when absent.
1783 pub low: Option<PriorityOptionName>,
1784}
1785
1786/// The name of an option of the board's `Priority` single-select field.
1787///
1788/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1789/// blank name.
1790#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1791#[serde(try_from = "String")]
1792#[schemars(extend("minLength" = 1))]
1793pub struct PriorityOptionName(String);
1794
1795impl PriorityOptionName {
1796 /// The option name, as the board spells it.
1797 fn as_str(&self) -> &str {
1798 &self.0
1799 }
1800}
1801
1802impl TryFrom<String> for PriorityOptionName {
1803 type Error = String;
1804
1805 fn try_from(name: String) -> Result<Self, Self::Error> {
1806 if name.trim().is_empty() {
1807 return Err("a priority_mapping option name cannot be blank".to_owned());
1808 }
1809 Ok(Self(name))
1810 }
1811}
1812
1813/// The name of the board field a priority is held in.
1814pub const PRIORITY_FIELD: &str = "Priority";
1815
1816/// The four priorities a board option can hold, in the order a new `Priority` field lists
1817/// them. `none` is not among them: it is the field holding no value.
1818///
1819/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1820/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1821/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1822/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1823pub const PRIORITY_LEVELS: [Priority; 4] = [
1824 Priority::Urgent,
1825 Priority::High,
1826 Priority::Medium,
1827 Priority::Low,
1828];
1829
1830/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1831/// see that list for what this pins.
1832#[must_use]
1833pub const fn level_position(priority: Priority) -> Option<usize> {
1834 match priority {
1835 Priority::None => None,
1836 Priority::Urgent => Some(0),
1837 Priority::High => Some(1),
1838 Priority::Medium => Some(2),
1839 Priority::Low => Some(3),
1840 }
1841}
1842
1843/// This instance's complete priority-to-option mapping, read in both directions.
1844///
1845/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1846/// two levels name one option.
1847#[derive(Debug, Clone)]
1848struct PriorityMapping {
1849 options: [PriorityOptionName; 4],
1850}
1851
1852impl PriorityMapping {
1853 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1854 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1855 let mapping = Self {
1856 options: [
1857 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1858 config.high.unwrap_or_else(|| shipped("High")),
1859 config.medium.unwrap_or_else(|| shipped("Medium")),
1860 config.low.unwrap_or_else(|| shipped("Low")),
1861 ],
1862 };
1863 for (index, option) in mapping.options.iter().enumerate() {
1864 if let Some(earlier) = mapping.options[..index]
1865 .iter()
1866 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1867 {
1868 return Err(SourceError::Config {
1869 message: format!(
1870 "priority_mapping of source {instance} sends both {} and {} to the board \
1871 option {:?}; one option cannot read back as two priorities",
1872 PRIORITY_LEVELS[earlier],
1873 PRIORITY_LEVELS[index],
1874 option.as_str()
1875 ),
1876 });
1877 }
1878 }
1879 Ok(mapping)
1880 }
1881
1882 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1883 fn option(&self, priority: Priority) -> Option<&str> {
1884 level_position(priority).map(|index| self.options[index].as_str())
1885 }
1886
1887 /// The priority a board option name reports, or `None` when nothing maps to it.
1888 fn priority_of(&self, option: &str) -> Option<Priority> {
1889 self.options
1890 .iter()
1891 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1892 .map(|index| PRIORITY_LEVELS[index])
1893 }
1894
1895 /// Every mapped option name, in the order a new `Priority` field lists them.
1896 fn names(&self) -> impl Iterator<Item = &str> {
1897 self.options.iter().map(PriorityOptionName::as_str)
1898 }
1899}
1900
1901/// What one item's `Priority` field says, read through this instance's mapping.
1902#[derive(Debug, Clone, PartialEq, Eq)]
1903enum HeldPriority {
1904 /// A priority this source reports: an option the mapping names, or no value (`none`).
1905 Read(Priority),
1906 /// An option the mapping does not name, which is never read as a level or as `none`.
1907 Unmapped(String),
1908}
1909
1910/// How fast this source writes, and how long it waits out a rate-limit refusal.
1911///
1912/// Configurable because a GitHub Enterprise installation sets its own limits and an
1913/// operator who has already been refused may want to go slower still — not because the
1914/// defaults are guesses.
1915#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1916#[serde(default, deny_unknown_fields)]
1917pub struct PacingConfig {
1918 /// Shortest interval between two content-creating mutations, in milliseconds.
1919 ///
1920 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1921 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1922 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.
1923 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1924 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1925 /// zero while there is a budget to spend, because a schedule of zero-length waits
1926 /// consumes none of it and so never ends.
1927 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.
1928 /// Total time one call may spend waiting out rate limits, in milliseconds.
1929 ///
1930 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1931 /// the bound is what makes this a wait rather than a hang.
1932 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.
1933}
1934
1935/// The largest any pacing setting may be, in milliseconds.
1936///
1937/// One hour. GitHub's own harshest published bound on content-generating requests works
1938/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1939/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1940/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1941/// and an interval beyond it is a command that never sends its second mutation. It also
1942/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1943/// what an `Instant` can hold on every platform.
1944pub const MAX_PACING_MS: u64 = 3_600_000;
1945
1946/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1947/// source holds.
1948#[derive(Debug, Clone, Copy)]
1949struct Pacing {
1950 min_mutation_interval: Duration,
1951 retry_backoff: Duration,
1952 retry_budget: Duration,
1953}
1954
1955impl Pacing {
1956 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1957 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1958 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1959 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1960 message: format!(
1961 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1962 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1963 GitHub's own harshest published limit"
1964 ),
1965 }),
1966 Some(value) => Ok(Duration::from_millis(value)),
1967 None => Ok(Duration::from_millis(default)),
1968 };
1969 let retry_backoff = bounded(
1970 config.retry_backoff_ms,
1971 RETRY_BACKOFF_MS,
1972 "retry_backoff_ms",
1973 )?;
1974 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1975 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1976 return Err(SourceError::Config {
1977 message: format!(
1978 "pacing.retry_backoff_ms of source {instance} is 0 while \
1979 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1980 none of that budget, so it would retry a refusal forever. Set a backoff of \
1981 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1982 waiting at all",
1983 retry_budget.as_millis()
1984 ),
1985 });
1986 }
1987 Ok(Self {
1988 min_mutation_interval: bounded(
1989 config.min_mutation_interval_ms,
1990 MIN_MUTATION_INTERVAL_MS,
1991 "min_mutation_interval_ms",
1992 )?,
1993 retry_backoff,
1994 retry_budget,
1995 })
1996 }
1997}
1998
1999/// Factory for [`GitHubProjectsSource`].
2000#[derive(Debug, Clone, Copy, Default)]
2001pub struct Plugin;
2002
2003impl SourcePlugin for Plugin {
2004 fn kind(&self) -> &'static str {
2005 KIND
2006 }
2007 fn config_schema(&self) -> Schema {
2008 schema_for!(GitHubProjectsConfig)
2009 }
2010 fn build(
2011 &self,
2012 name: &SourceName,
2013 config: &Value,
2014 secrets: &dyn SecretResolver,
2015 ) -> Result<Box<dyn TaskSource>, SourceError> {
2016 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2017 }
2018}
2019
2020impl Plugin {
2021 /// Build a source recording every request it sends into an accounting the caller holds.
2022 ///
2023 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2024 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2025 /// session total rather than two — see [`accounting`] and
2026 /// [`GitHubProjectsSource::recording_into`].
2027 ///
2028 /// # Errors
2029 ///
2030 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2031 /// [`SourceError::Config`] for configuration this plugin cannot use and
2032 /// [`SourceError::Auth`] for a credential it cannot find.
2033 pub fn build_recording_into(
2034 &self,
2035 name: &SourceName,
2036 config: &Value,
2037 secrets: &dyn SecretResolver,
2038 ledger: Arc<Accounting>,
2039 ) -> Result<Box<dyn TaskSource>, SourceError> {
2040 let config: GitHubProjectsConfig =
2041 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2042 message: format!("source {name}: {e}"),
2043 })?;
2044 let prefix = format!("source {name}: ");
2045 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2046 |error| match error {
2047 // The shared `StatusMapping::distinct` names the source itself.
2048 SourceError::Config { message } if message.starts_with(&prefix) => {
2049 SourceError::Config { message }
2050 }
2051 SourceError::Config { message } => SourceError::Config {
2052 message: format!("{prefix}{message}"),
2053 },
2054 SourceError::Auth { message } => SourceError::Auth {
2055 message: format!("source {name}: {message}"),
2056 },
2057 other => other,
2058 },
2059 )?;
2060 Ok(Box::new(source))
2061 }
2062}
2063
2064/// Where a status category lands on this board, once configuration is resolved.
2065#[derive(Debug, Clone, PartialEq, Eq)]
2066enum StatusTarget {
2067 /// Not usable against this instance for this kind, and why.
2068 Disabled(UnmappedStatus),
2069 /// The board's `Status` option of this name.
2070 Column(ColumnName),
2071 /// A closed issue, with both its board option and the reason that says which closed it means.
2072 // 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, `BoardStatuses::resolve`, 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.
2073 Terminal(ColumnName, ClosedState),
2074}
2075
2076/// Every status category, in the order the vocabulary declares them.
2077///
2078/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2079/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2080/// added to the shared vocabulary fails to compile until it is named there, and this
2081/// crate's suite reconciles this list against that enum's own derived schema, which is
2082/// generated from the variants rather than written beside them. The schema is what
2083/// catches a list left one short — a list checking only the positions it already holds
2084/// would pass while every mapping indexed by the new position panicked.
2085pub const CATEGORIES: [StatusCategory; 8] = [
2086 StatusCategory::Draft,
2087 StatusCategory::Backlog,
2088 StatusCategory::Todo,
2089 StatusCategory::Queued,
2090 StatusCategory::InProgress,
2091 StatusCategory::Done,
2092 StatusCategory::Cancelled,
2093 StatusCategory::Unknown,
2094];
2095
2096/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2097#[must_use]
2098pub const fn category_position(category: StatusCategory) -> usize {
2099 match category {
2100 StatusCategory::Draft => 0,
2101 StatusCategory::Backlog => 1,
2102 StatusCategory::Todo => 2,
2103 StatusCategory::Queued => 3,
2104 StatusCategory::InProgress => 4,
2105 StatusCategory::Done => 5,
2106 StatusCategory::Cancelled => 6,
2107 StatusCategory::Unknown => 7,
2108 }
2109}
2110
2111/// The spelling a status category is configured and reported under.
2112fn category_name(category: StatusCategory) -> &'static str {
2113 match category {
2114 StatusCategory::Draft => "draft",
2115 StatusCategory::Backlog => "backlog",
2116 StatusCategory::Todo => "todo",
2117 StatusCategory::Queued => "queued",
2118 StatusCategory::InProgress => "in-progress",
2119 StatusCategory::Done => "done",
2120 StatusCategory::Cancelled => "cancelled",
2121 StatusCategory::Unknown => "unknown",
2122 }
2123}
2124
2125/// A shipped default's option name.
2126///
2127/// The literals below are this file's own and non-blank, and they are validated by the
2128/// one constructor a configured name goes through rather than beside it.
2129fn shipped_column(name: &'static str) -> ColumnName {
2130 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2131}
2132
2133/// The shipped default for one category this instance's `status_mapping` does not mention,
2134/// for either kind.
2135fn shipped_default(category: StatusCategory) -> StatusTarget {
2136 match category {
2137 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2138 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2139 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2140 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2141 StatusCategory::Done => {
2142 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2143 }
2144 StatusCategory::Cancelled => {
2145 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2146 }
2147 StatusCategory::Draft | StatusCategory::Unknown => {
2148 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2149 }
2150 }
2151}
2152
2153/// The two kinds a status is written and read for, each with its own half of the mapping.
2154const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2155
2156/// This instance's complete category-to-target mapping for each kind, read in both
2157/// directions.
2158///
2159/// One target per category per kind, held at that category's own [`category_position`], so
2160/// a category missing from the mapping, named twice in it, or filed out of order is a state
2161/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2162/// targets are options of the board's one `Status` field.
2163#[derive(Debug, Clone)]
2164struct BoardStatuses {
2165 tasks: [StatusTarget; CATEGORIES.len()],
2166 projects: [StatusTarget; CATEGORIES.len()],
2167}
2168
2169impl BoardStatuses {
2170 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2171 /// would read back from one option.
2172 ///
2173 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2174 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2175 /// omits unmapped rather than defaulted.
2176 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2177 let resolve_kind =
2178 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2179 // `CATEGORIES[position] == category` for every category — the crate's suite
2180 // asserts it — so mapping the list in order fills each category's own slot.
2181 let mut targets = CATEGORIES.map(shipped_default);
2182 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2183 if !configured.mentions(category) {
2184 continue;
2185 }
2186 *slot = match configured.name_for(category, kind) {
2187 Err(why) => StatusTarget::Disabled(why),
2188 Ok(name) => {
2189 let option = ColumnName::try_from(name.as_str().to_owned())
2190 .map_err(|message| SourceError::Config { message })?;
2191 match category {
2192 StatusCategory::Done => {
2193 StatusTarget::Terminal(option, ClosedState::Completed)
2194 }
2195 StatusCategory::Cancelled => {
2196 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2197 }
2198 _ => StatusTarget::Column(option),
2199 }
2200 }
2201 };
2202 }
2203 StatusMapping::distinct(
2204 instance,
2205 kind,
2206 CATEGORIES
2207 .iter()
2208 .zip(&targets)
2209 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2210 )?;
2211 Ok(targets)
2212 };
2213 Ok(Self {
2214 tasks: resolve_kind(ItemKind::Task)?,
2215 projects: resolve_kind(ItemKind::Project)?,
2216 })
2217 }
2218
2219 /// Every category's target for `kind`, in category order.
2220 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2221 match kind {
2222 ItemKind::Task => &self.tasks,
2223 ItemKind::Project => &self.projects,
2224 }
2225 }
2226
2227 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2228 &self.targets(kind)[category_position(category)]
2229 }
2230
2231 /// The category a board option name reports for `kind`, or `None` when nothing of that
2232 /// kind maps to it.
2233 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2234 CATEGORIES.into_iter().find(|category| {
2235 self.target(kind, *category)
2236 .option()
2237 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2238 })
2239 }
2240
2241 /// Every option name either kind maps a category to, each once ignoring case, in
2242 /// category order with a task's name before a project's — what the guarded setup asks
2243 /// the `Status` field to hold.
2244 fn wanted(&self) -> Vec<String> {
2245 let mut wanted: Vec<String> = Vec::new();
2246 for category in CATEGORIES {
2247 for kind in STATUS_KINDS {
2248 if let Some(name) = self.target(kind, category).option()
2249 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2250 {
2251 wanted.push(name.to_owned());
2252 }
2253 }
2254 }
2255 wanted
2256 }
2257
2258 /// The status an item of `kind` reports, from the three things a read of it says: its
2259 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2260 ///
2261 /// The closed state decides the category and the `Status` option decides the name, so
2262 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2263 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2264 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2265 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2266 /// it is read permissively rather than refused — reads are faithful, and refusals
2267 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2268 /// option that mapping does not name reads as `Unknown` under its own name.
2269 ///
2270 /// One function of those three rather than of a response, so a narrow status write can
2271 /// answer what a re-read would report by applying it to the state it has just written.
2272 fn status(
2273 &self,
2274 kind: ItemKind,
2275 option: Option<&str>,
2276 closed: bool,
2277 reason: Option<&str>,
2278 ) -> Status {
2279 if closed {
2280 let category = match reason {
2281 None | Some("COMPLETED") => StatusCategory::Done,
2282 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2283 Some(_) => StatusCategory::Unknown,
2284 };
2285 let fallback = match category {
2286 StatusCategory::Done => "Done",
2287 StatusCategory::Cancelled => "Cancelled",
2288 _ => "Closed",
2289 };
2290 return Status {
2291 category,
2292 name: option.unwrap_or(fallback).to_owned(),
2293 };
2294 }
2295 let name = option.unwrap_or("Open").to_owned();
2296 Status {
2297 category: self
2298 .category_of(kind, &name)
2299 .unwrap_or(StatusCategory::Unknown),
2300 name,
2301 }
2302 }
2303}
2304
2305impl BoardStatuses {
2306 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2307 /// case; a kind lacking none is left out.
2308 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2309 STATUS_KINDS
2310 .into_iter()
2311 .filter_map(|kind| {
2312 let missing: Vec<String> = self
2313 .targets(kind)
2314 .iter()
2315 .filter_map(StatusTarget::option)
2316 .filter(|wanted| {
2317 !existing
2318 .iter()
2319 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2320 })
2321 .map(str::to_owned)
2322 .collect();
2323 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2324 })
2325 .collect()
2326 }
2327}
2328
2329impl StatusTarget {
2330 /// The board option this target selects, or `None` for an unmapped one.
2331 fn option(&self) -> Option<&str> {
2332 match self {
2333 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2334 Self::Disabled(_) => None,
2335 }
2336 }
2337}
2338
2339// 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.
2340/// One repository this source can create an issue in, as `owner/name`.
2341///
2342/// Every `createIssue` this source sends names one of these: the item's own single
2343/// `repositories` entry, else its parent project issue's repository, else the configured
2344/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2345/// that choice and says what it refuses before `createIssue`.
2346// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2347#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2348struct RepositoryTarget {
2349 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2350 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2351}
2352
2353impl RepositoryTarget {
2354 fn parse(value: &str) -> Result<Self, SourceError> {
2355 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2356 message: format!(
2357 "repository must be spelled owner/name; {value:?} names no repository"
2358 ),
2359 })?;
2360 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2361 return Err(SourceError::Config {
2362 message: format!(
2363 "repository must be spelled owner/name with a GitHub login and one \
2364 repository name; {value:?} is not"
2365 ),
2366 });
2367 }
2368 Ok(Self {
2369 owner: owner.to_owned(),
2370 name: name.to_owned(),
2371 })
2372 }
2373
2374 /// The one host whose repositories this source creates issues in, spelled once: it is
2375 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2376 const HOST: &str = "github.com";
2377
2378 fn origin(&self) -> String {
2379 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2380 }
2381
2382 /// The repository a normalized origin names, or why it is none this source can create
2383 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2384 fn from_origin(origin: &Repository) -> Result<Self, String> {
2385 let not_here = || {
2386 format!(
2387 "{} is not a {}/owner/name repository",
2388 origin.as_str(),
2389 Self::HOST
2390 )
2391 };
2392 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2393 if host != Self::HOST {
2394 return Err(not_here());
2395 }
2396 Self::parse(rest).map_err(|_| not_here())
2397 }
2398
2399 fn slug(&self) -> String {
2400 format!("{}/{}", self.owner, self.name)
2401 }
2402}
2403
2404/// A source which reads GitHub afresh for every operation.
2405pub struct GitHubProjectsSource {
2406 /// This source's configured name, used both to tell a far end naming this source
2407 /// from one naming a system it knows nothing about, and to name the instance a
2408 /// status refusal is about.
2409 name: SourceName,
2410 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2411 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2412 repository: Option<RepositoryTarget>,
2413 endpoint: Url,
2414 token: SecretString,
2415 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2416 statuses: BoardStatuses,
2417 /// Where each priority lands on this board, or `None` when this instance holds none.
2418 priorities: Option<PriorityMapping>,
2419 client: Client,
2420 /// Every item this source has created in this command, in the order it created them —
2421 /// dropped by [`TaskSource::end_command`].
2422 ///
2423 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2424 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2425 /// a copy resolving a dependency on an item it had just created refused it as not
2426 /// found. A board read is completed from this — an item remembered here and absent from
2427 /// the read is added back, because the board really does hold it and only the read is
2428 /// behind.
2429 ///
2430 /// It is not a cache of a user's work: nothing is remembered that this process did not
2431 /// itself just write, it lives and dies with the process, and it is never consulted for
2432 /// an item this source did not create.
2433 created: Mutex<Vec<Resolved>>,
2434 /// Every item that already existed and that this source has written in this command, as
2435 /// it wrote it — dropped by [`TaskSource::end_command`].
2436 ///
2437 /// The other half of [`Self::created`], held on the same terms and for the reason a
2438 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2439 /// filter is an index behind a write this process made moments ago, so a query matching
2440 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2441 /// is remembered that this process did not itself just write.
2442 updated: Mutex<Vec<Resolved>>,
2443 /// Every issue this source has added a comment to or edited a comment of in this command
2444 /// — dropped by [`TaskSource::end_command`].
2445 ///
2446 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
2447 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
2448 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
2449 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
2450 /// from before — until the index caught up. Each id here is a candidate of every such
2451 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
2452 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
2453 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
2454 /// process wrote is still found only once the index has it.
2455 commented: Mutex<Vec<NativeId>>,
2456 /// How fast this source writes, and how long it waits out a refusal.
2457 pacing: Pacing,
2458 /// When the last content-creating mutation finished, or the moment the furthest-out
2459 /// reserved slot releases the next one, whichever is later — so the one after it can be
2460 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2461 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2462 /// what it is measured from.
2463 last_mutation: Mutex<Option<Instant>>,
2464 /// The board as this process last read it, for the length of one command — dropped by
2465 /// [`TaskSource::end_command`].
2466 ///
2467 /// A copy of a project used to re-read the whole board, paged, before writing each of
2468 /// its items, which is by far the largest part of a copy's request count and none of
2469 /// its work. Nothing else changes this board while a command runs — this source's own
2470 /// writes are the only writer — so one read answers them all.
2471 ///
2472 /// It is not a store of a user's work and it is not the cache the no-persistence
2473 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2474 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2475 /// an item this command created and then depends on resolves whether or not GitHub's
2476 /// own eventually-consistent read has caught up. A write to an item already on the
2477 /// board updates the entry here too, so what this holds is the last read plus this
2478 /// process's own writes rather than a snapshot taken before them.
2479 board_cache: Mutex<Option<Board>>,
2480 /// Every issue this board's own search reported, for the length of one command — dropped
2481 /// by [`TaskSource::end_command`].
2482 ///
2483 /// The second half of a board read, and cached for the same reason and on the same
2484 /// terms as the first: it lives and dies with the process, nothing is written down, and
2485 /// a write this process makes updates the entry here exactly as it updates the one in
2486 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2487 /// that lists this board's projects and its tasks pays for one search rather than two.
2488 search_cache: Mutex<Option<Vec<Resolved>>>,
2489 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2490 /// the length of one command — dropped by [`TaskSource::end_command`].
2491 ///
2492 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2493 /// and dies with the process, nothing is written down, a write this process makes
2494 /// updates the entry here as it updates the other two, and every answer is completed
2495 /// with this process's own writes each time it is given. A command that asks the same
2496 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2497 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2498 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2499 search_next: Mutex<BTreeMap<String, Option<String>>>,
2500 /// Records already resolved in this command, reused by writes and for comment identity.
2501 /// Explicit item reads still reach GitHub. Nothing is persisted, and
2502 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2503 /// its item as a person has since left it.
2504 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2505 /// The board's own id and field definitions as this process last read them on their
2506 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2507 ///
2508 /// What a write needs of the board and its item does not say, read once per command
2509 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2510 /// lives and dies with the process and nothing is written down. It holds no item and so
2511 /// can answer no question about one — see [`Self::board_fields`].
2512 fields_cache: Mutex<Option<BoardFields>>,
2513 /// Each destination repository's node id, resolved once per repository
2514 /// rather than per issue created.
2515 ///
2516 /// A repository's node id does not change, and re-reading it for every issue of a copy
2517 /// spent one request per item on an answer this source already had. It is a map rather
2518 /// than one entry because a copy files each item in the repository its own
2519 /// `repositories` field names, so a plan across five repositories asks GitHub five
2520 /// times and not once per item.
2521 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2522 /// What every request this source sends is recorded into.
2523 ///
2524 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2525 /// a request leaves this crate, so nothing has to be switched on for a session to be
2526 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2527 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2528 /// source's reads and writes — adds up one accounting instead of two. See
2529 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2530 ledger: Arc<Accounting>,
2531}
2532
2533/// GitHub's closed single-select color vocabulary.
2534#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2535#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2536pub enum StatusOptionColor {
2537 /// Gray.
2538 Gray,
2539 /// Blue.
2540 Blue,
2541 /// Green.
2542 Green,
2543 /// Yellow.
2544 Yellow,
2545 /// Purple.
2546 Purple,
2547 /// Red.
2548 Red,
2549 /// Orange.
2550 Orange,
2551 /// Pink.
2552 Pink,
2553}
2554
2555/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2556/// applies its additions.
2557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2558pub enum SetupMode {
2559 /// Read without mutation.
2560 Plan,
2561 /// Apply and verify.
2562 Apply,
2563}
2564
2565/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2566/// against it goes on compiling.
2567pub type StatusOptionsMode = SetupMode;
2568
2569/// The explicit result of the requested operation.
2570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2571#[serde(rename_all = "kebab-case")]
2572pub enum StatusOptionsOutcome {
2573 /// A read-only plan.
2574 Planned,
2575 /// Apply found nothing missing.
2576 Unchanged,
2577 /// Additions were applied and verified.
2578 Applied,
2579}
2580
2581/// A GitHub single-select option's opaque GraphQL node identifier.
2582#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2583#[serde(transparent)]
2584pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2585
2586impl TryFrom<String> for StatusOptionId {
2587 type Error = String;
2588
2589 fn try_from(id: String) -> Result<Self, Self::Error> {
2590 if id.trim().is_empty() {
2591 return Err("a GitHub Status option id cannot be blank".to_owned());
2592 }
2593 Ok(Self(id))
2594 }
2595}
2596
2597/// One existing or proposed option in a guarded Status-field update.
2598#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2599pub struct StatusOption {
2600 /// GitHub's stable id.
2601 pub id: StatusOptionId,
2602 /// The visible option name.
2603 pub name: ColumnName,
2604 /// GitHub's single-select color token.
2605 pub color: StatusOptionColor,
2606 /// The option description, including an empty one.
2607 pub description: String,
2608}
2609
2610/// One board item's Status assignment, retained as recovery data.
2611#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2612pub struct StatusAssignment {
2613 /// The project item id whose assignment this is.
2614 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2615 // carried verbatim as operator recovery data; introducing a semantic type would claim
2616 // validation rules GitHub does not publish and no operation here interprets.
2617 pub item_id: String,
2618 /// The selected option, absent when the item has no status.
2619 #[serde(skip_serializing_if = "Option::is_none")]
2620 pub option: Option<AssignedStatusOption>,
2621}
2622
2623/// The inseparable id and name of an assigned option.
2624#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2625pub struct AssignedStatusOption {
2626 /// GitHub's stable id.
2627 pub id: StatusOptionId,
2628 /// The visible name.
2629 pub name: ColumnName,
2630}
2631
2632/// The plan and verified outcome of reconciling configured Status options.
2633#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2634pub struct StatusOptionsReport {
2635 /// The configured source name.
2636 pub source: SourceName,
2637 /// Configured option names absent before the operation.
2638 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2639 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2640 // serialized string here preserves the report's intentionally simple public contract.
2641 pub missing: Vec<String>,
2642 /// What the requested operation did.
2643 pub outcome: StatusOptionsOutcome,
2644 /// The complete option list observed before any mutation.
2645 pub existing: Vec<StatusOption>,
2646}
2647
2648#[derive(Debug, Clone, PartialEq, Eq)]
2649struct StatusSnapshot {
2650 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2651 // passed back as the mutation's project identity; a newtype could enforce no stronger
2652 // invariant because GitHub publishes no grammar for it.
2653 board_id: String,
2654 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2655 // passed back as the mutation's field identity; a newtype could enforce no stronger
2656 // invariant because GitHub publishes no grammar for it.
2657 field_id: String,
2658 options: Vec<StatusOption>,
2659 assignments: Vec<StatusAssignment>,
2660}
2661
2662/// The name of the board field a status is held in.
2663const STATUS_FIELD: &str = "Status";
2664
2665/// Every item's value of each field `report` names, as it stood before the setup wrote
2666/// anything — what a person puts back when the setup is refused part way.
2667fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2668 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2669 .fields
2670 .iter()
2671 .map(|field| (field.field.name(), before.assignments(field.field)))
2672 .collect();
2673 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2674 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2675 })
2676}
2677
2678/// One board field the guarded setup reads and writes — every one it reads, and the only
2679/// ones it writes.
2680#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2681pub enum BoardField {
2682 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2683 Status,
2684 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2685 Priority,
2686}
2687
2688impl BoardField {
2689 /// The field's name on the board.
2690 #[must_use]
2691 pub const fn name(self) -> &'static str {
2692 match self {
2693 Self::Status => STATUS_FIELD,
2694 Self::Priority => PRIORITY_FIELD,
2695 }
2696 }
2697
2698 /// The field a board calls `name`, or `None` for one this setup does not own.
2699 fn named(name: &str) -> Option<Self> {
2700 [Self::Status, Self::Priority]
2701 .into_iter()
2702 .find(|field| field.name() == name)
2703 }
2704}
2705
2706/// What the guarded setup did to one field.
2707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2708#[serde(rename_all = "kebab-case")]
2709pub enum FieldOutcome {
2710 /// A read-only plan.
2711 Planned,
2712 /// Apply found the field there with every configured option.
2713 Unchanged,
2714 /// Missing options were added to the field that was there, and verified.
2715 Applied,
2716 /// The field was not there; it was created holding the configured options, and verified.
2717 Created,
2718}
2719
2720/// One field's plan, or its verified outcome.
2721#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2722pub struct FieldReport {
2723 /// Which field.
2724 pub field: BoardField,
2725 /// Whether the board had the field before the operation.
2726 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2727 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2728 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2729 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2730 // one constructor, and it derives `outcome` from `exists` in one match.
2731 pub exists: bool,
2732 /// Configured option names the field lacked before the operation — every one of them,
2733 /// in the order a new field lists them, when the field was not there at all.
2734 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2735 // mapping name and has therefore already passed its nonblank validation; the serialized
2736 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2737 pub missing: Vec<String>,
2738 /// For the `Status` field, which item kind each missing name is configured for: one
2739 /// entry per kind `status_mapping` names a missing option for, task before project, each
2740 /// listing that kind's missing names in category order. A name both kinds use is in
2741 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2742 /// `Priority`, which only a task holds.
2743 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2744 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2745 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2746 #[schemars(!skip_serializing_if)]
2747 pub kinds: Vec<KindMissing>,
2748 /// What the requested operation did.
2749 pub outcome: FieldOutcome,
2750 /// The field's complete option list observed before any mutation; empty when the field
2751 /// was not there.
2752 pub existing: Vec<StatusOption>,
2753}
2754
2755/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2756#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2757pub struct KindMissing {
2758 /// The kind these names are configured for.
2759 pub kind: ItemKind,
2760 /// The names that kind maps a category to and the field lacked, in category order.
2761 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2762 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2763 // intentionally simple public contract.
2764 pub missing: Vec<String>,
2765}
2766
2767/// The plan and verified outcome of setting up every field a source's configuration names.
2768#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2769pub struct FieldsReport {
2770 /// The configured source name.
2771 pub source: SourceName,
2772 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2773 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2774 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2775 // per field would change a published JSON shape. The states the list could hold and the
2776 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2777 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2778 pub fields: Vec<FieldReport>,
2779}
2780
2781/// Which options one field is configured with, in the order a new field would list them.
2782struct FieldPlan {
2783 field: BoardField,
2784 wanted: Vec<String>,
2785}
2786
2787/// One single-select field as the guarded setup snapshots it.
2788#[derive(Debug, Clone, PartialEq, Eq)]
2789struct SnapshotField {
2790 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2791 // passed back as the mutation's field identity; a newtype could enforce no stronger
2792 // invariant because GitHub publishes no grammar for it.
2793 field_id: String,
2794 options: Vec<StatusOption>,
2795}
2796
2797/// Every single-select field of a board and every item's value of each.
2798#[derive(Debug, Clone, PartialEq, Eq)]
2799struct BoardSnapshot {
2800 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2801 // passed back as the mutation's project identity; a newtype could enforce no stronger
2802 // invariant because GitHub publishes no grammar for it.
2803 board_id: String,
2804 fields: BTreeMap<BoardField, SnapshotField>,
2805 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2806 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2807}
2808
2809impl BoardSnapshot {
2810 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2811 /// carries.
2812 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2813 self.items
2814 .iter()
2815 .map(|(item_id, values)| StatusAssignment {
2816 item_id: item_id.clone(),
2817 option: values.get(&field).cloned(),
2818 })
2819 .collect()
2820 }
2821}
2822
2823impl GitHubProjectsSource {
2824 /// Report missing configured Status options and, when `apply` is true, add them with
2825 /// a whole-list mutation that preserves every existing id and verifies the result.
2826 ///
2827 /// # Errors
2828 ///
2829 /// Refuses a board without a single-select `Status` field. A post-write difference in
2830 /// any pre-existing option id or item assignment is refused with the complete pre-write
2831 /// assignment snapshot in the diagnostic for recovery.
2832 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2833 // successful mutation, both drift refusals, source selection, missing Status, casing,
2834 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2835 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2836 // responses from entering the defensive malformed-response branches below.
2837 pub async fn status_options(
2838 &self,
2839 mode: StatusOptionsMode,
2840 ) -> Result<StatusOptionsReport, SourceError> {
2841 let before = self.status_snapshot().await?;
2842 // A terminal category's option is as configured as an open one's: a terminal
2843 // write validates it before closing and refuses when the board lacks it. Both
2844 // kinds' names are options of the one field, so both are asked for.
2845 let missing = self
2846 .statuses
2847 .wanted()
2848 .into_iter()
2849 .filter(|wanted| {
2850 !before
2851 .options
2852 .iter()
2853 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2854 })
2855 .collect::<Vec<_>>();
2856 let report = StatusOptionsReport {
2857 source: self.name.clone(),
2858 missing: missing.clone(),
2859 outcome: match (mode, missing.is_empty()) {
2860 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2861 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2862 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2863 },
2864 existing: before.options.clone(),
2865 };
2866 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2867 return Ok(report);
2868 }
2869 let mut options = before
2870 .options
2871 .iter()
2872 .map(|option| {
2873 json!({
2874 "id": option.id, "name": option.name, "color": option.color,
2875 "description": option.description,
2876 })
2877 })
2878 .collect::<Vec<_>>();
2879 options.extend(missing.iter().map(|name| {
2880 json!({
2881 "name": name, "color": "GRAY", "description": ""
2882 })
2883 }));
2884 self.graphql(
2885 graphql::STATUS_OPTIONS_UPDATE,
2886 json!({"input": {
2887 "projectId": before.board_id, "fieldId": before.field_id,
2888 "singleSelectOptions": options,
2889 }}),
2890 )
2891 .await?;
2892 let after = self.status_snapshot().await?;
2893 let options_preserved = before
2894 .options
2895 .iter()
2896 .all(|old| after.options.iter().any(|new| new == old));
2897 let additions_present = missing.iter().all(|wanted| {
2898 after
2899 .options
2900 .iter()
2901 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2902 });
2903 if !options_preserved || !additions_present || after.assignments != before.assignments {
2904 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2905 SourceError::Malformed {
2906 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2907 }
2908 })?;
2909 return Err(SourceError::Refused {
2910 message: format!(
2911 "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}"
2912 ),
2913 });
2914 }
2915 Ok(report)
2916 }
2917
2918 /// A fresh snapshot of the Status field and every board item's assignment of it.
2919 ///
2920 /// # Errors
2921 ///
2922 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2923 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2924 // Status alone, as this operation has always read it: a `Priority` field is another
2925 // operation's, so nothing about it can refuse this one.
2926 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2927 let field = board
2928 .fields
2929 .remove(&BoardField::Status)
2930 .ok_or_else(|| self.no_status_field())?;
2931 Ok(StatusSnapshot {
2932 assignments: board.assignments(BoardField::Status),
2933 board_id: board.board_id,
2934 field_id: field.field_id,
2935 options: field.options,
2936 })
2937 }
2938
2939 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2940 fn no_status_field(&self) -> SourceError {
2941 SourceError::Refused {
2942 message: format!("source {} board has no Status field", self.name),
2943 }
2944 }
2945
2946 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2947 // the real CLI loopback journey, including pagination. The individual malformed guards
2948 // are defensive validation of a schema-pinned third-party response, not separate user
2949 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2950 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2951 /// every board item's value of each, walked to the end of the board's items. A field not
2952 /// in `owned` is read past whatever it holds.
2953 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2954 let mut after: Option<String> = None;
2955 let mut snapshot: Option<BoardSnapshot> = None;
2956 loop {
2957 let data = self
2958 .graphql(
2959 graphql::STATUS_OPTIONS_SNAPSHOT,
2960 json!({
2961 "owner": self.owner, "number": self.project_number,
2962 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2963 }),
2964 )
2965 .await?;
2966 let board = data
2967 .pointer("/owner/projectV2")
2968 .filter(|board| board.is_object())
2969 .ok_or_else(|| SourceError::Refused {
2970 message: format!(
2971 "source {} has no accessible GitHub Projects board",
2972 self.name
2973 ),
2974 })?;
2975 if board
2976 .pointer("/fields/pageInfo/hasNextPage")
2977 .and_then(Value::as_bool)
2978 != Some(false)
2979 {
2980 return Err(SourceError::Malformed {
2981 message:
2982 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2983 .into(),
2984 });
2985 }
2986 let mut fields = BTreeMap::new();
2987 // Only the fields this setup owns, by name: a node the single-select fragment did not
2988 // match carries no name, and a person's own single-select field — a `Size`, a
2989 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2990 // `Status` or `Priority` field without its options is malformed, not absent.
2991 // 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.
2992 for (owned, field) in board
2993 .pointer("/fields/nodes")
2994 .and_then(Value::as_array)
2995 .ok_or_else(|| SourceError::Malformed {
2996 message: "GitHub project fields.nodes is not an array".into(),
2997 })?
2998 .iter()
2999 .filter_map(|field| {
3000 let named = BoardField::named(field.get("name")?.as_str()?)?;
3001 owned.contains(&named).then_some((named, field))
3002 })
3003 {
3004 let options = field
3005 .get("options")
3006 .and_then(Value::as_array)
3007 .ok_or_else(|| SourceError::Malformed {
3008 message: "GitHub single-select field options is not an array".into(),
3009 })?
3010 .iter()
3011 .map(|option| {
3012 Ok(StatusOption {
3013 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3014 .map_err(|message| SourceError::Malformed { message })?,
3015 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3016 .map_err(|message| SourceError::Malformed {
3017 message: format!(
3018 "GitHub single-select option name is invalid: {message}"
3019 ),
3020 })?,
3021 color: serde_json::from_value(
3022 option.get("color").cloned().unwrap_or(Value::Null),
3023 )
3024 .map_err(|error| {
3025 SourceError::Malformed {
3026 message: format!(
3027 "GitHub single-select option color is invalid: {error}"
3028 ),
3029 }
3030 })?,
3031 description: optional_str(option, "description")?
3032 .unwrap_or_default()
3033 .to_owned(),
3034 })
3035 })
3036 .collect::<Result<Vec<_>, SourceError>>()?;
3037 let snapshot = SnapshotField {
3038 field_id: required_nonblank_str(field, "id")?.to_owned(),
3039 options,
3040 };
3041 // A board's field names are unique, so a second one is an answer that cannot
3042 // say which field the setup would act on — refused rather than one chosen.
3043 if fields.insert(owned, snapshot).is_some() {
3044 return Err(SourceError::Malformed {
3045 message: format!(
3046 "GitHub answered two {} fields for this board",
3047 owned.name()
3048 ),
3049 });
3050 }
3051 }
3052 let board_id = required_nonblank_str(board, "id")?.to_owned();
3053 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3054 board_id,
3055 fields,
3056 items: Vec::new(),
3057 });
3058 let items = board
3059 .pointer("/items/nodes")
3060 .and_then(Value::as_array)
3061 .ok_or_else(|| SourceError::Malformed {
3062 message: "GitHub project items.nodes is not an array".into(),
3063 })?;
3064 for item in items {
3065 let field_values =
3066 item.get("fieldValues")
3067 .ok_or_else(|| SourceError::Malformed {
3068 message: "GitHub project item is missing fieldValues".into(),
3069 })?;
3070 if field_values
3071 .pointer("/pageInfo/hasNextPage")
3072 .and_then(Value::as_bool)
3073 != Some(false)
3074 {
3075 return Err(SourceError::Malformed {
3076 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3077 });
3078 }
3079 let values = item
3080 .pointer("/fieldValues/nodes")
3081 .and_then(Value::as_array)
3082 .ok_or_else(|| SourceError::Malformed {
3083 message: "GitHub project item fieldValues.nodes is not an array".into(),
3084 })?;
3085 let item_id = required_nonblank_str(item, "id")?;
3086 let mut assigned = BTreeMap::new();
3087 for value in values {
3088 let Some(field) = value
3089 .pointer("/field/name")
3090 .and_then(Value::as_str)
3091 .and_then(BoardField::named)
3092 .filter(|field| owned.contains(field))
3093 else {
3094 continue;
3095 };
3096 let held = assigned.insert(
3097 field,
3098 AssignedStatusOption {
3099 id: StatusOptionId::try_from(
3100 required_str(value, "optionId")?.to_owned(),
3101 )
3102 .map_err(|message| SourceError::Malformed { message })?,
3103 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3104 .map_err(|message| SourceError::Malformed {
3105 message: format!(
3106 "GitHub assigned {} name is invalid: {message}",
3107 field.name()
3108 ),
3109 })?,
3110 },
3111 );
3112 // An item holds one value of a field, so a second one leaves no way to
3113 // tell which it holds — and a verification or recovery built on either
3114 // could restore the wrong one.
3115 if held.is_some() {
3116 return Err(SourceError::Malformed {
3117 message: format!(
3118 "GitHub answered two {} values for board item {item_id}",
3119 field.name()
3120 ),
3121 });
3122 }
3123 }
3124 current.items.push((item_id.to_owned(), assigned));
3125 }
3126 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3127 message: "GitHub project is missing items".into(),
3128 })?;
3129 let has_next = page
3130 .pointer("/pageInfo/hasNextPage")
3131 .and_then(Value::as_bool)
3132 .ok_or_else(|| SourceError::Malformed {
3133 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3134 })?;
3135 if !has_next {
3136 break;
3137 }
3138 let next =
3139 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3140 validate_cursor_progress(after.as_deref(), next)?;
3141 after = Some(next.to_owned());
3142 }
3143 snapshot.ok_or_else(|| SourceError::Malformed {
3144 message: "GitHub returned no board field snapshot".into(),
3145 })
3146 }
3147 // llmlint: ignore-end[changed_behavior_has_e2e]
3148
3149 /// Report every board field this source's configuration names and, with
3150 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3151 /// the `Priority` field when the board has none.
3152 ///
3153 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3154 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3155 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3156 /// color and description: the whole option list goes back with every existing id, because
3157 /// a re-minted id clears every item's value.
3158 ///
3159 /// # Errors
3160 ///
3161 /// Refuses a board without a single-select `Status` field. After an apply the board is
3162 /// read again, and a pre-existing option or any item's value of either field that moved is
3163 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3164 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3165 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3166 // with no Status field and a non-github-projects source through the compiled CLI against
3167 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3168 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3169 let owned: Vec<BoardField> = if self.priorities.is_some() {
3170 vec![BoardField::Status, BoardField::Priority]
3171 } else {
3172 vec![BoardField::Status]
3173 };
3174 let before = self.board_snapshot(&owned).await?;
3175 let mut plans = vec![FieldPlan {
3176 field: BoardField::Status,
3177 wanted: self.statuses.wanted(),
3178 }];
3179 if !before.fields.contains_key(&BoardField::Status) {
3180 return Err(self.no_status_field());
3181 }
3182 if let Some(mapping) = &self.priorities {
3183 plans.push(FieldPlan {
3184 field: BoardField::Priority,
3185 wanted: mapping.names().map(str::to_owned).collect(),
3186 });
3187 }
3188 // The snapshot reads single-select fields alone, so a field it did not find may still
3189 // be on the board under the name, of another type: creating one beside it would fail
3190 // part way, or leave two fields of one name. Asked of the board's own field list, and
3191 // only when a field is missing.
3192 if plans
3193 .iter()
3194 .any(|plan| !before.fields.contains_key(&plan.field))
3195 {
3196 let board = self.board_fields().await?;
3197 for plan in plans
3198 .iter()
3199 .filter(|plan| !before.fields.contains_key(&plan.field))
3200 {
3201 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3202 return Err(SourceError::Refused {
3203 message: format!(
3204 "source {}'s board has a {} field that is not a single-select field \
3205 (it is a {}), so it cannot hold this source's options; next: rename \
3206 or remove that field, then run this again",
3207 self.name,
3208 plan.field.name(),
3209 optional_str(field, "__typename")?.unwrap_or("field of another type")
3210 ),
3211 });
3212 }
3213 }
3214 }
3215 let mut reports = Vec::new();
3216 for plan in &plans {
3217 let held = before.fields.get(&plan.field);
3218 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3219 let mut missing: Vec<String> = Vec::new();
3220 for wanted in &plan.wanted {
3221 let present = existing
3222 .iter()
3223 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3224 || missing
3225 .iter()
3226 .any(|named| named.eq_ignore_ascii_case(wanted));
3227 if !present {
3228 missing.push(wanted.clone());
3229 }
3230 }
3231 let kinds = match plan.field {
3232 BoardField::Status => self.statuses.missing_by_kind(&existing),
3233 BoardField::Priority => Vec::new(),
3234 };
3235 reports.push(FieldReport {
3236 field: plan.field,
3237 exists: held.is_some(),
3238 kinds,
3239 outcome: match (mode, held.is_some(), missing.is_empty()) {
3240 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3241 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3242 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3243 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3244 },
3245 missing,
3246 existing,
3247 });
3248 }
3249 let report = FieldsReport {
3250 source: self.name.clone(),
3251 fields: reports,
3252 };
3253 let writes: Vec<&FieldReport> = report
3254 .fields
3255 .iter()
3256 .filter(|field| !field.missing.is_empty() || !field.exists)
3257 .collect();
3258 if mode == SetupMode::Plan || writes.is_empty() {
3259 return Ok(report);
3260 }
3261 let mut landed: Vec<&str> = Vec::new();
3262 for field in &writes {
3263 let added = field
3264 .missing
3265 .iter()
3266 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3267 let sent = match before.fields.get(&field.field) {
3268 Some(held) => {
3269 let mut options = held
3270 .options
3271 .iter()
3272 .map(|option| {
3273 json!({
3274 "id": option.id, "name": option.name, "color": option.color,
3275 "description": option.description,
3276 })
3277 })
3278 .collect::<Vec<_>>();
3279 options.extend(added);
3280 self.graphql(
3281 graphql::STATUS_OPTIONS_UPDATE,
3282 json!({"input": {
3283 "projectId": before.board_id, "fieldId": held.field_id,
3284 "singleSelectOptions": options,
3285 }}),
3286 )
3287 .await
3288 }
3289 None => {
3290 self.graphql(
3291 graphql::CREATE_FIELD,
3292 json!({"input": {
3293 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3294 "name": field.field.name(),
3295 "singleSelectOptions": added.collect::<Vec<_>>(),
3296 }}),
3297 )
3298 .await
3299 }
3300 };
3301 // A mutation that failed does not establish that GitHub left its field as it was,
3302 // so every failure from here on carries the recovery data a drift refusal does.
3303 match sent {
3304 Ok(_) => landed.push(field.field.name()),
3305 Err(error) => {
3306 let changed = if landed.is_empty() {
3307 String::new()
3308 } else {
3309 format!("changed the {} field and then ", landed.join(" and "))
3310 };
3311 return Err(SourceError::Refused {
3312 message: format!(
3313 "the guarded field setup {changed}failed on the {} field, which it may \
3314 have changed part way: {error}; the pre-write item assignments \
3315 are:\n{}",
3316 field.field.name(),
3317 recovery(&report, &before)?
3318 ),
3319 });
3320 }
3321 }
3322 }
3323 // The board has been written, so a verification read that fails leaves it unverified
3324 // rather than unchanged, and says what to put back.
3325 let after = match self.board_snapshot(&owned).await {
3326 Ok(after) => after,
3327 Err(error) => {
3328 return Err(SourceError::Refused {
3329 message: format!(
3330 "the guarded field setup changed the {} field and then could not read the \
3331 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3332 landed.join(" and "),
3333 recovery(&report, &before)?
3334 ),
3335 });
3336 }
3337 };
3338 let mut moved = Vec::new();
3339 for field in &report.fields {
3340 let name = field.field.name();
3341 let now = after
3342 .fields
3343 .get(&field.field)
3344 .map(|held| held.options.as_slice())
3345 .unwrap_or_default();
3346 if !field.existing.iter().all(|old| now.contains(old)) {
3347 moved.push(format!(
3348 "a pre-existing {name} option id, name, color or description"
3349 ));
3350 }
3351 if !field.missing.iter().all(|wanted| {
3352 now.iter()
3353 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3354 }) {
3355 moved.push(format!("an added {name} option"));
3356 }
3357 if after.assignments(field.field) != before.assignments(field.field) {
3358 moved.push(format!("an item's {name} value"));
3359 }
3360 }
3361 if !moved.is_empty() {
3362 return Err(SourceError::Refused {
3363 message: format!(
3364 "GitHub changed {} after the guarded field setup; the pre-write item \
3365 assignments are:\n{}",
3366 moved.join(", "),
3367 recovery(&report, &before)?
3368 ),
3369 });
3370 }
3371 Ok(report)
3372 }
3373
3374 /// Validate configuration and capture the named credential without exposing it.
3375 ///
3376 /// # Errors
3377 ///
3378 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3379 /// [`SourceError::Auth`] when the named credential is missing or empty.
3380 pub fn new(
3381 name: &SourceName,
3382 config: GitHubProjectsConfig,
3383 secrets: &dyn SecretResolver,
3384 ) -> Result<Self, SourceError> {
3385 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3386 }
3387
3388 /// The same, recording every request it sends into an accounting the caller holds too.
3389 ///
3390 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3391 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3392 /// up — passes the one it records those into, so the session total accounts for the
3393 /// whole session rather than for this source's share of it.
3394 ///
3395 /// # Errors
3396 ///
3397 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3398 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3399 pub fn recording_into(
3400 name: &SourceName,
3401 config: GitHubProjectsConfig,
3402 secrets: &dyn SecretResolver,
3403 ledger: Arc<Accounting>,
3404 ) -> Result<Self, SourceError> {
3405 if !valid_github_owner(&config.owner) {
3406 return Err(SourceError::Config {
3407 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3408 });
3409 }
3410 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3411 return Err(SourceError::Config {
3412 message: format!("project_number must be between 1 and {}", i32::MAX),
3413 });
3414 }
3415 if !valid_environment_name(&config.token_env) {
3416 return Err(SourceError::Config {
3417 message: "token_env must be a valid environment-variable name".into(),
3418 });
3419 }
3420 let repository = config
3421 .repository
3422 .as_deref()
3423 .map(RepositoryTarget::parse)
3424 .transpose()?;
3425 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3426 message: format!("endpoint is not a valid URL: {e}"),
3427 })?;
3428 if endpoint.scheme() != "https"
3429 && !(endpoint.scheme() == "http"
3430 && endpoint
3431 .host_str()
3432 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3433 {
3434 return Err(SourceError::Config {
3435 message:
3436 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3437 .into(),
3438 });
3439 }
3440 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3441 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),
3442 })?;
3443 Ok(Self {
3444 name: name.clone(),
3445 owner: config.owner,
3446 project_number: config.project_number,
3447 repository,
3448 endpoint,
3449 token,
3450 credential_name: config.token_env,
3451 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3452 priorities: config
3453 .priority_mapping
3454 .map(|mapping| PriorityMapping::resolve(mapping, name))
3455 .transpose()?,
3456 client: Client::builder()
3457 .user_agent("onetaskgraph")
3458 .build()
3459 .map_err(|e| SourceError::Config {
3460 message: format!("cannot build HTTP client: {e}"),
3461 })?,
3462 created: Mutex::new(Vec::new()),
3463 updated: Mutex::new(Vec::new()),
3464 commented: Mutex::new(Vec::new()),
3465 pacing: Pacing::resolve(config.pacing, name)?,
3466 last_mutation: Mutex::new(None),
3467 board_cache: Mutex::new(None),
3468 search_cache: Mutex::new(None),
3469 narrowed_cache: Mutex::new(BTreeMap::new()),
3470 resolved_cache: Mutex::new(BTreeMap::new()),
3471 search_next: Mutex::new(BTreeMap::new()),
3472 fields_cache: Mutex::new(None),
3473 repository_cache: Mutex::new(BTreeMap::new()),
3474 ledger,
3475 })
3476 }
3477
3478 /// A snapshot of every request this source has sent, and what each cost.
3479 ///
3480 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3481 /// of them can sit side by side. When this source was built with
3482 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3483 /// point of building it that way.
3484 #[must_use]
3485 pub fn accounting(&self) -> accounting::Session {
3486 self.ledger.snapshot()
3487 }
3488
3489 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3490 /// rate limit rather than handing it straight back as an error.
3491 ///
3492 /// Retrying is safe for every document here, including the mutations, and the reason
3493 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3494 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3495 /// this replays has already taken effect. An outcome this source cannot know — the
3496 /// send failed, or the body could not be read, so the mutation may well have landed —
3497 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3498 /// attempt. A duplicate write would come from replaying one of those, and none is
3499 /// replayed.
3500 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3501 if is_mutation(query)
3502 && ![
3503 graphql::ADD_COMMENT,
3504 graphql::UPDATE_COMMENT,
3505 graphql::DELETE_COMMENT,
3506 ]
3507 .contains(&query)
3508 {
3509 let mut cache = self.resolved_cache()?;
3510 for argument in ["input", "second", "third", "clear"] {
3511 if let Some(input) = variables.get(argument) {
3512 cache.retain(|id, item| {
3513 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3514 input
3515 .get(key)
3516 .and_then(Value::as_str)
3517 .is_some_and(|value| value == id.0 || value == item.item_id)
3518 })
3519 });
3520 }
3521 }
3522 }
3523 let doing = operation_description(query);
3524 let mut waited = Duration::ZERO;
3525 let mut waits = 0_u32;
3526 let mut backoff = self.pacing.retry_backoff;
3527 loop {
3528 if is_mutation(query) {
3529 let spacing = self.reserve_mutation_slot();
3530 if !spacing.is_zero() {
3531 tokio::time::sleep(spacing).await;
3532 }
3533 }
3534 let attempt = self.send_once(query, &variables).await;
3535 if is_mutation(query) {
3536 self.finish_mutation();
3537 }
3538 let limited = match attempt {
3539 Ok(data) => return Ok(data),
3540 Err(Attempt::Failed(error)) => return Err(error),
3541 Err(Attempt::Limited(limited)) => limited,
3542 };
3543 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3544 // move that extends a secondary limit, so a hint below the schedule's own next
3545 // wait is raised to it.
3546 let wait = match limited.hint {
3547 Some(hint) => Duration::from_secs(hint).max(backoff),
3548 None => backoff,
3549 };
3550 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3551 // A wait of nothing spends none of the budget, so it is exhaustion rather
3552 // than a retry. `Pacing::resolve` rules out every way of configuring one
3553 // except a budget of zero, where reporting the first refusal is the ask.
3554 if wait.is_zero() || wait > remaining {
3555 return Err(limited.exhausted(
3556 doing,
3557 waits,
3558 waited,
3559 wait,
3560 self.pacing.retry_budget,
3561 ));
3562 }
3563 tokio::time::sleep(wait).await;
3564 waited += wait;
3565 waits += 1;
3566 backoff = backoff.saturating_mul(2);
3567 }
3568 }
3569
3570 /// The next moment a content-creating mutation may leave this source, as a wait from
3571 /// now.
3572 ///
3573 /// The slot is reserved under the lock and the waiting happens outside it, so two
3574 /// callers take two slots rather than the same one — and no lock is held across an
3575 /// await.
3576 ///
3577 /// The moment it is spaced from is the previous mutation's *completion*, which
3578 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3579 /// own is the wrong thing to measure from.
3580 fn reserve_mutation_slot(&self) -> Duration {
3581 if self.pacing.min_mutation_interval.is_zero() {
3582 return Duration::ZERO;
3583 }
3584 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3585 // it would turn an earlier failure into a second one for no gain.
3586 let mut last = self
3587 .last_mutation
3588 .lock()
3589 .unwrap_or_else(std::sync::PoisonError::into_inner);
3590 let now = Instant::now();
3591 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3592 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3593 let at = last.map_or(now, |previous| {
3594 previous
3595 .checked_add(self.pacing.min_mutation_interval)
3596 .map_or(now, |earliest| earliest.max(now))
3597 });
3598 *last = Some(at);
3599 at.saturating_duration_since(now)
3600 }
3601
3602 /// Record that a content-creating mutation has finished, so the next one is spaced
3603 /// from here rather than from the moment this one was released.
3604 ///
3605 /// This source can only choose when a request *departs*; the limiter counts when it
3606 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3607 /// departure from the last therefore hands the limiter a gap of the interval less that
3608 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3609 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3610 /// slower machine while passing on a quick one.
3611 ///
3612 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3613 /// previous request had already arrived before its response came back, so its arrival
3614 /// is no later than this moment, and the next mutation is released at least the
3615 /// interval after this moment and arrives no earlier than it is released: the gap the
3616 /// limiter measures is therefore at least the interval, whatever transit costs and on
3617 /// whatever platform. The price is that a mutation's own round trip no longer counts
3618 /// towards its spacing, which makes this source slightly slower than the configured
3619 /// rate rather than slightly faster — the safe side of a limit that punishes being
3620 /// wrong by refusing reads for the next fifty minutes.
3621 ///
3622 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3623 /// and one that never left costs only a wait nobody needed.
3624 fn finish_mutation(&self) {
3625 if self.pacing.min_mutation_interval.is_zero() {
3626 return;
3627 }
3628 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3629 let mut last = self
3630 .last_mutation
3631 .lock()
3632 .unwrap_or_else(std::sync::PoisonError::into_inner);
3633 let now = Instant::now();
3634 // `max` rather than an assignment: a concurrent caller may already have reserved a
3635 // slot further out, and completing this request must never pull that slot back in.
3636 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3637 }
3638
3639 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3640 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3641 ///
3642 /// This is the one place a request leaves this crate, which is why the accounting is
3643 /// here rather than at each of the callers: a read path added later is counted without
3644 /// anybody remembering to count it, and
3645 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3646 /// when one is not.
3647 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3648 let Attempted {
3649 result,
3650 limits,
3651 reported_cost,
3652 } = self.attempt(query, variables).await;
3653 // No `otherwise` name: every document this source sends is one of its own, and the
3654 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3655 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3656 let outcome = match &result {
3657 Ok(_) => accounting::Outcome::Answered,
3658 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3659 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3660 };
3661 self.ledger.record(sending.finished(outcome, limits));
3662 result
3663 }
3664
3665 /// The attempt itself, with what its response said about the rate limit alongside.
3666 ///
3667 /// The two are returned together rather than recorded here because every one of the
3668 /// early exits below is a different outcome, and a record written at each of them is a
3669 /// record one of them can be added without.
3670 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3671 let mut limits = accounting::RateLimit::default();
3672 let mut reported_cost = None;
3673 let result = self
3674 .attempted(query, variables, &mut limits, &mut reported_cost)
3675 .await;
3676 Attempted {
3677 result,
3678 limits,
3679 reported_cost,
3680 }
3681 }
3682
3683 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3684 async fn attempted(
3685 &self,
3686 query: &str,
3687 variables: &Value,
3688 limits: &mut accounting::RateLimit,
3689 reported_cost: &mut Option<u64>,
3690 ) -> Result<Value, Attempt> {
3691 let response = self
3692 .client
3693 .post(self.endpoint.clone())
3694 .bearer_auth(self.token.expose_secret())
3695 .json(&json!({"query": query, "variables": variables}))
3696 .send()
3697 .await
3698 .map_err(|e| {
3699 Attempt::Failed(SourceError::Unavailable {
3700 message: format!("GitHub GraphQL request failed: {e}"),
3701 })
3702 })?;
3703 let status = response.status();
3704 let header = |name: &str| whole_seconds(response.headers().get(name));
3705 *limits = accounting::RateLimit::read(|name| {
3706 response
3707 .headers()
3708 .get(name)
3709 .and_then(|value| value.to_str().ok())
3710 .map(str::to_owned)
3711 });
3712 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3713 // that are not text at all — is "not known to be exhausted". This never makes a
3714 // response a refusal on its own: it says which limiter a refusal is attributed to
3715 // and where its hint comes from, so a value this cannot read costs a hint rather
3716 // than an answer.
3717 let exhausted = response
3718 .headers()
3719 .get("x-ratelimit-remaining")
3720 .and_then(|value| value.to_str().ok())
3721 == Some("0");
3722 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3723 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3724 // which is the same question answered as an absolute time. Nothing else here is a
3725 // hint, and a schedule is what answers a refusal that carries none.
3726 let hint = header("retry-after").or_else(|| {
3727 exhausted
3728 .then(|| header("x-ratelimit-reset"))
3729 .flatten()
3730 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3731 });
3732 // Read before it is parsed, because the evidence which tells a secondary rate
3733 // limit from a rejected credential is in the body of a response whose status says
3734 // only "forbidden" — and a non-success response was never parsed at all.
3735 let body = response.text().await.map_err(|e| {
3736 Attempt::Failed(SourceError::Unavailable {
3737 message: format!("GitHub GraphQL response could not be read: {e}"),
3738 })
3739 })?;
3740 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3741 return Err(Attempt::Limited(Limited { limiter, hint }));
3742 }
3743 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3744 return Err(Attempt::Failed(SourceError::Auth {
3745 message: format!(
3746 "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"
3747 ),
3748 }));
3749 }
3750 if !status.is_success() {
3751 return Err(Attempt::Failed(SourceError::Unavailable {
3752 message: format!("GitHub GraphQL returned HTTP {status}"),
3753 }));
3754 }
3755 // GitHub reports what a call cost only when the document asked it to, and no
3756 // document this source sends does — so this is `None` here and carries the figure
3757 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3758 // pick up is a `dryRun` probe's cost, which is some other document's.
3759 *reported_cost = serde_json::from_str::<Value>(&body)
3760 .ok()
3761 .as_ref()
3762 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3763 .and_then(Value::as_u64);
3764 self.answer(&body).map_err(Attempt::Failed)
3765 }
3766
3767 /// What one successful HTTP response says, once its GraphQL errors are read.
3768 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3769 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3770 message: format!("GitHub returned invalid JSON: {e}"),
3771 })?;
3772 let errors = body
3773 .get("errors")
3774 .map(|value| {
3775 value.as_array().ok_or_else(|| SourceError::Malformed {
3776 message: "GitHub response errors is not an array".into(),
3777 })
3778 })
3779 .transpose()?;
3780 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3781 let messages = errors
3782 .iter()
3783 .filter_map(|e| e.get("message").and_then(Value::as_str))
3784 .collect::<Vec<_>>()
3785 .join("; ");
3786 let message = if messages.is_empty() {
3787 "GitHub returned GraphQL errors".into()
3788 } else {
3789 messages
3790 };
3791 let normalized = message.to_ascii_lowercase();
3792 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3793 return Err(SourceError::Auth {
3794 message: format!(
3795 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3796 self.credential_name
3797 ),
3798 });
3799 }
3800 return Err(SourceError::Refused { message });
3801 }
3802 body.get("data")
3803 .filter(|data| data.is_object())
3804 .cloned()
3805 .ok_or_else(|| SourceError::Malformed {
3806 message: "GitHub response has no data object".into(),
3807 })
3808 }
3809
3810 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3811 // GraphQL cannot independently page them inside the outer item page. This source page is
3812 // deliberately bounded at that published maximum; the live drift journey exercises it.
3813 async fn board_page(
3814 &self,
3815 items_after: Option<&str>,
3816 items_first: u32,
3817 ) -> Result<Value, SourceError> {
3818 let data = self
3819 .graphql(
3820 graphql::BOARD,
3821 json!({"owner":self.owner,"number":self.project_number,
3822 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3823 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3824 )
3825 .await?;
3826 data.pointer("/owner/projectV2")
3827 .filter(|v| !v.is_null())
3828 .cloned()
3829 .ok_or_else(|| SourceError::Refused {
3830 message: format!(
3831 "GitHub project {}/{} was not found or is not visible to the token",
3832 self.owner, self.project_number
3833 ),
3834 })
3835 }
3836
3837 /// The search that finds the issues of this board, narrowed by `also` when it is
3838 /// given.
3839 ///
3840 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3841 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3842 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3843 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3844 /// from a task by the `parent` field each issue carries rather than by the search.
3845 fn board_search(&self, also: Option<&str>) -> String {
3846 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3847 match also {
3848 Some(also) => format!("{scope} {also}"),
3849 None => scope,
3850 }
3851 }
3852
3853 /// One issue this source reached directly, as the board item a read of the board would
3854 /// have produced — or `None` when this board does not hold it.
3855 ///
3856 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3857 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3858 /// item's own id, that item's field values, and the issue as its content. One resolver
3859 /// for both routes is what makes an issue read through a search, through its own node
3860 /// id, or through its project's sub-issues report the same title, the same status, the
3861 /// same labels and the same qualified id.
3862 ///
3863 /// An issue with no entry for *this* board is not this source's to report, which is
3864 /// what keeps an id naming some other repository's issue from being answered as an item
3865 /// of this board. That answer is given about an **exhausted** connection and never
3866 /// about an unread page: the entry is looked for on the page in hand, and only if that
3867 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3868 /// rest of it.
3869 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3870 if optional_str(issue, "__typename")? != Some("Issue") {
3871 return Ok(None);
3872 }
3873 let memberships = issue
3874 .get("projectItems")
3875 .ok_or_else(|| SourceError::Malformed {
3876 message: "GitHub issue is missing projectItems".into(),
3877 })?;
3878 let nodes = memberships
3879 .get("nodes")
3880 .and_then(Value::as_array)
3881 .ok_or_else(|| SourceError::Malformed {
3882 message: "GitHub issue projectItems.nodes is not an array".into(),
3883 })?;
3884 let held = match self.board_entry(nodes) {
3885 Some(held) => held.clone(),
3886 None => {
3887 let info = memberships
3888 .get("pageInfo")
3889 .ok_or_else(|| SourceError::Malformed {
3890 message: "GitHub issue projectItems has no pageInfo".into(),
3891 })?;
3892 // The page held no entry for this board. Whether that means the issue is
3893 // not on it is a question about the rest of the connection, and only a
3894 // connection with no rest answers it here.
3895 if !required_bool(info, "hasNextPage")? {
3896 return Ok(None);
3897 }
3898 let cursor = required_str(info, "endCursor")?;
3899 validate_cursor_progress(None, cursor)?;
3900 let issue_id = required_str(issue, "id")?;
3901 match self.board_membership(issue_id, cursor).await? {
3902 Some(held) => held,
3903 None => return Ok(None),
3904 }
3905 }
3906 };
3907 let item = json!({
3908 "id": required_str(&held, "id")?,
3909 "project": held.get("project"),
3910 "fieldValues": held.get("fieldValues"),
3911 "content": issue,
3912 });
3913 self.resolve(&item)
3914 }
3915
3916 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3917 ///
3918 /// One spelling of *which membership is this board's*, so the page a read carries and
3919 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3920 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3921 nodes.iter().find(|node| {
3922 node.pointer("/project/number").and_then(Value::as_u64)
3923 == Some(u64::from(self.project_number))
3924 })
3925 }
3926
3927 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3928 ///
3929 /// The recovery read: a page of memberships that holds no entry for this board says
3930 /// nothing about the memberships past it, so the connection is walked to exhaustion
3931 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3932 /// positive answer — the whole connection was read and no entry named this board —
3933 /// rather than a failure, and the walk is held to
3934 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3935 /// with a cursor that does not advance is refused instead of spun on.
3936 async fn board_membership(
3937 &self,
3938 issue: &str,
3939 after: &str,
3940 ) -> Result<Option<Value>, SourceError> {
3941 let mut after = after.to_owned();
3942 loop {
3943 let data = self
3944 .graphql(
3945 graphql::ISSUE_BOARD_ITEMS,
3946 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3947 "nestedFirst":NESTED_PAGE_SIZE}),
3948 )
3949 .await?;
3950 let Some(connection) = data
3951 .pointer("/node/projectItems")
3952 .filter(|value| !value.is_null())
3953 else {
3954 // The id resolved to nothing, or to something with no memberships to walk —
3955 // which is the same answer as a connection holding no entry for this board.
3956 return Ok(None);
3957 };
3958 let nodes = connection
3959 .get("nodes")
3960 .and_then(Value::as_array)
3961 .ok_or_else(|| SourceError::Malformed {
3962 message: "GitHub issue projectItems.nodes is not an array".into(),
3963 })?;
3964 if let Some(held) = self.board_entry(nodes) {
3965 return Ok(Some(held.clone()));
3966 }
3967 let info = connection
3968 .get("pageInfo")
3969 .ok_or_else(|| SourceError::Malformed {
3970 message: "GitHub issue projectItems has no pageInfo".into(),
3971 })?;
3972 let next = required_bool(info, "hasNextPage")?
3973 .then(|| required_str(info, "endCursor"))
3974 .transpose()?;
3975 match next {
3976 Some(next) => {
3977 validate_cursor_progress(Some(&after), next)?;
3978 after = next.to_owned();
3979 }
3980 None => return Ok(None),
3981 }
3982 }
3983 }
3984
3985 /// One page of a board-scoped issue search, and where the next page resumes.
3986 async fn search_page(
3987 &self,
3988 search: &str,
3989 first: u32,
3990 after: Option<&str>,
3991 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3992 let data = self
3993 .graphql(
3994 graphql::SEARCH_ISSUES,
3995 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3996 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3997 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3998 )
3999 .await?;
4000 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4001 message: "GitHub search response has no search connection".into(),
4002 })?;
4003 let mut found = Vec::new();
4004 for node in connection
4005 .get("nodes")
4006 .and_then(Value::as_array)
4007 .ok_or_else(|| SourceError::Malformed {
4008 message: "GitHub search nodes is not an array".into(),
4009 })?
4010 {
4011 if let Some(resolved) = self.resolve_issue(node).await? {
4012 found.push(resolved);
4013 }
4014 }
4015 let info = connection
4016 .get("pageInfo")
4017 .ok_or_else(|| SourceError::Malformed {
4018 message: "GitHub search connection has no pageInfo".into(),
4019 })?;
4020 let next = required_bool(info, "hasNextPage")?
4021 .then(|| required_str(info, "endCursor"))
4022 .transpose()?
4023 .map(str::to_owned);
4024 if let Some(next) = &next {
4025 validate_cursor_progress(after, next)?;
4026 }
4027 Ok((found, next))
4028 }
4029
4030 /// Every issue this board holds, completed with what this run wrote.
4031 ///
4032 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4033 /// is an index and is eventually consistent, so an issue this run created seconds ago
4034 /// can be absent from it, and a project listed straight after being written would
4035 /// otherwise be missing from its own board. What is added back is only what this
4036 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4037 /// process.
4038 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4039 let found = self.searched_issues().await?;
4040 self.completed_with_written(found, |_| true)
4041 }
4042
4043 /// Every issue this board's own search reports, walked to exhaustion, read once per
4044 /// source.
4045 ///
4046 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4047 /// needs it too and the two would otherwise walk the same search twice in one command.
4048 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4049 /// is.
4050 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4051 let cached = self.search_cache()?.clone();
4052 if let Some(held) = cached {
4053 return Ok(held);
4054 }
4055 let mut after: Option<String> = None;
4056 let mut found = Vec::new();
4057 let search = self.board_search(None);
4058 loop {
4059 let (page, next) = self
4060 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4061 .await?;
4062 found.extend(page);
4063 match next {
4064 Some(next) => after = Some(next),
4065 None => break,
4066 }
4067 }
4068 *self.search_cache()? = Some(found.clone());
4069 Ok(found)
4070 }
4071
4072 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4073 fn search_cache(
4074 &self,
4075 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4076 self.search_cache
4077 .lock()
4078 .map_err(|_| SourceError::Unavailable {
4079 message: "this source's view of the board's issues was left inconsistent by an \
4080 earlier failure; next: run the command again"
4081 .into(),
4082 })
4083 }
4084
4085 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4086 /// report.
4087 ///
4088 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4089 /// at all: the search index is behind, and a node read of an item filed moments ago can
4090 /// be too.
4091 fn completed_with_written(
4092 &self,
4093 mut found: Vec<Resolved>,
4094 keep: impl Fn(&Resolved) -> bool,
4095 ) -> Result<Vec<Resolved>, SourceError> {
4096 for own in self.created()?.iter().filter(|own| keep(own)) {
4097 if !found.iter().any(|item| item.id == own.id) {
4098 found.push(own.clone());
4099 }
4100 }
4101 Ok(found)
4102 }
4103
4104 /// What resolving one node id reached.
4105 ///
4106 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4107 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4108 /// is completed by a read of the draft itself rather than reported as nothing.
4109 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4110 let asked = self
4111 .graphql(
4112 graphql::ISSUE,
4113 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4114 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4115 )
4116 .await;
4117 let data = match asked {
4118 Ok(data) => data,
4119 // A string that is not a node id at all is not a failure to report: it is an id
4120 // this board does not hold, which is what every read of one already answers.
4121 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4122 Err(error) => return Err(error),
4123 };
4124 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4125 return Ok(Reached::Nothing);
4126 };
4127 if optional_str(node, "__typename")? == Some("DraftIssue") {
4128 return Ok(Reached::Draft);
4129 }
4130 Ok(match self.resolve_issue(node).await? {
4131 Some(item) => Reached::Held(Box::new(item)),
4132 None => Reached::Nothing,
4133 })
4134 }
4135
4136 /// One item of this board by its own id, whatever kind it is.
4137 ///
4138 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4139 /// run wrote is read first, because a node read of an item created moments ago can
4140 /// still be behind the board field values written onto it — see [`Self::created`].
4141 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4142 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4143 return Ok(Some(own.clone()));
4144 }
4145 match self.reach(id).await? {
4146 Reached::Held(item) => Ok(Some(*item)),
4147 Reached::Nothing => Ok(None),
4148 Reached::Draft => self.draft_by_id(id).await,
4149 }
4150 }
4151
4152 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4153 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4154 /// than one request per id.
4155 ///
4156 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4157 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4158 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4159 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4160 /// is completed by a read of the draft, exactly as there.
4161 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4162 let mut found: Vec<Option<Option<Resolved>>> = {
4163 let created = self.created()?;
4164 ids.iter()
4165 .map(|id| {
4166 created
4167 .iter()
4168 .find(|own| own.id == *id)
4169 .map(|own| Some(own.clone()))
4170 })
4171 .collect()
4172 };
4173 let unread: Vec<NativeId> = ids
4174 .iter()
4175 .zip(&found)
4176 .filter(|(_, found)| found.is_none())
4177 .map(|(id, _)| id.clone())
4178 .collect();
4179 let mut read = Vec::with_capacity(unread.len());
4180 if let [one] = unread.as_slice() {
4181 read.push(self.item_by_id(one).await?);
4182 } else {
4183 for batch in unread.chunks(DETAIL_BATCH) {
4184 let data = match self
4185 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4186 .await
4187 {
4188 Ok(data) => data,
4189 Err(error) if unresolvable_node(&error) => {
4190 for id in batch {
4191 read.push(self.item_by_id(id).await?);
4192 }
4193 continue;
4194 }
4195 Err(error) => return Err(error),
4196 };
4197 for (slot, id) in batch.iter().enumerate() {
4198 let node =
4199 data.get(format!("i{slot}"))
4200 .ok_or_else(|| SourceError::Malformed {
4201 message: format!(
4202 "GitHub answered a batch read with no item for {}",
4203 id.0
4204 ),
4205 })?;
4206 read.push(if node.is_null() {
4207 None
4208 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4209 self.draft_by_id(id).await?
4210 } else {
4211 if optional_str(node, "__typename")? == Some("Issue")
4212 && required_str(node, "id")? != id.0
4213 {
4214 return Err(SourceError::Malformed {
4215 message: format!(
4216 "GitHub answered the read of {} with issue {}",
4217 id.0,
4218 required_str(node, "id")?
4219 ),
4220 });
4221 }
4222 self.resolve_issue(node).await?
4223 });
4224 }
4225 }
4226 }
4227 let mut read = read.into_iter();
4228 Ok(found
4229 .iter_mut()
4230 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4231 .collect())
4232 }
4233
4234 fn resolved_cache(
4235 &self,
4236 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4237 self.resolved_cache
4238 .lock()
4239 .map_err(|_| SourceError::Unavailable {
4240 message: "resolved item records were left inconsistent; run the command again"
4241 .into(),
4242 })
4243 }
4244
4245 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4246 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4247 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4248 let cached = self.resolved_cache()?.get(id).cloned();
4249 match cached {
4250 Some(item) => Ok(Some(item)),
4251 None => self.item_by_id(id).await,
4252 }
4253 }
4254
4255 /// One board draft by its own id, with the board item it sits in — or `None` when no
4256 /// item of this board is that draft's.
4257 ///
4258 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4259 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4260 /// links a draft to one board item, so the page this read carries is the whole of that
4261 /// connection, and a page that reports more than it holds is refused rather than read
4262 /// as an answer about memberships nobody read.
4263 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4264 let data = self
4265 .graphql(
4266 graphql::DRAFT,
4267 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4268 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4269 )
4270 .await?;
4271 // Gone between the two reads is an answer — the draft is no longer there. Anything
4272 // else than the draft [`Self::reach`] was just told this id is, is not one.
4273 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4274 return Ok(None);
4275 };
4276 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4277 return Err(SourceError::Malformed {
4278 message: format!(
4279 "GitHub answered {} as a draft and then as something else",
4280 id.0
4281 ),
4282 });
4283 }
4284 if required_str(draft, "id")? != id.0 {
4285 return Err(SourceError::Malformed {
4286 message: format!("GitHub answered a different draft for {}", id.0),
4287 });
4288 }
4289 let memberships = draft
4290 .get("projectV2Items")
4291 .ok_or_else(|| SourceError::Malformed {
4292 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4293 })?;
4294 let nodes = memberships
4295 .get("nodes")
4296 .and_then(Value::as_array)
4297 .ok_or_else(|| SourceError::Malformed {
4298 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4299 })?;
4300 let info = memberships
4301 .get("pageInfo")
4302 .ok_or_else(|| SourceError::Malformed {
4303 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4304 })?;
4305 // Read whether or not this board's entry is on the page: a page claiming more than
4306 // the one item GitHub links a draft to is a malformed answer either way.
4307 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4308 return Err(SourceError::Malformed {
4309 message: format!(
4310 "GitHub draft {} reports more board items than the one GitHub links a draft \
4311 to",
4312 id.0
4313 ),
4314 });
4315 }
4316 if let Some(node) = nodes.first()
4317 && node
4318 .pointer("/project/number")
4319 .and_then(Value::as_u64)
4320 .is_none()
4321 {
4322 return Err(SourceError::Malformed {
4323 message: format!(
4324 "GitHub draft {} board item has no numeric project number",
4325 id.0
4326 ),
4327 });
4328 }
4329 let Some(held) = self.board_entry(nodes) else {
4330 return Ok(None);
4331 };
4332 if required_str(
4333 held.get("project").ok_or_else(|| SourceError::Malformed {
4334 message: format!("GitHub draft {} board item has no project", id.0),
4335 })?,
4336 "id",
4337 )? != self.board_fields().await?.id.as_str()
4338 {
4339 return Ok(None);
4340 }
4341 let item = json!({
4342 "id": required_str(held, "id")?,
4343 "project": held.get("project"),
4344 "fieldValues": held.get("fieldValues"),
4345 "content": draft,
4346 });
4347 self.resolve(&item)
4348 }
4349
4350 /// The board's own id and field definitions, for a write whose item does not carry
4351 /// them — never its items.
4352 ///
4353 /// A board this command has already listed supplies them, since it read them beside its
4354 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4355 /// is consulted about which items the board holds: see the module documentation for
4356 /// why a question about one known item is answered by reading that item.
4357 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4358 if let Some(board) = self.board_cache()?.as_ref() {
4359 return Ok(BoardFields {
4360 id: BoardId::parse(&board.id)?,
4361 fields: board.fields.clone(),
4362 });
4363 }
4364 if let Some(held) = self.fields_cache()?.clone() {
4365 return Ok(held);
4366 }
4367 let data = self
4368 .graphql(
4369 graphql::BOARD_FIELDS,
4370 json!({"owner":self.owner,"number":self.project_number,
4371 "nestedFirst":NESTED_PAGE_SIZE}),
4372 )
4373 .await?;
4374 self.fields_read(&data)
4375 }
4376
4377 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4378 /// the rest of this command.
4379 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4380 let board = data
4381 .pointer("/boardFields/projectV2")
4382 .filter(|value| !value.is_null())
4383 .ok_or_else(|| SourceError::Refused {
4384 message: format!(
4385 "GitHub project {}/{} was not found or is not visible to the token",
4386 self.owner, self.project_number
4387 ),
4388 })?;
4389 let read = BoardFields {
4390 id: BoardId::parse(required_str(board, "id")?)?,
4391 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4392 };
4393 *self.fields_cache()? = Some(read.clone());
4394 Ok(read)
4395 }
4396
4397 /// Read what creating an issue in `repository` needs and this command has not read yet —
4398 /// the board's fields and the repository's node id — in one request when it needs both.
4399 ///
4400 /// When either is already known this sends nothing, and the other is read by its own
4401 /// document where it is asked for, so no create reads anything twice.
4402 async fn creation_context(
4403 &self,
4404 repository: &RepositoryTarget,
4405 incoming: &Incoming<'_>,
4406 ) -> Result<(), SourceError> {
4407 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4408 if fields_known || self.repository_cache()?.contains_key(repository) {
4409 return Ok(());
4410 }
4411 let data = self
4412 .graphql(
4413 graphql::CREATION_CONTEXT,
4414 json!({"owner":self.owner,"number":self.project_number,
4415 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4416 "repositoryName":repository.name}),
4417 )
4418 .await?;
4419 self.fields_read(&data)?;
4420 self.repository_read(&data, repository, incoming)?;
4421 Ok(())
4422 }
4423
4424 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4425 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4426 self.fields_cache
4427 .lock()
4428 .map_err(|_| SourceError::Unavailable {
4429 message: "this source's view of the board's fields was left inconsistent by an \
4430 earlier failure; next: run the command again"
4431 .into(),
4432 })
4433 }
4434
4435 /// What a write to `item` needs of the board, read off that item when it says enough and
4436 /// off [`Self::board_fields`] when it does not.
4437 ///
4438 /// A node read of an item names its board and carries the definition of every field it
4439 /// holds a value of — so an item naming its board, holding a value of the origin field,
4440 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4441 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4442 /// of may still be on the board, and a view reading it as absent would refuse a write the
4443 /// board can take or skip a field write the board needs, so such an item — and a create,
4444 /// which has no item yet — takes the board's fields from their own read instead.
4445 async fn fields_for(
4446 &self,
4447 item: Option<&Resolved>,
4448 writes_status: bool,
4449 selects_priority: bool,
4450 ) -> Result<BoardFields, SourceError> {
4451 if let Some(board) = item.and_then(Resolved::carried_board) {
4452 return Ok(board);
4453 }
4454 if let Some(item) = item
4455 && let Some(board_id) = item.named_board()
4456 && item.defines(ORIGIN_FIELD)
4457 && (!writes_status || item.defines("Status"))
4458 && (!selects_priority || item.defines(PRIORITY_FIELD))
4459 {
4460 return Ok(BoardFields {
4461 id: board_id,
4462 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4463 });
4464 }
4465 self.board_fields().await
4466 }
4467
4468 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4469 /// when that id names nothing here with a sub-issue relationship to walk.
4470 ///
4471 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4472 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4473 /// empty vector is a project that holds nothing.
4474 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4475 let mut after: Option<String> = None;
4476 let mut children = Vec::new();
4477 loop {
4478 let asked = self
4479 .graphql(
4480 graphql::SUB_ISSUES,
4481 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4482 "nestedFirst":NESTED_PAGE_SIZE,
4483 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4484 )
4485 .await;
4486 let data = match asked {
4487 Ok(data) => data,
4488 // A string that is not a node id at all is not a failure to report: it is
4489 // the ordinary answer to a selector naming a project by its name.
4490 Err(error) if unresolvable_node(&error) => return Ok(None),
4491 Err(error) => return Err(error),
4492 };
4493 let Some(connection) = data
4494 .pointer("/node/subIssues")
4495 .filter(|value| !value.is_null())
4496 else {
4497 // No such node, or one with no sub-issue relationship — a board draft is
4498 // the one this board can really hold.
4499 return Ok(None);
4500 };
4501 for node in connection
4502 .get("nodes")
4503 .and_then(Value::as_array)
4504 .ok_or_else(|| SourceError::Malformed {
4505 message: "GitHub subIssues.nodes is not an array".into(),
4506 })?
4507 {
4508 if let Some(resolved) = self.resolve_issue(node).await? {
4509 children.push(resolved);
4510 }
4511 }
4512 let info = connection
4513 .get("pageInfo")
4514 .ok_or_else(|| SourceError::Malformed {
4515 message: "GitHub subIssues connection has no pageInfo".into(),
4516 })?;
4517 let next = required_bool(info, "hasNextPage")?
4518 .then(|| required_str(info, "endCursor"))
4519 .transpose()?;
4520 match next {
4521 Some(next) => {
4522 validate_cursor_progress(after.as_deref(), next)?;
4523 after = Some(next.to_owned());
4524 }
4525 None => return Ok(Some(children)),
4526 }
4527 }
4528 }
4529
4530 /// Which issue of this board a project *name* is, or `None` when none is.
4531 ///
4532 /// One bounded query which filters on that name at the server, rather than a walk of
4533 /// every issue the board holds. The name is compared again here: the qualifier narrows
4534 /// what GitHub sends, and this source decides what it names.
4535 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4536 let search = self.board_search(Some(&title_qualifier(name)));
4537 let mut after = None;
4538 loop {
4539 let (candidates, next) = self
4540 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4541 .await?;
4542 if let Some(item) = candidates.into_iter().find(|item| {
4543 item.kind == BoardKind::Work(ItemKind::Project)
4544 && item.title.eq_ignore_ascii_case(name)
4545 }) {
4546 return Ok(Some(item.id));
4547 }
4548 match next {
4549 Some(next) => after = Some(next),
4550 None => return Ok(None),
4551 }
4552 }
4553 }
4554
4555 /// Everything filed under one project of this board: the sub-issues of the issue that
4556 /// project is.
4557 ///
4558 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4559 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4560 /// gains projects, or as another project gains tasks.
4561 ///
4562 /// A qualified id names the issue and is asked for its sub-issues directly: one
4563 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4564 /// read as a project *name*, which costs the one bounded search
4565 /// [`Self::project_by_name`] makes.
4566 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4567 let (project, children) = match self.sub_issues(selector).await? {
4568 Some(children) => (selector.clone(), children),
4569 None => match self.project_by_name(&selector.0).await? {
4570 Some(project) => {
4571 let children = self.sub_issues(&project).await?.unwrap_or_default();
4572 (project, children)
4573 }
4574 None => return Ok(Vec::new()),
4575 },
4576 };
4577 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4578 }
4579
4580 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4581 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4582 ///
4583 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4584 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4585 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4586 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4587 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4588 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4589 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4590 /// rather than silently narrowing a caller's answer.
4591 ///
4592 /// The instant is written to the second, rounded down, which can only widen what the
4593 /// search returns; confirmation against each candidate's own comments is what makes the
4594 /// answer exact. The search is an index that lags a write by a second or two — the module
4595 /// documentation records it — so a caller that asks again from its last instant should
4596 /// overlap the two by more than that.
4597 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4598 let found = self.searched(&updated_qualifier(since)).await?;
4599 self.completed_with_written(found, |_| true)
4600 }
4601
4602 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4603 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4604 ///
4605 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4606 /// own record is the fresher of the two.
4607 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4608 let search = self.board_search(Some(also));
4609 let mut after: Option<String> = None;
4610 let mut found = Vec::new();
4611 loop {
4612 let (page, next) = self
4613 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4614 .await?;
4615 found.extend(page);
4616 match next {
4617 Some(next) => after = Some(next),
4618 None => return Ok(found),
4619 }
4620 }
4621 }
4622
4623 /// A bounded task answer; the versioned cursor carries the connection position, how
4624 /// many rows of the page starting there were already handed out, and the own-write ids
4625 /// already observed, including across a new source instance.
4626 ///
4627 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4628 /// sliced from the pages it needs; why is the module documentation's paging contract.
4629 async fn search_tasks(
4630 &self,
4631 query: &TaskQuery,
4632 page: &PageRequest,
4633 also: &str,
4634 ) -> Result<Page<Task>, SourceError> {
4635 let mut position = match &page.cursor {
4636 None => SearchPosition::default(),
4637 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4638 .ok()
4639 .filter(|position| {
4640 position.version == SEARCH_CURSOR_VERSION
4641 && position.connection.valid_resume(position.offset)
4642 })
4643 .ok_or_else(|| SourceError::Config {
4644 message: "page cursor is invalid".into(),
4645 })?,
4646 };
4647 let search = self.board_search(Some(also));
4648 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4649 let own = self.with_own_writes(Vec::new())?;
4650 // An issue this process commented on is a candidate of a comment-activity read
4651 // whether or not the search has caught up with the comment; see `Self::commented`.
4652 let commented = match query.commented_since {
4653 Some(_) => self.commented()?.clone(),
4654 None => Vec::new(),
4655 };
4656 for id in own.iter().map(|item| &item.id).chain(&commented) {
4657 if !position.own.contains(id) {
4658 position.own.push(id.clone());
4659 }
4660 }
4661 let mut tasks = Vec::new();
4662 while !position.connection.exhausted() && tasks.len() < limit {
4663 let first = SEARCH_PAGE_SIZE;
4664 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4665 let key =
4666 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4667 .expect("search page key is serializable");
4668 let cached = if query.commented_since.is_none() {
4669 self.narrowed_cache()?.get(&key).cloned()
4670 } else {
4671 None
4672 };
4673 let (found, next) = match cached {
4674 Some(found) => {
4675 let next = self
4676 .search_next
4677 .lock()
4678 .map_err(|_| SourceError::Unavailable {
4679 message:
4680 "search pagination was left inconsistent; run the command again"
4681 .into(),
4682 })?
4683 .get(&key)
4684 .cloned()
4685 .flatten();
4686 (found, next)
4687 }
4688 None => {
4689 let (found, next) = self
4690 .search_page(&search, first, position.connection.after())
4691 .await?;
4692 if query.commented_since.is_none() {
4693 self.search_next
4694 .lock()
4695 .map_err(|_| SourceError::Unavailable {
4696 message:
4697 "search pagination was left inconsistent; run the command again"
4698 .into(),
4699 })?
4700 .insert(key.clone(), next.clone());
4701 self.narrowed_cache()?.insert(key, found.clone());
4702 }
4703 (found, next)
4704 }
4705 };
4706 let rows = found.len();
4707 for mut item in found.into_iter().skip(position.offset) {
4708 if tasks.len() == limit {
4709 break;
4710 }
4711 position.offset += 1;
4712 if position.own.contains(&item.id) {
4713 if position.seen.contains(&item.id) {
4714 continue;
4715 }
4716 position.seen.push(item.id.clone());
4717 // The search's own copy of an issue this process only commented on is as
4718 // good as a node read of it, since its comments are read either way.
4719 let only_commented = commented.contains(&item.id)
4720 && !own.iter().any(|written| written.id == item.id);
4721 if !only_commented {
4722 let updated_at = item.updated_at;
4723 let Some(written) = self.search_written(&own, &item.id).await? else {
4724 continue;
4725 };
4726 item = written;
4727 item.updated_at = item.updated_at.max(updated_at);
4728 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4729 }
4730 }
4731 if item.kind == BoardKind::Work(ItemKind::Task) {
4732 let task = item.task()?;
4733 if task_matches(&task, query, &query.project)
4734 && self.commented_since(&item, query.commented_since).await?
4735 {
4736 tasks.push(task);
4737 }
4738 }
4739 }
4740 if position.offset < rows {
4741 continue;
4742 }
4743 position.offset = 0;
4744 position.connection = match next {
4745 Some(after) => SearchConnection::Continuing {
4746 after: Cursor(after),
4747 },
4748 None => SearchConnection::Exhausted {},
4749 };
4750 }
4751 if position.connection.exhausted() {
4752 for id in position.own.clone() {
4753 if position.seen.contains(&id) {
4754 continue;
4755 }
4756 if tasks.len() == limit {
4757 break;
4758 }
4759 position.seen.push(id.clone());
4760 let Some(item) = self.search_written(&own, &id).await? else {
4761 continue;
4762 };
4763 if item.kind == BoardKind::Work(ItemKind::Task) {
4764 let task = item.task()?;
4765 if task_matches(&task, query, &query.project)
4766 && self.commented_since(&item, query.commented_since).await?
4767 {
4768 tasks.push(task);
4769 }
4770 }
4771 }
4772 }
4773 let more = !position.connection.exhausted()
4774 || position.own.iter().any(|id| !position.seen.contains(id));
4775 Ok(Page {
4776 items: tasks,
4777 next: more.then(|| {
4778 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4779 }),
4780 })
4781 }
4782
4783 /// A resumed process has the ids but no write records; resolve only a record the
4784 /// current page needs, by its uncached node read rather than the lagging search index.
4785 async fn search_written(
4786 &self,
4787 own: &[Resolved],
4788 id: &NativeId,
4789 ) -> Result<Option<Resolved>, SourceError> {
4790 match own.iter().find(|item| item.id == *id) {
4791 Some(item) => Ok(Some(item.clone())),
4792 None => self.item_by_id(id).await,
4793 }
4794 }
4795
4796 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4797 /// without enumerating the board — or `None` for a query carrying none of the three, which
4798 /// keeps the reads it always had.
4799 ///
4800 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4801 /// because it names at most a handful of items. Text and metadata are answered by one
4802 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4803 /// further by `updated:>=` when the query also asks for comment activity, since both
4804 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4805 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4806 ///
4807 /// Completed with what this process wrote, its own record winning over the index's copy
4808 /// of the same item: see [`Self::with_own_writes`].
4809 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4810 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4811 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4812 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4813 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4814 None => qualifiers,
4815 }),
4816 (None, None) => return Ok(None),
4817 };
4818 // A question about comment activity is asked afresh every time, as it always was: it
4819 // is the one a caller polls from one source while waiting for the index, and an
4820 // answer held from the first poll would be the answer to every later one.
4821 let key = query.commented_since.is_none().then(|| asked.key());
4822 let cached = match &key {
4823 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4824 None => None,
4825 };
4826 let found = match cached {
4827 Some(found) => found,
4828 None => {
4829 let found = match &asked {
4830 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4831 Narrowing::Search(also) => self.searched(also).await?,
4832 };
4833 if let Some(key) = key {
4834 self.narrowed_cache()?.insert(key, found.clone());
4835 }
4836 found
4837 }
4838 };
4839 self.with_own_writes(found).map(Some)
4840 }
4841
4842 /// The candidates for a project or unscoped document query carrying a searchable text,
4843 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4844 /// which keeps the read it always had.
4845 ///
4846 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4847 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4848 /// costs is the issues that match and never the board. Its answer is held for the command
4849 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4850 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4851 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4852 /// process wrote: see [`Self::with_own_writes`].
4853 async fn text_searched(
4854 &self,
4855 text: Option<&TextQuery>,
4856 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4857 let Some(also) = text_qualifiers(text) else {
4858 return Ok(None);
4859 };
4860 let key = Narrowing::Search(also.clone()).key();
4861 let cached = self.narrowed_cache()?.get(&key).cloned();
4862 let found = match cached {
4863 Some(found) => found,
4864 None => {
4865 let found = self.searched(&also).await?;
4866 self.narrowed_cache()?.insert(key, found.clone());
4867 found
4868 }
4869 };
4870 self.with_own_writes(found).map(Some)
4871 }
4872
4873 /// Every item of this board that may carry `origin` — a superset of those that do — found
4874 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4875 ///
4876 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4877 /// which reads the field every carrier holds, whichever release wrote it — and the
4878 /// board-scoped issue search for the same id as a phrase in the body, where this source
4879 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4880 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4881 /// query's, exactly.
4882 ///
4883 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4884 /// already ended is sent its last cursor again, which answers an empty page, so the one
4885 /// document serves every page of either. What the two leave is stated in the module
4886 /// documentation: a carrier another process added within the last second or two, before
4887 /// either index has it.
4888 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4889 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4890 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4891 let mut items_after: Option<String> = None;
4892 let mut search_after: Option<String> = None;
4893 let mut found: Vec<Resolved> = Vec::new();
4894 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4895 if !found.iter().any(|held| held.id == resolved.id) {
4896 found.push(resolved);
4897 }
4898 };
4899 loop {
4900 let data = self
4901 .graphql(
4902 graphql::ORIGIN_LOOKUP,
4903 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4904 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4905 "itemsAfter":items_after,"searchAfter":search_after,
4906 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4907 "duplicates":true}),
4908 )
4909 .await?;
4910 let items = data
4911 .pointer("/originItems/projectV2/items")
4912 .filter(|value| !value.is_null())
4913 .ok_or_else(|| SourceError::Refused {
4914 message: format!(
4915 "GitHub project {}/{} was not found or is not visible to the token",
4916 self.owner, self.project_number
4917 ),
4918 })?;
4919 for item in optional_nodes(Some(items), "project items")?
4920 .into_iter()
4921 .flatten()
4922 {
4923 // The board's own items list its drafts too, and a draft is not an issue: no
4924 // narrowed read answers with one, whatever its origin field holds.
4925 if let Some(resolved) = self.resolve(item)?
4926 && resolved.content_kind == ContentKind::Issue
4927 {
4928 keep(resolved, &mut found);
4929 }
4930 }
4931 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4932 message: "GitHub search response has no search connection".into(),
4933 })?;
4934 for node in optional_nodes(Some(searched), "search")?
4935 .into_iter()
4936 .flatten()
4937 {
4938 if let Some(resolved) = self.resolve_issue(node).await? {
4939 keep(resolved, &mut found);
4940 }
4941 }
4942 let items_next = resumed(items, items_after.as_deref())?;
4943 let search_next = resumed(searched, search_after.as_deref())?;
4944 if !items_next.has_more() && !search_next.has_more() {
4945 return Ok(found);
4946 }
4947 items_after = items_next.cursor();
4948 search_after = search_next.cursor();
4949 }
4950 }
4951
4952 /// `found`, with every item this process created or wrote in its place, and every one of
4953 /// them the read did not report added.
4954 ///
4955 /// This process's own record wins over the read's copy of the same item, because a read
4956 /// of an item written moments ago can still be behind what was written onto it — the
4957 /// origin field included, which is the one a narrowed read is confirmed against — and a
4958 /// read that still names an item under a predicate this process's write moved it out of
4959 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4960 /// last saw the item change, which is what a comment-activity read rules a candidate out
4961 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4962 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4963 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4964 // A board draft is not an issue, so no narrowed read returns one, and this process
4965 // having written one does not make it an answer either.
4966 let own: Vec<Resolved> = self
4967 .created()?
4968 .iter()
4969 .chain(self.updated()?.iter())
4970 .filter(|own| own.content_kind == ContentKind::Issue)
4971 .cloned()
4972 .collect();
4973 for mut own in own {
4974 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4975 match found.iter_mut().find(|read| read.id == own.id) {
4976 Some(read) => {
4977 own.updated_at = own.updated_at.max(read.updated_at);
4978 *read = own;
4979 }
4980 None => found.push(own),
4981 }
4982 }
4983 Ok(found)
4984 }
4985
4986 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4987 /// there is no instant to hold it to.
4988 ///
4989 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4990 /// or after the instant moved it there: an issue not updated since holds no such comment,
4991 /// and its comments are never asked for — unless this process commented on it in this
4992 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
4993 /// only as far as the first that matches. A board draft is not an issue and has no
4994 /// comments, so it never matches.
4995 async fn commented_since(
4996 &self,
4997 item: &Resolved,
4998 since: Option<DateTime<Utc>>,
4999 ) -> Result<bool, SourceError> {
5000 let Some(since) = since else {
5001 return Ok(true);
5002 };
5003 if item.content_kind == ContentKind::DraftIssue {
5004 return Ok(false);
5005 }
5006 // An `updatedAt` this process's own record or a lagging index holds can predate a
5007 // comment this process wrote since, so only an issue it did not comment on is ruled
5008 // out by one.
5009 if item.updated_at.is_some_and(|updated| updated < since)
5010 && !self.commented()?.contains(&item.id)
5011 {
5012 return Ok(false);
5013 }
5014 let query = TaskQuery {
5015 commented_since: Some(since),
5016 ..TaskQuery::default()
5017 };
5018 let mut after: Option<String> = None;
5019 loop {
5020 let data = self
5021 .graphql(
5022 graphql::ISSUE_COMMENTS,
5023 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5024 )
5025 .await?;
5026 let Some(connection) = data
5027 .get("node")
5028 .filter(|value| !value.is_null())
5029 .and_then(|node| node.get("comments"))
5030 .filter(|value| !value.is_null())
5031 else {
5032 // Removed since the search reported it: no longer an issue with comments.
5033 return Ok(false);
5034 };
5035 let comments = optional_nodes(Some(connection), "issue comments")?
5036 .into_iter()
5037 .flatten()
5038 .map(comment_from)
5039 .collect::<Result<Vec<_>, _>>()?;
5040 if query.comments_match(&comments) {
5041 return Ok(true);
5042 }
5043 match next_cursor(connection)? {
5044 Some(next) => {
5045 validate_cursor_progress(after.as_deref(), &next.0)?;
5046 after = Some(next.0);
5047 }
5048 None => return Ok(false),
5049 }
5050 }
5051 }
5052
5053 /// Every item on the board: the union of both enumerations GitHub offers of one.
5054 ///
5055 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5056 /// board **draft** and reads the board's own fields beside its items, and only the search
5057 /// reports an item that connection is behind on. The module documentation is where the lag and the
5058 /// measurements behind it are written down.
5059 ///
5060 /// A search result is admitted on the same terms as any other issue this source reaches
5061 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5062 /// names *this* board — so an issue the index still believes is here after it was taken
5063 /// off is refused rather than reported.
5064 ///
5065 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5066 /// which is what the cache could otherwise have broken.
5067 async fn board(&self) -> Result<Board, SourceError> {
5068 let cached = self.board_cache()?.clone();
5069 let mut board = match cached {
5070 Some(board) => board,
5071 None => {
5072 let read = self.read_board().await?;
5073 *self.board_cache()? = Some(read.clone());
5074 read
5075 }
5076 };
5077 for held in self.searched_issues().await? {
5078 if !board.items.iter().any(|item| item.id == held.id) {
5079 board.items.push(held);
5080 }
5081 }
5082 for own in self.created()?.iter() {
5083 if !board.items.iter().any(|item| item.id == own.id) {
5084 board.items.push(own.clone());
5085 }
5086 }
5087 Ok(board)
5088 }
5089
5090 /// This process's own view of the board, or the refusal a poisoned lock is.
5091 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5092 self.board_cache
5093 .lock()
5094 .map_err(|_| SourceError::Unavailable {
5095 message: "this source's view of the board was left inconsistent by an earlier \
5096 failure; next: run the command again"
5097 .into(),
5098 })
5099 }
5100
5101 /// Bring this process's own view of the board up to an item it has just written.
5102 ///
5103 /// A created item goes to `created`, which is what completes a board read GitHub's own
5104 /// eventual consistency has left behind. An item that was already there is replaced
5105 /// where it sits, so a second write of it in the same command reads its real parent
5106 /// rather than the one it had before the first write.
5107 ///
5108 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5109 /// that wins: an item this same run created is held in `created` and not in the cached
5110 /// board, and `board` completes the cached board *from* `created`, so replacing only
5111 /// the cached copy of such an item replaces nothing and the read still reports the
5112 /// title it was created with. The search is the third, and it is the one an item the
5113 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5114 /// source is least able to re-read, so leaving it out would put the stale title back on
5115 /// the only items the completion in [`Self::board`] exists for.
5116 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5117 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5118 if created {
5119 self.created()?.push(item);
5120 return Ok(());
5121 }
5122 {
5123 let mut own = self.created()?;
5124 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5125 *held = item;
5126 return Ok(());
5127 }
5128 }
5129 {
5130 let mut own = self.updated()?;
5131 match own.iter_mut().find(|held| held.id == item.id) {
5132 Some(held) => *held = item.clone(),
5133 None => own.push(item.clone()),
5134 }
5135 }
5136 if let Some(board) = self.board_cache()?.as_mut()
5137 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5138 {
5139 *held = item.clone();
5140 }
5141 if let Some(found) = self.search_cache()?.as_mut()
5142 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5143 {
5144 *held = item.clone();
5145 }
5146 for found in self.narrowed_cache()?.values_mut() {
5147 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5148 *held = item.clone();
5149 }
5150 }
5151 Ok(())
5152 }
5153
5154 /// Forget one item this process has just deleted, from every half of its own view.
5155 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5156 self.resolved_cache()?.remove(id);
5157 self.created()?.retain(|own| own.id != *id);
5158 self.updated()?.retain(|own| own.id != *id);
5159 self.commented()?.retain(|own| own != id);
5160 if let Some(board) = self.board_cache()?.as_mut() {
5161 board.items.retain(|item| item.id != *id);
5162 }
5163 if let Some(found) = self.search_cache()?.as_mut() {
5164 found.retain(|item| item.id != *id);
5165 }
5166 for found in self.narrowed_cache()?.values_mut() {
5167 found.retain(|item| item.id != *id);
5168 }
5169 Ok(())
5170 }
5171
5172 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5173 fn narrowed_cache(
5174 &self,
5175 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5176 self.narrowed_cache
5177 .lock()
5178 .map_err(|_| SourceError::Unavailable {
5179 message: "this source's view of a narrowed read was left inconsistent by an \
5180 earlier failure; next: run the command again"
5181 .into(),
5182 })
5183 }
5184
5185 /// Every page of the board, read from GitHub.
5186 async fn read_board(&self) -> Result<Board, SourceError> {
5187 let mut after: Option<String> = None;
5188 let mut items = Vec::new();
5189 let mut board;
5190 loop {
5191 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5192 for item in page
5193 .pointer("/items/nodes")
5194 .and_then(Value::as_array)
5195 .ok_or_else(|| SourceError::Malformed {
5196 message: "GitHub project items.nodes is not an array".into(),
5197 })?
5198 {
5199 if let Some(resolved) = self.resolve(item)? {
5200 items.push(resolved);
5201 }
5202 }
5203 let info = page
5204 .pointer("/items/pageInfo")
5205 .ok_or_else(|| SourceError::Malformed {
5206 message: "GitHub project items have no pageInfo".into(),
5207 })?;
5208 let has_next = required_bool(info, "hasNextPage")?;
5209 let next = has_next
5210 .then(|| required_str(info, "endCursor"))
5211 .transpose()?;
5212 board = page.clone();
5213 match next {
5214 Some(next) => {
5215 validate_cursor_progress(after.as_deref(), next)?;
5216 after = Some(next.to_owned());
5217 }
5218 None => break,
5219 }
5220 }
5221 Ok(Board {
5222 id: required_str(&board, "id")?.to_owned(),
5223 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5224 items,
5225 })
5226 }
5227
5228 /// The existing items this source has written, for completing a narrowed read that is
5229 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5230 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5231 self.updated.lock().map_err(|_| SourceError::Unavailable {
5232 message: "this source's record of what it wrote in this run was left inconsistent \
5233 by an earlier failure; next: run the command again"
5234 .into(),
5235 })
5236 }
5237
5238 /// The issues this source has commented on in this command; see
5239 /// [`Self::commented`](GitHubProjectsSource::commented).
5240 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
5241 self.commented.lock().map_err(|_| SourceError::Unavailable {
5242 message: "this source's record of what it commented on in this run was left \
5243 inconsistent by an earlier failure; next: run the command again"
5244 .into(),
5245 })
5246 }
5247
5248 /// Called only once GitHub has answered the comment write, so an issue whose comment
5249 /// failed is never made a candidate a later read would pay a node read for.
5250 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
5251 let mut commented = self.commented()?;
5252 if !commented.contains(issue) {
5253 commented.push(issue.clone());
5254 }
5255 Ok(())
5256 }
5257
5258 /// The items this source has created, for completing a board read that is behind.
5259 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5260 self.created.lock().map_err(|_| SourceError::Unavailable {
5261 message: "this source's record of what it created in this run was left \
5262 inconsistent by an earlier failure; next: run the command again"
5263 .into(),
5264 })
5265 }
5266
5267 /// One board item as this source reports it, or `None` for content it ignores.
5268 ///
5269 /// A pull request is neither a project nor a task — it is somebody's change, not a
5270 /// unit of plan — and an item whose content the token cannot see has nothing to
5271 /// report at all.
5272 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5273 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5274 message: "GitHub project item is missing content".into(),
5275 })?;
5276 if content.is_null() {
5277 return Ok(None);
5278 }
5279 let content_kind = match required_str(content, "__typename")? {
5280 "Issue" => ContentKind::Issue,
5281 "DraftIssue" => ContentKind::DraftIssue,
5282 _ => return Ok(None),
5283 };
5284 let field_values = item
5285 .get("fieldValues")
5286 .ok_or_else(|| SourceError::Malformed {
5287 message: "GitHub project item is missing fieldValues".into(),
5288 })?;
5289 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5290 let nodes = field_values
5291 .get("nodes")
5292 .and_then(Value::as_array)
5293 .ok_or_else(|| SourceError::Malformed {
5294 message: "GitHub project item fieldValues.nodes is not an array".into(),
5295 })?;
5296 if let Some(labels) = content.get("labels") {
5297 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5298 }
5299 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5300 let (body, slot) = metadata_body(raw_body.clone())?;
5301 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5302 .map(|id| NativeId(id.to_owned()));
5303 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5304 // to read one from; it is a task, and never a project.
5305 let sub_issues = match content_kind {
5306 ContentKind::Issue => sub_issue_total(content)?,
5307 ContentKind::DraftIssue => 0,
5308 };
5309 let content_id = required_str(content, "id")?;
5310 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5311 message: format!("GitHub issue {content_id}: {message}"),
5312 })?;
5313 let raw_title = required_str(content, "title")?;
5314 // The design prefix is read *first*, before either of the two rules that separate
5315 // a project from a task. A document is not work whatever sub-issues it has and
5316 // whatever marker it carries, and reading the prefix later would make a design
5317 // issue with none of either an empty project.
5318 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5319 BoardKind::Document
5320 } else if parent.is_some() {
5321 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5322 // under a project is that project's task even when it has sub-issues of its
5323 // own.
5324 BoardKind::Work(ItemKind::Task)
5325 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5326 BoardKind::Work(ItemKind::Project)
5327 } else {
5328 BoardKind::Work(ItemKind::Task)
5329 };
5330 // The title a person wrote, which for a document is the one without the prefix —
5331 // the same way `content` above is the body without this source's metadata slot.
5332 let title = match kind {
5333 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5334 BoardKind::Work(_) => raw_title.to_owned(),
5335 };
5336 let own_repository = content
5337 .pointer("/repository/nameWithOwner")
5338 .and_then(Value::as_str)
5339 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5340 .transpose()
5341 .map_err(|message| SourceError::Malformed { message })?;
5342 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5343 Repository::from_metadata(&slot)
5344 .map_err(|message| SourceError::Malformed { message })?
5345 } else {
5346 own_repository.clone().into_iter().collect()
5347 };
5348 let id = NativeId(content_id.to_owned());
5349 // Read only for a task, because only a task has either list: a project or a
5350 // document holding one of these keys holds nothing this source reports, and the
5351 // keys are left out of its caller-visible metadata all the same.
5352 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5353 let listed = |key: &str| {
5354 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5355 .map_err(|message| SourceError::Malformed { message })
5356 };
5357 (
5358 listed(TaskRef::DELIVERS_KEY)?,
5359 listed(TaskRef::DELIVERED_BY_KEY)?,
5360 )
5361 } else {
5362 (Vec::new(), Vec::new())
5363 };
5364 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5365 let priority = self.held_priority(nodes)?;
5366 // Present when the item was reached through its own issue, whose board entry
5367 // names the board; a read of the board's own items has the board already. An
5368 // empty id names nothing a field write could address, so it is read as absent and
5369 // the write goes back to reading the board.
5370 let board_id = item
5371 .pointer("/project/id")
5372 .and_then(Value::as_str)
5373 .filter(|id| !id.is_empty());
5374 let resolved = Resolved {
5375 item_id: required_str(item, "id")?.to_owned(),
5376 id,
5377 content_kind,
5378 kind,
5379 title,
5380 body: body.filter(|value| !value.is_empty()),
5381 raw_body,
5382 status: self
5383 .statuses
5384 .status(kind.status_kind(), option, closed, reason),
5385 option: option.map(str::to_owned),
5386 priority,
5387 closed,
5388 delivers,
5389 delivered_by,
5390 labels: labels(content)?,
5391 parent,
5392 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5393 number: match content_kind {
5394 ContentKind::Issue => Some(issue_number(content)?),
5395 // A draft is filed in no repository, so nothing ever numbered it:
5396 // `DraftIssue` declares no `number` at all, exactly as it declares no
5397 // `subIssuesSummary` the branch above reads.
5398 ContentKind::DraftIssue => None,
5399 },
5400 url: optional_str(content, "url")?.map(str::to_owned),
5401 created_at: optional_time(content, "createdAt")?,
5402 updated_at: optional_time(content, "updatedAt")?,
5403 own_repository,
5404 repositories,
5405 slot,
5406 board_id: board_id.map(str::to_owned),
5407 fields: field_definitions(nodes),
5408 board_fields: Self::carried_board_fields(content, board_id)?,
5409 blocked_by: carried_blocked_by(content)?,
5410 };
5411 self.resolved_cache()?
5412 .insert(resolved.id.clone(), resolved.clone());
5413 Ok(Some(resolved))
5414 }
5415
5416 /// The field definitions of the board `board_id` names — the project this issue's own
5417 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5418 /// `None` when the read carried none, carried no entry for that board, or the board item
5419 /// named no board, which a write then answers by reading the board's fields itself.
5420 ///
5421 /// Matched by the board's node id and never by its number alone: a project number is
5422 /// unique only within its owner, so another owner's board numbered alike can sit on the
5423 /// same page, and its field and option ids address nothing on this one.
5424 fn carried_board_fields(
5425 content: &Value,
5426 board_id: Option<&str>,
5427 ) -> Result<Option<Value>, SourceError> {
5428 let (Some(nodes), Some(board_id)) = (
5429 content.pointer("/boards/nodes").and_then(Value::as_array),
5430 board_id,
5431 ) else {
5432 return Ok(None);
5433 };
5434 let Some(board) = nodes.iter().find_map(|node| {
5435 let project = node.get("project")?;
5436 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5437 }) else {
5438 return Ok(None);
5439 };
5440 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5441 return Ok(None);
5442 };
5443 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5444 Ok(Some(fields.clone()))
5445 }
5446
5447 /// What one board item's `Priority` field says, through this instance's mapping.
5448 ///
5449 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5450 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5451 /// option the mapping does not name is kept as itself — never read as a level or as
5452 /// `none` — for a read of the task to report by name.
5453 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5454 let Some(mapping) = &self.priorities else {
5455 return Ok(HeldPriority::Read(Priority::None));
5456 };
5457 // A value of the field that names no option — a text field someone called `Priority` —
5458 // is malformed rather than `none`: reading it as no priority would let the next copy
5459 // clear one a person set.
5460 let Some(option) = field_values
5461 .iter()
5462 .find(|value| {
5463 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5464 })
5465 .map(|value| required_str(value, "name"))
5466 .transpose()?
5467 else {
5468 return Ok(HeldPriority::Read(Priority::None));
5469 };
5470 Ok(mapping.priority_of(option).map_or_else(
5471 || HeldPriority::Unmapped(option.to_owned()),
5472 HeldPriority::Read,
5473 ))
5474 }
5475
5476 /// What one board item's status is read from: its `Status` option, whether its issue
5477 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5478 /// three into the status it reports.
5479 fn status_parts<'a>(
5480 field_values: &'a [Value],
5481 content: &'a Value,
5482 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5483 let option = field_values
5484 .iter()
5485 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5486 .map(|value| required_str(value, "name"))
5487 .transpose()?;
5488 let closed = optional_str(content, "state")? == Some("CLOSED");
5489 Ok((option, closed, optional_str(content, "stateReason")?))
5490 }
5491
5492 /// The board Status option this write selects, or the refusal that says why not.
5493 ///
5494 /// The mapped option is required for both open and terminal targets. A terminal write
5495 /// validates it before changing either representation, so it can never fall back to
5496 /// closing an issue whose board cannot display the matching status.
5497 ///
5498 /// Answers the field's id, the option's id, and the option's name as the board spells
5499 /// it — which is the name a read of the item reports once it sits there.
5500 fn column_for(
5501 &self,
5502 fields: &Value,
5503 kind: ItemKind,
5504 category: StatusCategory,
5505 target: &StatusTarget,
5506 ) -> Result<Option<(String, String, String)>, SourceError> {
5507 let Some(wanted) = target.option() else {
5508 return Ok(None);
5509 };
5510 let missing = |detail: &str| SourceError::Refused {
5511 message: format!(
5512 "{} status {} of source {} needs the board Status option {wanted:?}, and \
5513 {detail}; next: add that option to the board, which `onetaskgraph sources \
5514 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5515 it has",
5516 kind.marker(),
5517 category_name(category),
5518 self.name,
5519 self.name,
5520 category_name(category),
5521 kind.marker()
5522 ),
5523 };
5524 let Some(field) = Board::field(fields, "Status")? else {
5525 return Err(missing("this board has no Status field"));
5526 };
5527 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5528 return Err(missing(
5529 "this board's Status field is not a single-select field",
5530 ));
5531 }
5532 let option = field
5533 .get("options")
5534 .and_then(Value::as_array)
5535 .and_then(|options| {
5536 options.iter().find(|option| {
5537 option
5538 .get("name")
5539 .and_then(Value::as_str)
5540 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5541 })
5542 });
5543 match option {
5544 None => Err(missing("this board does not have it")),
5545 Some(option) => Ok(Some((
5546 required_str(field, "id")?.to_owned(),
5547 required_str(option, "id")?.to_owned(),
5548 required_str(option, "name")?.to_owned(),
5549 ))),
5550 }
5551 }
5552
5553 /// The refusal a status that closes an issue is answered with over a board draft.
5554 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5555 SourceError::Refused {
5556 message: format!(
5557 "status {} of source {} closes the item's issue, and GitHub draft items have \
5558 no open or closed state",
5559 category_name(category),
5560 self.name
5561 ),
5562 }
5563 }
5564
5565 /// What a status write to one item needs of the board: the board's id and the
5566 /// definition of its `Status` field, read off the item when the item says both.
5567 ///
5568 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5569 /// and its `Status` value carries that field's definition, options and all. An item that
5570 /// does not say — no board id, or no `Status` value to read the field off — takes them
5571 /// from [`Self::board_fields`], which reads no item.
5572 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5573 if let Some(board) = item.carried_board() {
5574 return Ok(board);
5575 }
5576 if item.defines("Status")
5577 && let Some(board_id) = item.named_board()
5578 {
5579 return Ok(BoardFields {
5580 id: board_id,
5581 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5582 });
5583 }
5584 self.board_fields().await
5585 }
5586
5587 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5588 async fn set_status(
5589 &self,
5590 id: &NativeId,
5591 category: StatusCategory,
5592 ) -> Result<Option<Status>, SourceError> {
5593 // Refused before anything is read, in the words a write of the same status is.
5594 let target = self.resolved_target(ItemKind::Task, category)?;
5595 let Some(mut item) = self
5596 .bound_item(id)
5597 .await?
5598 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5599 else {
5600 return Ok(None);
5601 };
5602 let board = self.status_board(&item).await?;
5603 let (field, option, name) = self
5604 .column_for(&board.fields, ItemKind::Task, category, &target)?
5605 .ok_or_else(|| SourceError::Malformed {
5606 message: format!(
5607 "status {} of source {} names no board Status option",
5608 category_name(category),
5609 self.name
5610 ),
5611 })?;
5612 if item.status.category == category && item.option.as_deref() == Some(&name) {
5613 return Ok(Some(item.status));
5614 }
5615 match &target {
5616 StatusTarget::Terminal(_, reason) => {
5617 if item.content_kind == ContentKind::DraftIssue {
5618 return Err(self.closes_a_draft(category));
5619 }
5620 self.set_item_field(
5621 board.id.as_str(),
5622 &item.item_id,
5623 &field,
5624 json!({"singleSelectOptionId": option}),
5625 )
5626 .await?;
5627 self.update_content(
5628 ContentKind::Issue,
5629 &item.id,
5630 json!({"stateInput": state_input(Some(&target))}),
5631 )
5632 .await?;
5633 item.closed = true;
5634 item.status =
5635 self.statuses
5636 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5637 item.option = Some(name);
5638 }
5639 StatusTarget::Column(_) => {
5640 // An option is what an open item's status is, so a closed issue is reopened
5641 // first — sitting closed in the column, it would read back as closed. A draft has
5642 // no state to reopen.
5643 if item.content_kind == ContentKind::Issue && item.closed {
5644 self.update_content(
5645 ContentKind::Issue,
5646 &item.id,
5647 json!({"stateInput": state_input(Some(&target))}),
5648 )
5649 .await?;
5650 item.closed = false;
5651 }
5652 self.set_item_field(
5653 board.id.as_str(),
5654 &item.item_id,
5655 &field,
5656 json!({"singleSelectOptionId": option}),
5657 )
5658 .await?;
5659 item.status = self
5660 .statuses
5661 .status(ItemKind::Task, Some(&name), false, None);
5662 item.option = Some(name);
5663 }
5664 StatusTarget::Disabled(_) => {
5665 unreachable!("resolved_target refused a disabled status")
5666 }
5667 }
5668 let status = item.status.clone();
5669 self.remember_written(item, false)?;
5670 Ok(Some(status))
5671 }
5672
5673 /// Replace one task's `delivered_by` and nothing else; see
5674 /// [`TaskSource::set_delivered_by`].
5675 ///
5676 /// One update of the body, which differs from the body GitHub holds only inside the
5677 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5678 async fn replace_delivered_by(
5679 &self,
5680 id: &NativeId,
5681 delivered_by: &[TaskRef],
5682 ) -> Result<Option<()>, SourceError> {
5683 let entries = TaskRef::listed(
5684 TaskRef::DELIVERED_BY_KEY,
5685 id,
5686 Some(&self.name),
5687 delivered_by.to_vec(),
5688 )
5689 .map_err(|message| SourceError::Refused { message })?;
5690 let Some(mut item) = self
5691 .bound_item(id)
5692 .await?
5693 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5694 else {
5695 return Ok(None);
5696 };
5697 let mut slot = item.slot.clone();
5698 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5699 self.write_slot(&mut item, &slot).await?;
5700 item.delivered_by = entries;
5701 self.remember_written(item, false)?;
5702 Ok(Some(()))
5703 }
5704
5705 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5706 /// see [`TaskSource::set_task_metadata`].
5707 ///
5708 /// `None` when this board holds no item by that id, or holds one of another kind. The
5709 /// answer is the item as this source now reads it, so what a caller is told the key
5710 /// holds is what the slot holds.
5711 ///
5712 /// A key already holding the value is answered without a write, compared as JSON rather
5713 /// than as the body's bytes: a slot a person spelled with other whitespace would
5714 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5715 async fn set_slot_key(
5716 &self,
5717 id: &NativeId,
5718 kind: BoardKind,
5719 key: &MetadataKey,
5720 value: &Value,
5721 ) -> Result<Option<Resolved>, SourceError> {
5722 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5723 return Ok(None);
5724 };
5725 if item.slot.get(key.as_str()) == Some(value) {
5726 return Ok(Some(item));
5727 }
5728 let mut slot = item.slot.clone();
5729 slot.insert(key.as_str().to_owned(), value.clone());
5730 self.write_slot(&mut item, &slot).await?;
5731 self.remember_written(item.clone(), false)?;
5732 Ok(Some(item))
5733 }
5734
5735 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5736 /// `item` up to what that write left.
5737 ///
5738 /// The body sent differs from the body GitHub holds only inside the slot — see
5739 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5740 /// the mutation the item's content takes, so a board draft's body is written with
5741 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5742 async fn write_slot(
5743 &self,
5744 item: &mut Resolved,
5745 slot: &BTreeMap<String, Value>,
5746 ) -> Result<(), SourceError> {
5747 let held = item.raw_body.clone().unwrap_or_default();
5748 let body = with_slot(&held, slot)?;
5749 if body != held {
5750 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5751 .await?;
5752 }
5753 let (visible, slot) = metadata_body(Some(body.clone()))?;
5754 item.body = visible.filter(|value| !value.is_empty());
5755 item.raw_body = Some(body);
5756 item.slot = slot;
5757 Ok(())
5758 }
5759
5760 /// This instance's target for a category written to an item of `kind`, refusing one
5761 /// that kind has no option for — before anything is read or written.
5762 ///
5763 /// Nothing here mutates the board's option set to make room for a status. GitHub
5764 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5765 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5766 /// field and every item's status.
5767 fn resolved_target(
5768 &self,
5769 kind: ItemKind,
5770 category: StatusCategory,
5771 ) -> Result<StatusTarget, SourceError> {
5772 let target = self.statuses.target(kind, category).clone();
5773 let StatusTarget::Disabled(why) = target else {
5774 return Ok(target);
5775 };
5776 let refusal = why.refusal(&self.name, category, kind);
5777 // Why there is no shipped default, which is the question a person meeting this
5778 // refusal on a source that never mentioned the category asks.
5779 let shipped_none = match category {
5780 StatusCategory::Draft => Some(
5781 "draft has no shipped default because GitHub draft issues cannot have \
5782 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5783 ),
5784 StatusCategory::Unknown => Some(
5785 "unknown has no shipped default because this board keeps no open-ended status \
5786 word: every word classified unknown is written to the one board Status option \
5787 status_mapping.unknown names",
5788 ),
5789 _ => None,
5790 };
5791 Err(match (refusal, shipped_none, why) {
5792 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5793 SourceError::Refused {
5794 message: format!("{message}; {note}"),
5795 }
5796 }
5797 (refusal, _, _) => refusal,
5798 })
5799 }
5800
5801 /// What writing `priority` does to one item's `Priority` field on this board, or the
5802 /// refusal naming what the board lacks.
5803 ///
5804 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5805 /// none already, or of an item not created yet. Every other priority selects the option
5806 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5807 /// without that option, is refused rather than given one: reads and writes never create
5808 /// a field or an option.
5809 fn priority_write(
5810 &self,
5811 fields: &Value,
5812 existing: Option<&Resolved>,
5813 priority: Priority,
5814 ) -> Result<Option<PriorityWrite>, SourceError> {
5815 let Some(mapping) = &self.priorities else {
5816 return Err(self.holds_no_priority());
5817 };
5818 let Some(wanted) = mapping.option(priority) else {
5819 if !existing.is_some_and(Resolved::holds_priority) {
5820 return Ok(None);
5821 }
5822 let field =
5823 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5824 message: format!(
5825 "an item holding a {PRIORITY_FIELD} value was read without that field"
5826 ),
5827 })?;
5828 return Ok(Some(PriorityWrite::Clear {
5829 field: required_str(field, "id")?.to_owned(),
5830 }));
5831 };
5832 let missing = |detail: &str| SourceError::Refused {
5833 message: format!(
5834 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5835 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5836 it, or point priority_mapping.{priority} of this source at an option the board \
5837 has",
5838 self.name, self.name
5839 ),
5840 };
5841 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5842 return Err(missing(&format!(
5843 "this board has no {PRIORITY_FIELD} field"
5844 )));
5845 };
5846 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5847 return Err(missing(&format!(
5848 "this board's {PRIORITY_FIELD} field is not a single-select field"
5849 )));
5850 }
5851 // An options list that is absent or not a list is an answer this source cannot read,
5852 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5853 let option = field
5854 .get("options")
5855 .and_then(Value::as_array)
5856 .ok_or_else(|| SourceError::Malformed {
5857 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5858 })?
5859 .iter()
5860 .find(|option| {
5861 option
5862 .get("name")
5863 .and_then(Value::as_str)
5864 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5865 })
5866 .ok_or_else(|| missing("this board does not have it"))?;
5867 Ok(Some(PriorityWrite::Select {
5868 field: required_str(field, "id")?.to_owned(),
5869 option: required_str(option, "id")?.to_owned(),
5870 }))
5871 }
5872
5873 /// Apply one priority write to one board item.
5874 async fn write_priority(
5875 &self,
5876 board_id: &str,
5877 item_id: &str,
5878 write: &PriorityWrite,
5879 ) -> Result<(), SourceError> {
5880 match write {
5881 PriorityWrite::Select { field, option } => {
5882 self.set_item_field(
5883 board_id,
5884 item_id,
5885 field,
5886 json!({"singleSelectOptionId": option}),
5887 )
5888 .await
5889 }
5890 PriorityWrite::Clear { field } => {
5891 let data = self
5892 .graphql(
5893 graphql::CLEAR_FIELD,
5894 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5895 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5896 )
5897 .await?;
5898 let returned = data
5899 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5900 .ok_or_else(|| SourceError::Malformed {
5901 message: "GitHub field clear returned no project item".into(),
5902 })?;
5903 if required_str(returned, "id")? != item_id {
5904 return Err(SourceError::Malformed {
5905 message: "GitHub field clear returned the wrong project item".into(),
5906 });
5907 }
5908 Ok(())
5909 }
5910 }
5911 }
5912
5913 /// The refusal a priority is answered with by an instance configured with no
5914 /// `priority_mapping`, which holds none.
5915 fn holds_no_priority(&self) -> SourceError {
5916 SourceError::Refused {
5917 message: format!(
5918 "source {} holds no task priority: its configuration sets no priority_mapping; \
5919 next: set priority_mapping on this source, then run `onetaskgraph sources \
5920 fields {} --apply` to set its board up",
5921 self.name, self.name
5922 ),
5923 }
5924 }
5925
5926 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5927 ///
5928 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5929 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5930 async fn set_priority(
5931 &self,
5932 id: &NativeId,
5933 priority: Priority,
5934 ) -> Result<Option<Priority>, SourceError> {
5935 if self.priorities.is_none() {
5936 return Err(self.holds_no_priority());
5937 }
5938 let Some(mut item) = self
5939 .bound_item(id)
5940 .await?
5941 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5942 else {
5943 return Ok(None);
5944 };
5945 if priority == Priority::None && !item.holds_priority() {
5946 return Ok(Some(priority));
5947 }
5948 // The item's own read carries the field's definition whenever it holds a value of
5949 // it, which a clear always does; a select onto an item holding none reads the board.
5950 let board = match (item.carried_board(), item.named_board()) {
5951 (Some(board), _) => board,
5952 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5953 id,
5954 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5955 },
5956 _ => self.board_fields().await?,
5957 };
5958 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5959 return Ok(Some(priority));
5960 };
5961 let (document, root, input) = match write {
5962 PriorityWrite::Select { field, option } => (
5963 graphql::UPDATE_FIELD,
5964 "updateProjectV2ItemFieldValue",
5965 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5966 ),
5967 PriorityWrite::Clear { field } => (
5968 graphql::CLEAR_FIELD,
5969 "clearProjectV2ItemFieldValue",
5970 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5971 ),
5972 };
5973 let data = self
5974 .graphql(
5975 document,
5976 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5977 )
5978 .await?;
5979 let returned = data
5980 .get(root)
5981 .and_then(|value| value.get("projectV2Item"))
5982 .ok_or_else(|| SourceError::Malformed {
5983 message: "GitHub priority write returned no project item".into(),
5984 })?;
5985 if required_str(returned, "id")? != item.item_id {
5986 return Err(SourceError::Malformed {
5987 message: "GitHub priority write returned the wrong project item".into(),
5988 });
5989 }
5990 let value = returned
5991 .get("fieldValueByName")
5992 .ok_or_else(|| SourceError::Malformed {
5993 message: "GitHub priority write returned no priority read-back".into(),
5994 })?;
5995 if !value.is_null()
5996 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5997 {
5998 return Err(SourceError::Malformed {
5999 message: "GitHub priority read-back is not a Priority field value".into(),
6000 });
6001 }
6002 let values = if value.is_null() {
6003 Vec::new()
6004 } else {
6005 vec![value.clone()]
6006 };
6007 item.priority = self.held_priority(&values)?;
6008 let answer = item.task()?.priority;
6009 self.remember_written(item, false)?;
6010 Ok(Some(answer))
6011 }
6012
6013 /// Replace one task's visible body and nothing else; see
6014 /// [`TaskSource::set_task_content`].
6015 ///
6016 /// One update of the body, which differs from the body GitHub holds only outside the
6017 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
6018 /// this source keeps there reads back as it was. A body that would not change is not
6019 /// sent at all.
6020 async fn replace_content(
6021 &self,
6022 id: &NativeId,
6023 content: &str,
6024 ) -> Result<Option<()>, SourceError> {
6025 let Some(mut item) = self
6026 .bound_item(id)
6027 .await?
6028 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6029 else {
6030 return Ok(None);
6031 };
6032 let held = item.raw_body.clone().unwrap_or_default();
6033 let body = with_content(&held, content)?;
6034 // Checked before anything is sent: content ending in what this source reads as its own
6035 // metadata slot would read back as metadata rather than as the content it was.
6036 let (visible, slot) = metadata_body(Some(body.clone()))?;
6037 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
6038 return Err(SourceError::Refused {
6039 message: format!(
6040 "this content ends in what source {} reads as its own metadata slot \
6041 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6042 as content; next: remove that trailing block from the content",
6043 self.name
6044 ),
6045 });
6046 }
6047 if body != held {
6048 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6049 .await?;
6050 }
6051 item.body = visible.filter(|value| !value.is_empty());
6052 item.raw_body = Some(body);
6053 item.slot = slot;
6054 self.remember_written(item, false)?;
6055 Ok(Some(()))
6056 }
6057
6058 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
6059 ///
6060 /// One read of the item — which carries the board's field definitions and the issue's
6061 /// `blockedBy`, so neither is read again — and then only what differs from it: the
6062 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
6063 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
6064 /// the title, the body — visible content and metadata slot together — and a state change.
6065 /// So an update naming any of title, body, metadata, status and priority is one read and
6066 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
6067 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6068 /// as a whole write does; an open one selects its option and then reopens. The origin
6069 /// field is never written: an update is of an item that already exists, whose origin is
6070 /// what it is.
6071 ///
6072 /// The task answered is the item as those writes left it, built from the read and what was
6073 /// sent rather than read again — the same record a later read in this run answers from.
6074 async fn targeted_update(
6075 &self,
6076 id: &NativeId,
6077 update: &TaskUpdate,
6078 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6079 // Everything this source can refuse without reading the item is refused first, in the
6080 // words a whole write of the same fields is refused with.
6081 update.consistent()?;
6082 if update
6083 .title
6084 .as_deref()
6085 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6086 {
6087 return Err(SourceError::Refused {
6088 message: format!(
6089 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6090 spells a document, so it would read back as one rather than as a task; \
6091 retitle it",
6092 self.name
6093 ),
6094 });
6095 }
6096 if let Some(delivers) = &update.delivers {
6097 TaskRef::listed(
6098 TaskRef::DELIVERS_KEY,
6099 id,
6100 Some(&self.name),
6101 delivers.clone(),
6102 )
6103 .map_err(|message| SourceError::Refused { message })?;
6104 }
6105 if self.priorities.is_none()
6106 && update
6107 .priority
6108 .is_some_and(|priority| priority != Priority::None)
6109 {
6110 return Err(self.holds_no_priority());
6111 }
6112 let target = update
6113 .status
6114 .as_ref()
6115 .map(|status| self.resolved_target(ItemKind::Task, status.category))
6116 .transpose()?;
6117 let Some(mut item) = self
6118 .bound_item(id)
6119 .await?
6120 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6121 else {
6122 return Ok(None);
6123 };
6124 let before = item.task()?;
6125
6126 let mut status_move = None;
6127 if let (Some(status), Some(target)) = (&update.status, target) {
6128 let board = self.status_board(&item).await?;
6129 let (field, option, name) = self
6130 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6131 .ok_or_else(|| SourceError::Malformed {
6132 message: format!(
6133 "status {} of source {} names no board Status option",
6134 category_name(status.category),
6135 self.name
6136 ),
6137 })?;
6138 let terminal = matches!(target, StatusTarget::Terminal(_, _));
6139 if terminal && item.content_kind == ContentKind::DraftIssue {
6140 return Err(self.closes_a_draft(status.category));
6141 }
6142 let landed = match &target {
6143 StatusTarget::Terminal(_, reason) => {
6144 self.statuses
6145 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6146 }
6147 _ => self
6148 .statuses
6149 .status(ItemKind::Task, Some(&name), false, None),
6150 };
6151 let option_moves = item
6152 .option
6153 .as_deref()
6154 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6155 let state_moves = item.content_kind == ContentKind::Issue
6156 && (item.closed != terminal || (terminal && item.status != landed));
6157 if let Some(moves) = Moves::of(option_moves, state_moves) {
6158 status_move = Some(StatusMove {
6159 board: board.id,
6160 field,
6161 option,
6162 name,
6163 target,
6164 landed,
6165 moves,
6166 });
6167 }
6168 }
6169
6170 let mut priority_move = None;
6171 if let Some(priority) = update.priority
6172 && self.priorities.is_some()
6173 && item.priority != HeldPriority::Read(priority)
6174 {
6175 let board = match (item.carried_board(), item.named_board()) {
6176 (Some(board), _) => board,
6177 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6178 id: board,
6179 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6180 },
6181 _ => self.board_fields().await?,
6182 };
6183 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6184 priority_move = Some((board.id, write, priority));
6185 }
6186 }
6187
6188 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6189 // recorded in the slot, and the slot travels in the one body update below.
6190 let edges = match &update.depends_on {
6191 Some(edges) => Some(
6192 self.partition_edges(
6193 BoardKind::Work(ItemKind::Task),
6194 item.content_kind,
6195 item.blocked_by.as_deref(),
6196 edges,
6197 )
6198 .await?,
6199 ),
6200 None => None,
6201 };
6202
6203 let mut slot = item.slot.clone();
6204 for (key, value) in &update.metadata_set {
6205 slot.insert(key.as_str().to_owned(), value.clone());
6206 }
6207 for key in &update.metadata_remove {
6208 slot.remove(key.as_str());
6209 }
6210 if let Some(delivers) = &update.delivers {
6211 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6212 }
6213 if let Some((_, recorded)) = &edges {
6214 record_edges(&mut slot, recorded);
6215 }
6216 let held = item.raw_body.clone().unwrap_or_default();
6217 let content = match &update.content {
6218 Some(content) => with_content(&held, content)?,
6219 None => held.clone(),
6220 };
6221 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6222 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6223 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6224 let body = if slot == item.slot {
6225 content
6226 } else {
6227 with_slot(&content, &slot)?
6228 };
6229 // Checked before anything is sent, as a content write checks it: content ending in
6230 // what this source reads as its own slot would read back as metadata.
6231 let (visible, read) = metadata_body(Some(body.clone()))?;
6232 let wanted = update.content.as_deref().or(item.body.as_deref());
6233 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6234 return Err(SourceError::Refused {
6235 message: format!(
6236 "this content ends in what source {} reads as its own metadata slot \
6237 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6238 as content; next: remove that trailing block from the content",
6239 self.name
6240 ),
6241 });
6242 }
6243 let recorded_moves =
6244 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6245
6246 // One `updateIssue` carries all three, because every mutation spends the secondary
6247 // limiter and the title, body and state are one mutation's inputs.
6248 let mut fields = serde_json::Map::new();
6249 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6250 fields.insert("title".to_owned(), json!(title));
6251 }
6252 if body != held {
6253 fields.insert("body".to_owned(), json!(body));
6254 }
6255 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6256 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6257 }
6258 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6259 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6260 // without undoing an earlier field when a later one fails — so a body written before a
6261 // board field the board then refused would be left changed. Written after every other
6262 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6263 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6264 // first, together in one request — a terminal option selected before the issue
6265 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6266 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6267 let mut clear: Option<(&BoardId, &str)> = None;
6268 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6269 board_writes.push((
6270 &moving.board,
6271 (
6272 moving.field.clone(),
6273 json!({"singleSelectOptionId": moving.option}),
6274 ),
6275 ));
6276 }
6277 match &priority_move {
6278 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6279 board,
6280 (field.clone(), json!({"singleSelectOptionId": option})),
6281 )),
6282 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6283 None => {}
6284 }
6285 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6286 boards.extend(clear.map(|(board, _)| board));
6287 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6288 for board in boards {
6289 let writes = board_writes
6290 .iter()
6291 .filter(|(on, _)| on.as_str() == board.as_str())
6292 .map(|(_, write)| write.clone())
6293 .collect::<Vec<_>>();
6294 let cleared = clear
6295 .filter(|(on, _)| on.as_str() == board.as_str())
6296 .map(|(_, field)| field);
6297 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6298 .await?;
6299 }
6300 let mut blocked_by_moved = false;
6301 if let Some((native, _)) = &edges
6302 && item.content_kind == ContentKind::Issue
6303 {
6304 blocked_by_moved = self
6305 .reconcile_blocked_by(
6306 &item.id,
6307 native,
6308 Issue::Existing(item.blocked_by.as_deref()),
6309 )
6310 .await?;
6311 }
6312 if !fields.is_empty() {
6313 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6314 .await?;
6315 }
6316
6317 if let Some(title) = &update.title {
6318 item.title.clone_from(title);
6319 }
6320 item.body = visible.filter(|value| !value.is_empty());
6321 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6322 item.slot = slot;
6323 if let Some(delivers) = &update.delivers {
6324 item.delivers.clone_from(delivers);
6325 }
6326 if let Some(moving) = status_move {
6327 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6328 && item.content_kind == ContentKind::Issue;
6329 item.status = moving.landed;
6330 item.option = Some(moving.name);
6331 }
6332 if let Some((_, _, priority)) = priority_move {
6333 item.priority = HeldPriority::Read(priority);
6334 }
6335 let task = item.task()?;
6336 let mut written = update.changed(&before, &task);
6337 if blocked_by_moved || recorded_moves {
6338 written.insert(UpdatedField::DependsOn);
6339 }
6340 self.remember_written(item, false)?;
6341 Ok(Some(TaskUpdateOutcome {
6342 task,
6343 written,
6344 delivers_before: before.delivers,
6345 }))
6346 }
6347
6348 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6349 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6350 ///
6351 /// One update of the body: the content outside the slot, and inside it that one entry,
6352 /// every other entry kept as it was. This source keeps no template answers — an issue has
6353 /// no room beside itself that is not its body, and answers written there would duplicate
6354 /// what the content already says and count against GitHub's body limit — so `answers`
6355 /// reaches nothing here. A body that would not change is not sent at all.
6356 async fn replace_rendering(
6357 &self,
6358 id: &NativeId,
6359 kind: BoardKind,
6360 content: &str,
6361 provenance: &Value,
6362 ) -> Result<Option<()>, SourceError> {
6363 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6364 return Ok(None);
6365 };
6366 let held = item.raw_body.clone().unwrap_or_default();
6367 let mut slot = item.slot.clone();
6368 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6369 let body = with_slot(&with_content(&held, content)?, &slot)?;
6370 // Checked before anything is sent, as a content write checks it.
6371 let (visible, read) = metadata_body(Some(body.clone()))?;
6372 if visible.as_deref().unwrap_or_default() != content || read != slot {
6373 return Err(SourceError::Refused {
6374 message: format!(
6375 "this content ends in what source {} reads as its own metadata slot \
6376 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6377 as content; next: remove that trailing block from the template",
6378 self.name
6379 ),
6380 });
6381 }
6382 if body != held {
6383 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6384 .await?;
6385 }
6386 item.body = visible.filter(|value| !value.is_empty());
6387 item.raw_body = Some(body);
6388 item.slot = read;
6389 self.remember_written(item, false)?;
6390 Ok(Some(()))
6391 }
6392
6393 async fn set_item_field(
6394 &self,
6395 board_id: &str,
6396 item_id: &str,
6397 field_id: &str,
6398 value: Value,
6399 ) -> Result<(), SourceError> {
6400 let data = self
6401 .graphql(
6402 graphql::UPDATE_FIELD,
6403 json!({"input":{
6404 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6405 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6406 )
6407 .await?;
6408 let returned = data
6409 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6410 .ok_or_else(|| SourceError::Malformed {
6411 message: "GitHub field update returned no project item".into(),
6412 })?;
6413 if required_str(returned, "id")? != item_id {
6414 return Err(SourceError::Malformed {
6415 message: "GitHub field update returned the wrong project item".into(),
6416 });
6417 }
6418 Ok(())
6419 }
6420
6421 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6422 /// one request. Every returned item id is checked, including optional aliases.
6423 async fn set_item_fields(
6424 &self,
6425 board: &str,
6426 item: &str,
6427 fields: &[(String, Value)],
6428 clear: Option<&str>,
6429 ) -> Result<(), SourceError> {
6430 if fields.len() <= 1 && clear.is_none() {
6431 if let Some((field, value)) = fields.first() {
6432 self.set_item_field(board, item, field, value.clone())
6433 .await?;
6434 }
6435 return Ok(());
6436 }
6437 if fields.is_empty() {
6438 if let Some(field) = clear {
6439 self.write_priority(
6440 board,
6441 item,
6442 &PriorityWrite::Clear {
6443 field: field.to_owned(),
6444 },
6445 )
6446 .await?;
6447 }
6448 return Ok(());
6449 }
6450 let input = |index: usize| {
6451 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6452 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6453 };
6454 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6455 "input":input(0),"second":input(1),"third":input(2),
6456 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6457 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6458 })).await?;
6459 for alias in [
6460 Some("updateProjectV2ItemFieldValue"),
6461 (fields.len() > 1).then_some("second"),
6462 (fields.len() > 2).then_some("third"),
6463 clear.map(|_| "cleared"),
6464 ]
6465 .into_iter()
6466 .flatten()
6467 {
6468 let returned = data
6469 .get(alias)
6470 .and_then(|value| value.get("projectV2Item"))
6471 .ok_or_else(|| SourceError::Malformed {
6472 message: format!("GitHub field update {alias} returned no project item"),
6473 })?;
6474 if required_str(returned, "id")? != item {
6475 return Err(SourceError::Malformed {
6476 message: format!("GitHub field update {alias} returned the wrong project item"),
6477 });
6478 }
6479 }
6480 Ok(())
6481 }
6482
6483 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6484 let mut after: Option<String> = None;
6485 let mut ids = Vec::new();
6486 loop {
6487 let data = self
6488 .graphql(
6489 graphql::ISSUE_DEPENDENCIES,
6490 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6491 )
6492 .await?;
6493 let connection =
6494 data.pointer("/node/blockedBy")
6495 .ok_or_else(|| SourceError::Malformed {
6496 message: "GitHub dependency response has no blockedBy connection".into(),
6497 })?;
6498 ids.extend(
6499 connection
6500 .get("nodes")
6501 .and_then(Value::as_array)
6502 .ok_or_else(|| SourceError::Malformed {
6503 message: "GitHub dependency response nodes is not an array".into(),
6504 })?
6505 .iter()
6506 .map(|value| required_str(value, "id").map(str::to_owned))
6507 .collect::<Result<Vec<_>, _>>()?,
6508 );
6509 let next = next_cursor(connection)?;
6510 if let Some(next) = &next {
6511 validate_cursor_progress(after.as_deref(), &next.0)?;
6512 }
6513 after = next.map(|cursor| cursor.0);
6514 if after.is_none() {
6515 return Ok(ids);
6516 }
6517 }
6518 }
6519
6520 async fn dependencies(
6521 &self,
6522 id: &NativeId,
6523 near_kind: ItemKind,
6524 direction: Direction,
6525 page: &PageRequest,
6526 ) -> Result<Page<DependencyEdge>, SourceError> {
6527 validate_page(page)?;
6528 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6529 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6530 let recorded = recorded_offset(cursor, direction)?;
6531 // What this issue is blocked by, when a read of it by its own id in this command
6532 // already carried the whole connection — a copy reads the item it writes before it
6533 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6534 // after it. Answered from that read, in the shape the dependency read answers in;
6535 // anything else is asked of GitHub.
6536 let carried = match direction {
6537 Direction::DependsOn => self
6538 .resolved_cache()?
6539 .get(id)
6540 .filter(|item| item.content_kind == ContentKind::Issue)
6541 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6542 Direction::DependedOnBy => None,
6543 }
6544 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6545 // Asked for even in the recorded phase, whose page reads nothing from the
6546 // connection: `__typename` is what says whether this item has a native
6547 // relationship at all, and that is what decides which far ends the reserved key is
6548 // allowed to hold.
6549 let data = match carried {
6550 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6551 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6552 None => {
6553 self.graphql(
6554 graphql::ISSUE_DEPENDENCIES,
6555 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6556 "after":if recorded.is_some() {None} else {cursor}}),
6557 )
6558 .await?
6559 }
6560 };
6561 let node =
6562 data.get("node")
6563 .filter(|v| !v.is_null())
6564 .ok_or_else(|| SourceError::Refused {
6565 message: format!(
6566 "GitHub item {} was not found or does not support dependencies",
6567 id.0
6568 ),
6569 })?;
6570 let connection_name = match direction {
6571 Direction::DependsOn => "blockedBy",
6572 Direction::DependedOnBy => "blocking",
6573 };
6574 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6575 // named natively and the reserved key may hold any far end. An issue's connections
6576 // hold issues, and this source reads them at the near item's own level.
6577 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6578 if let Some(offset) = recorded {
6579 return Ok(recorded_page(
6580 self.recorded_edges(id, near_kind, direction, natively_names, node)
6581 .await?,
6582 offset,
6583 limit,
6584 ));
6585 }
6586 if natively_names.is_none() {
6587 return Ok(recorded_page(
6588 self.recorded_edges(id, near_kind, direction, natively_names, node)
6589 .await?,
6590 0,
6591 limit,
6592 ));
6593 }
6594 let connection = node
6595 .get(connection_name)
6596 .ok_or_else(|| SourceError::Malformed {
6597 message: "GitHub dependency response is missing its connection".into(),
6598 })?;
6599 let nodes = connection
6600 .get("nodes")
6601 .and_then(Value::as_array)
6602 .ok_or_else(|| SourceError::Malformed {
6603 message: "GitHub dependency response nodes is not an array".into(),
6604 })?;
6605 // `from` depends on `to`, always. GitHub spells the same relationship from either
6606 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6607 // it — so the near item is `from` in one direction and `to` in the other.
6608 let items = nodes
6609 .iter()
6610 .map(|value| {
6611 let related = NativeId(required_str(value, "id")?.into());
6612 let related_kind = related_kind(value)?;
6613 let (from, to) = match direction {
6614 Direction::DependsOn => (
6615 DependencyEndpoint::from_native(id.clone(), near_kind),
6616 DependencyEndpoint::from_native(related, related_kind),
6617 ),
6618 Direction::DependedOnBy => (
6619 DependencyEndpoint::from_native(related, related_kind),
6620 DependencyEndpoint::from_native(id.clone(), near_kind),
6621 ),
6622 };
6623 Ok(DependencyEdge {
6624 from,
6625 to,
6626 kind: DependencyKind::Blocks,
6627 })
6628 })
6629 .collect::<Result<Vec<_>, SourceError>>()?;
6630 let mut next = next_cursor(connection)?;
6631 if let Some(next) = &next {
6632 validate_cursor_progress(cursor, &next.0)?;
6633 }
6634 if next.is_none()
6635 && !self
6636 .recorded_edges(id, near_kind, direction, natively_names, node)
6637 .await?
6638 .is_empty()
6639 {
6640 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6641 }
6642 Ok(Page { items, next })
6643 }
6644
6645 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6646 /// a far end in another source has to live: no GitHub issue relationship can name one.
6647 ///
6648 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6649 /// source never writes one down.
6650 ///
6651 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6652 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6653 /// request beyond the read already made, and reading the board for them would be a
6654 /// walk of every item for one field of one. A draft has no body in that answer, because
6655 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6656 /// listing of the board, which can be behind on the very item asked about.
6657 async fn recorded_edges(
6658 &self,
6659 id: &NativeId,
6660 near_kind: ItemKind,
6661 direction: Direction,
6662 natively_names: Option<ItemKind>,
6663 node: &Value,
6664 ) -> Result<Vec<DependencyEdge>, SourceError> {
6665 if direction != Direction::DependsOn {
6666 return Ok(Vec::new());
6667 }
6668 let slot = match node.get("body") {
6669 Some(body) if natively_names.is_some() => {
6670 metadata_body(body.as_str().map(str::to_owned))?.1
6671 }
6672 _ => {
6673 let Some(item) = self.bound_item(id).await? else {
6674 return Ok(Vec::new());
6675 };
6676 item.slot
6677 }
6678 };
6679 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6680 .map_err(|message| SourceError::Malformed { message })
6681 }
6682
6683 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6684 self.repository
6685 .as_ref()
6686 .ok_or_else(|| SourceError::Refused {
6687 message: format!(
6688 "source {} has no repository configured, and a GitHub Projects board has no \
6689 repository of its own to create an issue in; set repository: owner/name on \
6690 this source",
6691 self.name
6692 ),
6693 })
6694 }
6695
6696 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6697 /// states.
6698 ///
6699 /// The fallback is demanded first, whichever arm answers: a write without a configured
6700 /// repository is refused naming the field exactly as it was before the rule existed,
6701 /// so a source that could not write before cannot write now, rather than writing for
6702 /// the one item whose own field happens to decide it.
6703 ///
6704 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6705 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6706 /// entry owned by someone other than the owner of the parent issue's repository —
6707 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6708 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6709 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6710 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6711 /// and is visible to the token is checked where its node id is resolved, still before
6712 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6713 /// looked up in a listing of the board, which can be minutes behind an issue its own
6714 /// `projectItems` already places on it — and that read answers first from this process's
6715 /// own record, so a project created moments ago in this command answers though GitHub
6716 /// has not caught up.
6717 async fn creation_target(
6718 &self,
6719 incoming: &Incoming<'_>,
6720 ) -> Result<RepositoryTarget, SourceError> {
6721 let fallback = self.configured_repository()?;
6722 let what = |incoming: &Incoming<'_>| {
6723 format!(
6724 "{} {:?}",
6725 incoming.written.kind().describes(),
6726 incoming.title
6727 )
6728 };
6729 let parent = match incoming.parent {
6730 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6731 SourceError::Refused {
6732 message: format!(
6733 "GitHub project issue {} was not found on the board of source {}, so {} \
6734 cannot be filed under it",
6735 parent.0,
6736 self.name,
6737 what(incoming)
6738 ),
6739 }
6740 })?),
6741 None => None,
6742 };
6743 let parents_repository = parent
6744 .as_ref()
6745 .map(|parent| {
6746 // A draft is on the board and so is found, but it has no repository to
6747 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6748 // would refuse the task only once `createIssue` had made it.
6749 if parent.content_kind == ContentKind::DraftIssue {
6750 return Err(SourceError::Refused {
6751 message: format!(
6752 "GitHub project item {} on the board of source {} is a draft, \
6753 which cannot have sub-issues, so {} cannot be filed under it",
6754 parent.id.0,
6755 self.name,
6756 what(incoming)
6757 ),
6758 });
6759 }
6760 // An issue's repository is where a sub-issue is placed and whose owner it
6761 // is compared against, so a parent whose repository this source cannot
6762 // spell as `owner/name` — GitHub's login grammar is wider than this
6763 // source's floor — is one nothing can be filed under.
6764 parent
6765 .own_repository
6766 .as_ref()
6767 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6768 .ok_or_else(|| SourceError::Malformed {
6769 message: format!(
6770 "GitHub project issue {} on the board of source {} is in {}, which \
6771 is not a {}/owner/name repository this source can place {} in",
6772 parent.id.0,
6773 self.name,
6774 parent
6775 .own_repository
6776 .as_ref()
6777 .map_or("no repository", Repository::as_str),
6778 RepositoryTarget::HOST,
6779 what(incoming)
6780 ),
6781 })
6782 })
6783 .transpose()?;
6784 match incoming.repositories {
6785 [named] => {
6786 let target =
6787 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6788 message: format!(
6789 "{} names repository {}, which is not a {}/owner/name repository \
6790 source {} can create an issue in; name one that is, or name none",
6791 what(incoming),
6792 named.as_str(),
6793 RepositoryTarget::HOST,
6794 self.name
6795 ),
6796 })?;
6797 if let Some(parents) = &parents_repository
6798 && parents.owner != target.owner
6799 {
6800 return Err(SourceError::Refused {
6801 message: format!(
6802 "{} names repository {}, owned by {}, but its project's issue is in \
6803 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6804 of the same owner as its parent issue; name a repository of {}, or \
6805 name none",
6806 what(incoming),
6807 target.slug(),
6808 target.owner,
6809 parents.slug(),
6810 parents.owner,
6811 parents.owner
6812 ),
6813 });
6814 }
6815 Ok(target)
6816 }
6817 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6818 }
6819 }
6820
6821 /// The node id of the repository `incoming` is being created in, or the refusal naming
6822 /// the item and the repository the token cannot see.
6823 ///
6824 /// Resolved once per command per repository; see [`Self::repository_cache`].
6825 async fn repository_id(
6826 &self,
6827 repository: &RepositoryTarget,
6828 incoming: &Incoming<'_>,
6829 ) -> Result<String, SourceError> {
6830 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6831 return Ok(id);
6832 }
6833 let data = self
6834 .graphql(
6835 graphql::REPOSITORY,
6836 json!({"owner":repository.owner,"name":repository.name}),
6837 )
6838 .await?;
6839 self.repository_read(&data, repository, incoming)
6840 }
6841
6842 /// The repository's node id out of an answer carrying the `repository` root, held for
6843 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6844 fn repository_read(
6845 &self,
6846 data: &Value,
6847 repository: &RepositoryTarget,
6848 incoming: &Incoming<'_>,
6849 ) -> Result<String, SourceError> {
6850 let node = data
6851 .get("repository")
6852 .filter(|value| !value.is_null())
6853 .ok_or_else(|| SourceError::Refused {
6854 message: format!(
6855 "GitHub repository {} was not found or is not visible to the token, so {} \
6856 {:?} cannot be created in it",
6857 repository.slug(),
6858 incoming.written.kind().describes(),
6859 incoming.title
6860 ),
6861 })?;
6862 let id = required_str(node, "id")?.to_owned();
6863 self.repository_cache()?
6864 .insert(repository.clone(), id.clone());
6865 Ok(id)
6866 }
6867
6868 fn repository_cache(
6869 &self,
6870 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6871 self.repository_cache
6872 .lock()
6873 .map_err(|_| SourceError::Unavailable {
6874 message: "this source's record of the destination repository was left \
6875 inconsistent by an earlier failure; next: run the command again"
6876 .into(),
6877 })
6878 }
6879
6880 /// Create or update one board item, whichever kind it is.
6881 async fn write_item(
6882 &self,
6883 incoming: &Incoming<'_>,
6884 target: Option<&NativeId>,
6885 depends_on: &[DependencyEdge],
6886 ) -> Result<NativeId, SourceError> {
6887 // Refused before anything is read or written: a task or a project titled the way
6888 // this board spells a document would land as an issue this same source reads back
6889 // as a document, so the field this destination cannot carry is named rather than
6890 // written and silently reclassified.
6891 if let Written::Work(kind, _) = incoming.written
6892 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6893 {
6894 return Err(SourceError::Refused {
6895 message: format!(
6896 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6897 spells a document, so it would read back as one rather than as a {}; \
6898 retitle it, or copy it as a document",
6899 kind.marker(),
6900 self.name,
6901 kind.marker()
6902 ),
6903 });
6904 }
6905 // The destination is read by its own id, and whether this board holds it is decided
6906 // by that read — its own `projectItems` — rather than by whether a listing of the
6907 // board happens to include it yet. See the module documentation.
6908 let existing = match target {
6909 Some(target) => {
6910 Some(
6911 self.bound_item(target)
6912 .await?
6913 .ok_or_else(|| SourceError::Refused {
6914 message: format!("GitHub destination item {} was not found", target.0),
6915 })?,
6916 )
6917 }
6918 None => None,
6919 };
6920 let existing = existing.as_ref();
6921 // An existing issue is never moved; a new one is created where the rule says — and
6922 // knowing where is what lets the board's fields and that repository's id be read
6923 // together, before anything below needs either.
6924 let creation_target = match existing {
6925 Some(_) => None,
6926 None => {
6927 let target = self.creation_target(incoming).await?;
6928 self.creation_context(&target, incoming).await?;
6929 Some(target)
6930 }
6931 };
6932 let board = self
6933 .fields_for(
6934 existing,
6935 incoming.written.status().is_some(),
6936 incoming
6937 .priority
6938 .is_some_and(|priority| priority != Priority::None),
6939 )
6940 .await?;
6941 let status_target = incoming
6942 .written
6943 .work_status()
6944 .map(|(kind, status)| self.resolved_target(kind, status.category))
6945 .transpose()?;
6946 let column = match (incoming.written.work_status(), status_target.as_ref()) {
6947 (Some((kind, status)), Some(target)) => {
6948 self.column_for(&board.fields, kind, status.category, target)?
6949 }
6950 _ => None,
6951 };
6952 // Resolved before anything is created, for the reason the column above is: a
6953 // priority this board has no option for is refused while nothing has been written.
6954 let priority_write = match incoming.priority {
6955 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6956 None => None,
6957 };
6958 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6959 if content_kind == ContentKind::DraftIssue {
6960 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6961 (status_target.as_ref(), incoming.written.status())
6962 {
6963 return Err(self.closes_a_draft(status.category));
6964 }
6965 if incoming.parent.is_some() {
6966 return Err(SourceError::Refused {
6967 message: "GitHub draft items cannot be a project's sub-issue".into(),
6968 });
6969 }
6970 }
6971 match existing {
6972 Some(item) if content_kind == ContentKind::Issue => {
6973 if item.labels != incoming.labels {
6974 return Err(SourceError::Refused {
6975 message: "GitHub issue labels differ from the labels being written".into(),
6976 });
6977 }
6978 }
6979 _ => {
6980 if !incoming.labels.is_empty() {
6981 return Err(SourceError::Refused {
6982 message: "GitHub items created by this destination carry no labels".into(),
6983 });
6984 }
6985 }
6986 }
6987
6988 // The repository the issue really lives in is what the slot below is written against,
6989 // so a single entry that is where the issue is created travels as no key at all, and
6990 // the read side derives it back from the issue.
6991 let own_repository = match (existing, &creation_target) {
6992 (Some(item), _) => item.own_repository.clone(),
6993 (None, Some(target)) => Some(
6994 Repository::try_from(target.origin())
6995 .map_err(|message| SourceError::Config { message })?,
6996 ),
6997 (None, None) => None,
6998 };
6999 let (native, fallback) = self
7000 .partition_edges(
7001 incoming.written.kind(),
7002 content_kind,
7003 existing.and_then(|item| item.blocked_by.as_deref()),
7004 depends_on,
7005 )
7006 .await?;
7007 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
7008 let body = compose_body(incoming.content, &slot)?;
7009 // Read before anything is created, for the reason the field below is: a value
7010 // this destination cannot store has to refuse, and refusing after `createIssue`
7011 // would leave an issue behind that nothing asked for. The engine writes a
7012 // qualified id here; a caller handing this key anything else is told so rather
7013 // than having it silently stored as no origin at all.
7014 // 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.
7015 let origin = match incoming.metadata.get(ORIGIN_KEY) {
7016 None => "",
7017 Some(Value::String(origin)) => origin.as_str(),
7018 Some(other) => {
7019 return Err(SourceError::Refused {
7020 message: format!(
7021 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
7022 is {other}"
7023 ),
7024 });
7025 }
7026 };
7027 // Resolved before anything is created: a board that cannot carry the copy origin
7028 // has to refuse the write, and refusing it after `createIssue` would leave an
7029 // issue behind that nothing asked for.
7030 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
7031 Some(field) => {
7032 if required_str(field, "__typename")? != "ProjectV2Field" {
7033 return Err(SourceError::Refused {
7034 message: format!(
7035 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
7036 ),
7037 });
7038 }
7039 Some(required_str(field, "id")?.to_owned())
7040 }
7041 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
7042 return Err(SourceError::Refused {
7043 message: format!(
7044 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
7045 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
7046 the board"
7047 ),
7048 });
7049 }
7050 None => None,
7051 };
7052
7053 let Landed {
7054 content_id,
7055 item_id,
7056 url,
7057 number,
7058 } = match existing {
7059 // Its content is written last, below, once everything else has landed.
7060 Some(item) => Landed {
7061 content_id: item.id.clone(),
7062 item_id: item.item_id.clone(),
7063 url: item.url.clone(),
7064 number: item.number,
7065 },
7066 None => {
7067 let target = creation_target
7068 .as_ref()
7069 .ok_or_else(|| SourceError::Malformed {
7070 message: "a new item was decided without a repository to create it in"
7071 .into(),
7072 })?;
7073 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7074 .await?
7075 }
7076 };
7077
7078 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7079 let column = column
7080 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7081 .map(|(field, option, _)| (field, option));
7082 // Creating an item here is several calls — `createIssue`, which files it on the
7083 // board, then its board fields, the parent and the dependencies — and GitHub can fail
7084 // at any of them. Everything this source can refuse *before* the first of those is
7085 // already checked above, so what is left is GitHub itself failing part way. When it
7086 // does over an item this call created, the issue is taken back: a write that
7087 // refused must not leave an item behind that nobody asked for, and one that does
7088 // makes the retry create a second.
7089 // Whether the board-field write carrying a moved origin was answered as landing whole.
7090 // When it was refused, GitHub does not say which of its fields ran before the one that
7091 // failed, so the origin may or may not have moved.
7092 let mut origin_landed = false;
7093 let landed = self
7094 .finish_write(
7095 board.id.as_str(),
7096 incoming,
7097 &content_id,
7098 &item_id,
7099 content_kind,
7100 existing,
7101 origin_field.as_deref(),
7102 origin,
7103 column,
7104 status_target.as_ref(),
7105 priority_write.as_ref(),
7106 &native,
7107 &mut origin_landed,
7108 )
7109 .await;
7110 // An existing item's title, body and state go last, in one `updateIssue`, once its board
7111 // fields and its relationships have landed: a refusal of any of those then leaves its
7112 // body — and the metadata slot inside it — exactly as it stood.
7113 let landed = match (landed, existing) {
7114 (Ok(()), Some(item)) => {
7115 self.update_existing(item, incoming, &body, status_target.as_ref())
7116 .await
7117 }
7118 (landed, _) => landed,
7119 };
7120 if let Err(error) = landed {
7121 match existing {
7122 // Best effort, and the write's own failure is what the caller is told: a
7123 // refusal naming the tidy-up would hide why the write failed at all.
7124 None => {
7125 let _ = self.delete_issue(&content_id).await;
7126 }
7127 // The origin field is the one piece of an existing item's metadata written
7128 // before its body, so a write refused after it puts it back as it was. When
7129 // that is refused too, the write's own failure is still what the caller is
7130 // told — with what it left behind added, because the item's metadata is then
7131 // not as it stood and a caller retrying has to know which key moved.
7132 Some(item) => {
7133 let before = item.origin.as_deref().unwrap_or("");
7134 if let Some(field) = origin_field.as_deref()
7135 && before != origin
7136 && let Err(restore) = self
7137 .set_item_field(
7138 board.id.as_str(),
7139 &item.item_id,
7140 field,
7141 json!({"text": before}),
7142 )
7143 .await
7144 {
7145 let left = if origin_landed {
7146 format!(
7147 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7148 not be put back to {before:?} ({restore}), so item {} still \
7149 holds {origin:?} there",
7150 item.id.0
7151 )
7152 } else {
7153 format!(
7154 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7155 {origin:?}, GitHub does not say whether that part of it ran, \
7156 and putting it back to {before:?} was refused ({restore}), so \
7157 item {} holds {origin:?} or {before:?} there",
7158 item.id.0
7159 )
7160 };
7161 return Err(noting(
7162 error,
7163 &format!(
7164 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7165 run the write again"
7166 ),
7167 ));
7168 }
7169 }
7170 }
7171 return Err(error);
7172 }
7173
7174 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7175 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7176 self.statuses
7177 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7178 }
7179 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7180 self.statuses
7181 .status(kind, written_option.as_deref(), false, None)
7182 }
7183 (Some((_, status)), _) => status.clone(),
7184 (None, _) => Status {
7185 category: StatusCategory::Unknown,
7186 name: "Open".to_owned(),
7187 },
7188 };
7189
7190 // So the rest of this command reads what it just did rather than what the board
7191 // said before it. See `remember_written` for which half takes it.
7192 let remembered = Resolved {
7193 item_id,
7194 id: content_id.clone(),
7195 content_kind,
7196 kind: incoming.written.kind(),
7197 title: incoming.title.to_owned(),
7198 // The visible half of the body this write composed, split back off it the
7199 // way a read splits it — so what this record reports is what a read of the
7200 // same issue reports, rather than the person's text with the metadata slot
7201 // still on the end of it.
7202 body: metadata_body(body.clone())?.0,
7203 raw_body: body.clone(),
7204 // A document has no status of its own; what it reads back as is whatever
7205 // the issue's own state says, which is what a re-read reports.
7206 status: written_status,
7207 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7208 priority: match incoming.priority {
7209 Some(priority) => HeldPriority::Read(priority),
7210 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7211 item.priority.clone()
7212 }),
7213 },
7214 // What `state_input` asked for: closed for a terminal target, open for any other
7215 // status, and the issue's own state left as it was by a document write.
7216 closed: content_kind == ContentKind::Issue
7217 && match status_target.as_ref() {
7218 Some(StatusTarget::Terminal(_, _)) => true,
7219 Some(_) => false,
7220 None => existing.is_some_and(|item| item.closed),
7221 },
7222 delivers: incoming.delivers.to_vec(),
7223 delivered_by: incoming.delivered_by.to_vec(),
7224 labels: incoming.labels.to_vec(),
7225 parent: incoming.parent.cloned(),
7226 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7227 number,
7228 // In the update path this is the item's own url, read off `existing` where the
7229 // record above was bound, so one expression serves both halves.
7230 url,
7231 created_at: existing.and_then(|item| item.created_at),
7232 updated_at: existing.and_then(|item| item.updated_at),
7233 own_repository,
7234 repositories: incoming.repositories.to_vec(),
7235 slot,
7236 board_id: Some(board.id.as_str().to_owned()),
7237 fields: board
7238 .fields
7239 .get("nodes")
7240 .and_then(Value::as_array)
7241 .cloned()
7242 .unwrap_or_default(),
7243 board_fields: Some(board.fields.clone()),
7244 // What this write left the relationship holding is known by id alone, and a
7245 // later read of its edges needs each far end's kind, so it reads them again.
7246 blocked_by: None,
7247 };
7248 self.remember_written(remembered, existing.is_none())?;
7249 Ok(content_id)
7250 }
7251
7252 /// Everything a write does after the item exists: its board fields, its parent, and
7253 /// its dependencies.
7254 ///
7255 /// Split out of `write_item` so there is one place a failure past the point of no
7256 /// return is caught, rather than a tidy-up repeated at each `?` above.
7257 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7258 // so there is one place a failure past the point of no return is caught, and its
7259 // arguments are exactly the values that tail already had in scope. Bundling them into a
7260 // struct would describe no concept — it would be "the arguments of this function" — and
7261 // would put the whole of `write_item`'s locals behind one more indirection.
7262 #[allow(clippy::too_many_arguments)]
7263 async fn finish_write(
7264 &self,
7265 board_id: &str,
7266 incoming: &Incoming<'_>,
7267 content_id: &NativeId,
7268 item_id: &str,
7269 content_kind: ContentKind,
7270 existing: Option<&Resolved>,
7271 origin_field: Option<&str>,
7272 origin: &str,
7273 column: Option<(String, String)>,
7274 status_target: Option<&StatusTarget>,
7275 priority: Option<&PriorityWrite>,
7276 native: &[String],
7277 origin_landed: &mut bool,
7278 ) -> Result<(), SourceError> {
7279 let mut fields = Vec::new();
7280 if let Some(field_id) = origin_field
7281 && existing.map_or(!origin.is_empty(), |item| {
7282 item.origin.as_deref().unwrap_or("") != origin
7283 })
7284 {
7285 fields.push((field_id.to_owned(), json!({"text":origin})));
7286 }
7287 if let Some((field_id, option_id)) = column {
7288 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7289 }
7290 let clear = match priority {
7291 Some(PriorityWrite::Select { field, option }) => {
7292 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7293 None
7294 }
7295 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7296 None => None,
7297 };
7298 self.set_item_fields(board_id, item_id, &fields, clear)
7299 .await?;
7300 *origin_landed = true;
7301
7302 // An existing issue closes in the `updateIssue` its write ends with; one created just
7303 // now closes here, once its option is selected.
7304 if existing.is_none()
7305 && content_kind == ContentKind::Issue
7306 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7307 {
7308 self.update_content(
7309 ContentKind::Issue,
7310 content_id,
7311 json!({"stateInput":state_input(status_target)}),
7312 )
7313 .await?;
7314 }
7315
7316 if content_kind == ContentKind::Issue {
7317 self.reparent(
7318 existing.and_then(|item| item.parent.clone()),
7319 content_id,
7320 incoming.parent,
7321 )
7322 .await?;
7323 // A document takes part in no dependency graph, so writing one neither reads
7324 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7325 // against the empty list a document write carries would *delete* whatever
7326 // relationships a person had made on that issue, which is a write nobody
7327 // asked for.
7328 if incoming.written.kind() != BoardKind::Document {
7329 let issue = match existing {
7330 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7331 None => Issue::Created,
7332 };
7333 self.reconcile_blocked_by(content_id, native, issue).await?;
7334 }
7335 }
7336 Ok(())
7337 }
7338
7339 /// Delete one issue, which takes its board item with it.
7340 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7341 let data = self
7342 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7343 .await?;
7344 data.pointer("/deleteIssue/repository")
7345 .filter(|value| !value.is_null())
7346 .ok_or_else(|| SourceError::Malformed {
7347 message: "GitHub issue deletion returned no repository".into(),
7348 })?;
7349 self.forget(id)?;
7350 Ok(())
7351 }
7352
7353 /// Remove one item this copy created, so a copy that could not finish leaves the board
7354 /// as it found it.
7355 ///
7356 /// Deleting the issue takes its board item with it, so there is no second mutation to
7357 /// keep in step. An id the board does not hold is not an error: the item is already
7358 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7359 /// by its own id — a listing of the board can still be missing an item it holds, and
7360 /// reading that as *already gone* would leave behind the very item this was asked to
7361 /// take back.
7362 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7363 let Some(item) = self.bound_item(id).await? else {
7364 return Ok(());
7365 };
7366 if item.content_kind == ContentKind::DraftIssue {
7367 return Err(SourceError::Refused {
7368 message: format!(
7369 "GitHub item {} is a draft, and this source removes an item by deleting \
7370 its issue; next: remove it from the board by hand",
7371 id.0
7372 ),
7373 });
7374 }
7375 let data = self
7376 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7377 .await?;
7378 data.pointer("/deleteIssue/repository")
7379 .filter(|value| !value.is_null())
7380 .ok_or_else(|| SourceError::Malformed {
7381 message: "GitHub issue deletion returned no repository".into(),
7382 })?;
7383 self.forget(id)?;
7384 Ok(())
7385 }
7386
7387 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7388 /// task.
7389 ///
7390 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7391 /// read of the task cannot disagree about which ids name one: a project or a document of
7392 /// this board is not a task here either.
7393 ///
7394 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7395 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7396 /// which would read as a task nobody has commented on yet.
7397 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7398 let cached = self.resolved_cache()?.get(task).cloned();
7399 let Some(item) = (match cached {
7400 Some(item) => Some(item),
7401 None => self.item_by_id(task).await?,
7402 })
7403 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7404 return Ok(None);
7405 };
7406 if item.content_kind == ContentKind::DraftIssue {
7407 return Err(self.draft_has_no_comments(task));
7408 }
7409 Ok(Some(item.id))
7410 }
7411
7412 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7413 /// issues, and a draft is not one.
7414 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7415 SourceError::Refused {
7416 message: format!(
7417 "task {} of source {} is a draft item on the board, and GitHub keeps \
7418 comments on issues alone, so a draft has none to read or write; next: \
7419 convert the draft to an issue on the board, then comment on the issue it \
7420 becomes",
7421 task.0, self.name
7422 ),
7423 }
7424 }
7425
7426 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7427 /// request — or `None` when this board holds no task by that id.
7428 ///
7429 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7430 /// is answered with the draft and the refusal, at the price of the draft's own read.
7431 async fn issue_detail(
7432 &self,
7433 id: &NativeId,
7434 page: &PageRequest,
7435 ) -> Result<Option<TaskDetailRead>, SourceError> {
7436 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7437 let asked = self
7438 .graphql(
7439 graphql::ISSUE_DETAIL,
7440 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7441 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7442 "duplicates":true}),
7443 )
7444 .await;
7445 let data = match asked {
7446 Ok(data) => data,
7447 Err(error) if unresolvable_node(&error) => return Ok(None),
7448 Err(error) => return Err(error),
7449 };
7450 // `node` is null for an id that names nothing, and absent only from an answer this
7451 // source cannot read — never the same thing.
7452 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7453 message: format!("GitHub answered the read of {} with no node", id.0),
7454 })?;
7455 self.detail_of(id, node, true, after).await
7456 }
7457
7458 /// Several tasks, each with the first page of its comments when `comments` is set, read
7459 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7460 /// order.
7461 ///
7462 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7463 /// one item at a time, so that id is answered as missing and the others as themselves; any
7464 /// other refusal is every id of that batch's answer.
7465 async fn issue_details(
7466 &self,
7467 ids: &[NativeId],
7468 comments: Option<&PageRequest>,
7469 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7470 let mut read = Vec::with_capacity(ids.len());
7471 for batch in ids.chunks(DETAIL_BATCH) {
7472 match self
7473 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7474 .await
7475 {
7476 Ok(data) => {
7477 for (slot, id) in batch.iter().enumerate() {
7478 // Every alias asked for is answered, null for an id naming nothing;
7479 // one missing is an answer this source cannot read.
7480 let read_one = match data.get(format!("i{slot}")) {
7481 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7482 None => Err(SourceError::Malformed {
7483 message: format!(
7484 "GitHub answered a batch read with no item for {}",
7485 id.0
7486 ),
7487 }),
7488 };
7489 read.push(read_one);
7490 }
7491 }
7492 Err(error) if unresolvable_node(&error) => {
7493 for id in batch {
7494 read.push(match comments {
7495 Some(page) => self.issue_detail(id, page).await,
7496 None => self.task_read(id).await,
7497 });
7498 }
7499 }
7500 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7501 }
7502 }
7503 read
7504 }
7505
7506 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7507 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7508 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7509 task,
7510 comments: None,
7511 }))
7512 }
7513
7514 /// What one node a detail read reached says: the task this board holds by `id`, with the
7515 /// page of comments the node carries when `commented` — or `None` for a node that is no
7516 /// task of this board.
7517 ///
7518 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7519 /// and an item this process created answers from this process's own record, which a node
7520 /// read taken moments after the write can still be behind.
7521 async fn detail_of(
7522 &self,
7523 id: &NativeId,
7524 node: &Value,
7525 commented: bool,
7526 after: Option<&str>,
7527 ) -> Result<Option<TaskDetailRead>, SourceError> {
7528 if node.is_null() {
7529 return Ok(None);
7530 }
7531 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7532 // An issue answered under one id is that id's, or the answer is not one this source
7533 // can report: reporting another issue's task and comments under the qualified id asked
7534 // for would be the one wrong answer here. A draft's own read checks the same.
7535 if !draft
7536 && optional_str(node, "__typename")? == Some("Issue")
7537 && required_str(node, "id")? != id.0
7538 {
7539 return Err(SourceError::Malformed {
7540 message: format!(
7541 "GitHub answered the read of {} with issue {}",
7542 id.0,
7543 required_str(node, "id")?
7544 ),
7545 });
7546 }
7547 let item = if draft {
7548 self.draft_by_id(id).await?
7549 } else {
7550 self.resolve_issue(node).await?
7551 };
7552 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7553 return Ok(None);
7554 };
7555 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7556 let task = own.unwrap_or(item).task()?;
7557 let comments = match (commented, draft) {
7558 (false, _) => None,
7559 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7560 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7561 };
7562 Ok(Some(TaskDetailRead { task, comments }))
7563 }
7564
7565 /// Whether the comment `comment` is one of `issue`'s own.
7566 ///
7567 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7568 /// comment's id and nothing else: a comment id given against the wrong task would
7569 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7570 /// names something that is not an issue comment, is a comment this task does not have —
7571 /// which is what GitHub refusing to resolve it means too.
7572 async fn comment_is_on(
7573 &self,
7574 issue: &NativeId,
7575 comment: &NativeId,
7576 ) -> Result<bool, SourceError> {
7577 let asked = self
7578 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7579 .await;
7580 let data = match asked {
7581 Ok(data) => data,
7582 Err(error) if unresolvable_node(&error) => return Ok(false),
7583 Err(error) => return Err(error),
7584 };
7585 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7586 return Ok(false);
7587 };
7588 if optional_str(node, "__typename")? != Some("IssueComment") {
7589 return Ok(false);
7590 }
7591 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7592 message: format!("GitHub issue comment {} names no issue", comment.0),
7593 })?;
7594 Ok(required_str(on, "id")? == issue.0)
7595 }
7596
7597 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7598 async fn partition_edges(
7599 &self,
7600 near_kind: BoardKind,
7601 near_content: ContentKind,
7602 carried: Option<&[Value]>,
7603 depends_on: &[DependencyEdge],
7604 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7605 let mut native = Vec::new();
7606 let mut fallback = Vec::new();
7607 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7608 .iter()
7609 .map(|edge| {
7610 let same_source = edge
7611 .to
7612 .source()
7613 .is_none_or(|source| source == self.name.as_str());
7614 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7615 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7616 // colons of its own, so the far end is everything after that one separator.
7617 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7618 let far_id = if edge.to.is_qualified() {
7619 edge.to
7620 .id()
7621 .split_once(':')
7622 .map_or(edge.to.id(), |(_, native)| native)
7623 } else {
7624 edge.to.id()
7625 };
7626 // One that already blocks the near issue was answered by that issue's own
7627 // read, which carried each of its blockers' kinds — an issue every one — so it
7628 // is not read again.
7629 let blocking = carried.and_then(|nodes| {
7630 nodes
7631 .iter()
7632 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7633 });
7634 (edge, far_id, same_source, blocking)
7635 })
7636 .collect();
7637 // Every other same-source far end is read by its own id, exactly as the item it is a
7638 // far end of is: whether this board holds it is that read's answer, never a listing's.
7639 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7640 let mut unread: Vec<NativeId> = Vec::new();
7641 for (_, far_id, same_source, blocking) in &far_ends {
7642 let id = NativeId((*far_id).to_owned());
7643 if *same_source && blocking.is_none() && !unread.contains(&id) {
7644 unread.push(id);
7645 }
7646 }
7647 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7648 .iter()
7649 .cloned()
7650 .zip(self.items_by_ids(&unread).await?)
7651 .collect();
7652 for (edge, far_id, same_source, blocking) in far_ends {
7653 let far = match (same_source, blocking) {
7654 (false, _) => None,
7655 (true, Some(node)) => Some(FarEnd {
7656 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7657 BoardKind::Document
7658 } else {
7659 BoardKind::Work(related_kind(node)?)
7660 },
7661 content_kind: ContentKind::Issue,
7662 }),
7663 (true, None) => {
7664 let read = read
7665 .get(&NativeId(far_id.to_owned()))
7666 .cloned()
7667 .flatten()
7668 .ok_or_else(|| SourceError::Refused {
7669 message: format!("GitHub dependency item {far_id} was not found"),
7670 })?;
7671 Some(FarEnd {
7672 kind: read.kind,
7673 content_kind: read.content_kind,
7674 })
7675 }
7676 };
7677 let far = far.as_ref();
7678 // The caller says which kind the far end is, and this board holds the far end
7679 // itself, so a disagreement is settled here rather than stored: recorded, the
7680 // wrong kind would read back as a cross-level edge that never existed; written
7681 // natively, it would name a relationship of a different level than the caller
7682 // asked for.
7683 //
7684 // A far end this board holds as a *document* fails the same comparison and is
7685 // refused by the same sentence: `ItemKind` has no document variant because
7686 // nothing may point at one, so no caller can name it correctly and the refusal
7687 // is the only honest answer.
7688 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7689 return Err(SourceError::Refused {
7690 message: format!(
7691 "GitHub dependency item {far_id} is a {} of this board, and this item \
7692 names it as a {}; record the kind it is",
7693 disagreeing.kind.describes(),
7694 edge.to.kind.marker()
7695 ),
7696 });
7697 }
7698 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7699 // however the far end is spelled — and one classified native here would be
7700 // written nowhere at all, because a draft's native reconciliation never runs.
7701 let native_here = near_content == ContentKind::Issue
7702 && far.is_some_and(|far| {
7703 far.content_kind == ContentKind::Issue
7704 && BoardKind::Work(edge.to.kind) == near_kind
7705 });
7706 if native_here {
7707 native.push(far_id.to_owned());
7708 } else {
7709 fallback.push(edge.clone());
7710 }
7711 }
7712 Ok((native, fallback))
7713 }
7714
7715 async fn update_existing(
7716 &self,
7717 item: &Resolved,
7718 incoming: &Incoming<'_>,
7719 body: &Option<String>,
7720 status_target: Option<&StatusTarget>,
7721 ) -> Result<(), SourceError> {
7722 let title = incoming.written_title();
7723 // A terminal status closes the issue here, in the same mutation as its body: its board
7724 // option was selected before this, so a close never lands on an item whose board cannot
7725 // show it.
7726 let fields = match item.content_kind {
7727 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7728 ContentKind::Issue => json!({"title":title,"body":body,
7729 "stateInput":state_input(status_target)}),
7730 };
7731 self.update_content(item.content_kind, &item.id, fields)
7732 .await
7733 }
7734
7735 /// Update one board item's content with exactly `fields` beside its id, through the
7736 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7737 /// a draft.
7738 ///
7739 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7740 /// is what lets a narrow write carry the one thing it changes and nothing else.
7741 async fn update_content(
7742 &self,
7743 kind: ContentKind,
7744 id: &NativeId,
7745 fields: Value,
7746 ) -> Result<(), SourceError> {
7747 let (operation, id_key, pointer) = match kind {
7748 ContentKind::DraftIssue => (
7749 graphql::UPDATE_DRAFT,
7750 "draftIssueId",
7751 "/updateProjectV2DraftIssue/draftIssue",
7752 ),
7753 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7754 };
7755 let mut input = fields;
7756 input[id_key] = json!(id.0);
7757 let data = self.graphql(operation, json!({"input":input})).await?;
7758 let returned = data
7759 .pointer(pointer)
7760 .ok_or_else(|| SourceError::Malformed {
7761 message: "GitHub item update returned no item".into(),
7762 })?;
7763 if required_str(returned, "id")? != id.0 {
7764 return Err(SourceError::Malformed {
7765 message: "GitHub item update returned the wrong item".into(),
7766 });
7767 }
7768 Ok(())
7769 }
7770
7771 /// Creates one issue, files it on the board, and reports what a read of it would say:
7772 /// its content id, its board item id, and the web address GitHub gave it.
7773 ///
7774 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7775 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7776 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7777 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7778 /// "Content already exists in this project". A terminal status is not written here:
7779 /// `finish_write` selects its option first and closes the issue after, so a close never
7780 /// lands on an item whose board cannot show it.
7781 ///
7782 /// The address and the number come back here because this is the only place either is
7783 /// known before GitHub's own board read catches up — an item this run created answers
7784 /// the reads that follow it out of the record below, and one remembered without them
7785 /// would report no location and no key for the rest of the run.
7786 async fn create_and_file_issue(
7787 &self,
7788 board_id: &str,
7789 repository: &RepositoryTarget,
7790 incoming: &Incoming<'_>,
7791 body: &Option<String>,
7792 ) -> Result<Landed, SourceError> {
7793 let repository_id = self.repository_id(repository, incoming).await?;
7794 let data = self
7795 .graphql(
7796 graphql::CREATE_ISSUE,
7797 json!({"input":{
7798 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7799 }}),
7800 )
7801 .await?;
7802 let created = data
7803 .pointer("/createIssue/issue")
7804 .filter(|value| !value.is_null())
7805 .ok_or_else(|| SourceError::Malformed {
7806 message: "GitHub issue creation returned no issue".into(),
7807 })?;
7808 let content_id = NativeId(required_str(created, "id")?.to_owned());
7809 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7810 // a response without it is not worth failing a landed write over — the item simply
7811 // reports no location until the board read catches up, which is what it did before.
7812 let url = optional_str(created, "url")?.map(str::to_owned);
7813 // The issue exists from here on, so an unreadable number and a refused board
7814 // filing below each try, best effort, to take it back: an issue in the repository
7815 // that is on no board is an item nobody asked for and nothing here would find again.
7816 //
7817 // Its number is optional on the same terms its address is — a landed write is not
7818 // worth failing over a member that came back missing, and such an item reports no
7819 // handle until a board read catches up. A number that is *present* and is not an
7820 // unsigned integer is still a response this source cannot read.
7821 let number = match created_issue_number(created) {
7822 Ok(number) => number,
7823 Err(error) => {
7824 let _ = self.delete_issue(&content_id).await;
7825 return Err(error);
7826 }
7827 };
7828 let added = match self
7829 .graphql(
7830 graphql::ADD_TO_BOARD,
7831 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7832 )
7833 .await
7834 {
7835 Ok(added) => added,
7836 Err(error) => {
7837 let _ = self.delete_issue(&content_id).await;
7838 return Err(error);
7839 }
7840 };
7841 let item = added
7842 .pointer("/addProjectV2ItemById/item")
7843 .filter(|value| !value.is_null())
7844 .ok_or_else(|| SourceError::Malformed {
7845 message: "GitHub board addition returned no project item".into(),
7846 })?;
7847 Ok(Landed {
7848 content_id,
7849 item_id: required_str(item, "id")?.to_owned(),
7850 url,
7851 number,
7852 })
7853 }
7854
7855 /// Move one issue under the project it now belongs to, or out of the one it left.
7856 async fn reparent(
7857 &self,
7858 held: Option<NativeId>,
7859 child: &NativeId,
7860 wanted: Option<&NativeId>,
7861 ) -> Result<(), SourceError> {
7862 if held.as_ref() == wanted {
7863 return Ok(());
7864 }
7865 if let Some(held) = &held {
7866 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7867 .await?;
7868 }
7869 if let Some(wanted) = wanted {
7870 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7871 .await?;
7872 }
7873 Ok(())
7874 }
7875
7876 async fn sub_issue(
7877 &self,
7878 operation: &str,
7879 parent: &NativeId,
7880 child: &NativeId,
7881 root: &str,
7882 ) -> Result<(), SourceError> {
7883 let data = self
7884 .graphql(
7885 operation,
7886 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7887 )
7888 .await?;
7889 let issue =
7890 data.pointer(&format!("/{root}/issue"))
7891 .ok_or_else(|| SourceError::Malformed {
7892 message: "GitHub sub-issue update returned no issue".into(),
7893 })?;
7894 let sub =
7895 data.pointer(&format!("/{root}/subIssue"))
7896 .ok_or_else(|| SourceError::Malformed {
7897 message: "GitHub sub-issue update returned no sub-issue".into(),
7898 })?;
7899 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7900 return Err(SourceError::Malformed {
7901 message: "GitHub sub-issue update returned the wrong issues".into(),
7902 });
7903 }
7904 Ok(())
7905 }
7906
7907 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7908 /// whether there was one.
7909 ///
7910 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7911 /// relationships are not read: there is nothing a read of them could find.
7912 async fn reconcile_blocked_by(
7913 &self,
7914 content_id: &NativeId,
7915 native: &[String],
7916 issue: Issue<'_>,
7917 ) -> Result<bool, SourceError> {
7918 let current = match issue {
7919 Issue::Created => Vec::new(),
7920 Issue::Existing(Some(held)) => held
7921 .iter()
7922 .map(|far| required_str(far, "id").map(str::to_owned))
7923 .collect::<Result<Vec<_>, _>>()?,
7924 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7925 };
7926 let mut changed = false;
7927 for (operation, far_id) in current
7928 .iter()
7929 .filter(|id| !native.contains(id))
7930 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7931 .chain(
7932 native
7933 .iter()
7934 .filter(|id| !current.contains(id))
7935 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7936 )
7937 {
7938 let data = self
7939 .graphql(
7940 operation,
7941 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7942 )
7943 .await?;
7944 let root = if operation == graphql::ADD_BLOCKED_BY {
7945 "addBlockedBy"
7946 } else {
7947 "removeBlockedBy"
7948 };
7949 let issue =
7950 data.pointer(&format!("/{root}/issue"))
7951 .ok_or_else(|| SourceError::Malformed {
7952 message: "GitHub dependency update returned no issue".into(),
7953 })?;
7954 let blocker = data
7955 .pointer(&format!("/{root}/blockingIssue"))
7956 .ok_or_else(|| SourceError::Malformed {
7957 message: "GitHub dependency update returned no blocking issue".into(),
7958 })?;
7959 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7960 {
7961 return Err(SourceError::Malformed {
7962 message: "GitHub dependency update returned the wrong issues".into(),
7963 });
7964 }
7965 changed = true;
7966 }
7967 Ok(changed)
7968 }
7969}
7970
7971/// What a write needs to know of one far end it names: which kind of item it is, and whether
7972/// it is an issue a native relationship can name.
7973struct FarEnd {
7974 kind: BoardKind,
7975 content_kind: ContentKind,
7976}
7977
7978/// Whether the issue one write reconciles was created by that write or was already there.
7979#[derive(Clone, Copy, PartialEq, Eq)]
7980enum Issue<'a> {
7981 /// Created by this write, so it holds no relationships yet.
7982 Created,
7983 /// On the board before this write, holding whatever relationships it holds — the far
7984 /// ends of its whole `blockedBy`, when the read that reached it carried them.
7985 Existing(Option<&'a [Value]>),
7986}
7987
7988/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7989enum Reached {
7990 /// An issue this board holds, resolved into everything this source reports about it.
7991 Held(Box<Resolved>),
7992 /// Nothing this board holds: no such node, or a node on some other board.
7993 Nothing,
7994 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7995 /// again by [`GitHubProjectsSource::draft_by_id`].
7996 Draft,
7997}
7998
7999/// What GitHub says when a string is not a node id it can resolve.
8000///
8001/// Matched because it is the ordinary answer to a project selector naming a project by its
8002/// *name*, and reporting that as a failure would make naming one impossible. It is read
8003/// off the refusal GitHub sent, never guessed from the shape of the string: this source
8004/// does not define the syntax of a GitHub node id and would be wrong about it.
8005const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
8006
8007/// `error` with `note` added to the end of what it says, its kind and every other member
8008/// unchanged — so a caller still branches on the failure that happened, and reads beside it
8009/// what that failure left behind.
8010fn noting(error: SourceError, note: &str) -> SourceError {
8011 match error {
8012 SourceError::Config { message } => SourceError::Config {
8013 message: message + note,
8014 },
8015 SourceError::Auth { message } => SourceError::Auth {
8016 message: message + note,
8017 },
8018 SourceError::Refused { message } => SourceError::Refused {
8019 message: message + note,
8020 },
8021 SourceError::RateLimited {
8022 retry_after_seconds,
8023 message,
8024 } => SourceError::RateLimited {
8025 retry_after_seconds,
8026 message: Some(message.unwrap_or_default() + note),
8027 },
8028 SourceError::Unavailable { message } => SourceError::Unavailable {
8029 message: message + note,
8030 },
8031 SourceError::Malformed { message } => SourceError::Malformed {
8032 message: message + note,
8033 },
8034 }
8035}
8036
8037/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
8038/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
8039/// for them.
8040///
8041/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
8042/// is read again at no added price.
8043fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
8044 let mut variables = serde_json::Map::new();
8045 for slot in 0..DETAIL_BATCH {
8046 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
8047 variables.insert(format!("id{slot}"), json!(id));
8048 }
8049 variables.insert(
8050 "first".to_owned(),
8051 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
8052 );
8053 variables.insert("comments".to_owned(), json!(comments.is_some()));
8054 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
8055 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
8056 variables.insert("duplicates".to_owned(), json!(true));
8057 Value::Object(variables)
8058}
8059
8060/// Whether this refusal is GitHub saying the id names no node at all.
8061fn unresolvable_node(error: &SourceError) -> bool {
8062 matches!(error, SourceError::Refused { message }
8063 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
8064}
8065
8066/// One project name, as a search qualifier which filters on it at the server.
8067///
8068/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8069/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8070/// the way it documents. A title matched here is still compared for equality afterwards:
8071/// the qualifier narrows what the server sends, and this source decides what it names.
8072fn title_qualifier(name: &str) -> String {
8073 format!("in:title {}", quoted(name))
8074}
8075
8076/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8077/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8078/// it documents — so a value holding a qualifier's spelling is searched for rather than
8079/// obeyed.
8080fn quoted(value: &str) -> String {
8081 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8082 format!("\"{escaped}\"")
8083}
8084
8085/// The search qualifier for the issues updated at or after `since`.
8086///
8087/// Written to the second, rounded down, which can only widen what the search returns.
8088fn updated_qualifier(since: DateTime<Utc>) -> String {
8089 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8090}
8091
8092/// The search terms that narrow a board-scoped issue search to a task query's text and
8093/// metadata predicates, or `None` when it carries neither.
8094///
8095/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8096/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8097/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8098/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8099/// a metadata value searches both fields for both — wider than asked, never narrower, and
8100/// every candidate is confirmed in process afterwards.
8101///
8102/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8103/// matches whole tokens where a substring rule would match inside a word, so an item holding
8104/// the text only inside a longer word is not returned. A text of nothing but whitespace
8105/// matches every item, so it narrows nothing and is not sent.
8106fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8107 let text = query
8108 .text
8109 .as_ref()
8110 .filter(|text| !text.terms.trim().is_empty());
8111 if text.is_none() && query.metadata.is_empty() {
8112 return None;
8113 }
8114 let (title, body) = match text.map(|text| text.fields) {
8115 None => (false, true),
8116 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8117 Some(TextFields::Content) => (false, true),
8118 Some(TextFields::TitleOrContent) => (true, true),
8119 };
8120 let fields = match (title, body) {
8121 (true, true) => "in:title,body",
8122 (true, false) => "in:title",
8123 _ => "in:body",
8124 };
8125 let phrases = text
8126 .map(|text| text.terms.clone())
8127 .into_iter()
8128 .chain(
8129 query
8130 .metadata
8131 .iter()
8132 .map(|wanted| as_stored(wanted.value())),
8133 )
8134 .map(|phrase| quoted(&phrase))
8135 .collect::<Vec<_>>();
8136 Some(format!("{fields} {}", phrases.join(" ")))
8137}
8138
8139/// The search terms that narrow a board-scoped issue search to a project or document query's
8140/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8141/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8142fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8143 narrowing_qualifiers(&TaskQuery {
8144 text: text.cloned(),
8145 ..TaskQuery::default()
8146 })
8147}
8148
8149/// Refuses a project or document query's text GitHub's issue search cannot find, before
8150/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8151/// query's.
8152fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8153 refuse_unsearchable(&TaskQuery {
8154 text: text.cloned(),
8155 ..TaskQuery::default()
8156 })
8157}
8158
8159/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8160/// before anything is asked of GitHub.
8161///
8162/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8163/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8164/// left out, the search is every issue of the board. So this source says it cannot answer
8165/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8166/// nothing GitHub could search for, and keeps the board read it always had.
8167fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8168 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8169 letter or digit with a bounded query";
8170 if let Some(text) = &query.text
8171 && !text.terms.trim().is_empty()
8172 && !has_words(&text.terms)
8173 {
8174 return Err(SourceError::Refused {
8175 message: format!(
8176 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8177 text.terms
8178 ),
8179 });
8180 }
8181 if let Some(wanted) = query
8182 .metadata
8183 .iter()
8184 .find(|wanted| !has_words(wanted.value()))
8185 {
8186 return Err(SourceError::Refused {
8187 message: format!(
8188 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8189 wanted.value(),
8190 std::iter::once(wanted.key())
8191 .chain(wanted.path().iter().map(String::as_str))
8192 .collect::<Vec<_>>()
8193 .join("/"),
8194 ),
8195 });
8196 }
8197 Ok(())
8198}
8199
8200/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8201fn has_words(phrase: &str) -> bool {
8202 phrase.chars().any(char::is_alphanumeric)
8203}
8204
8205/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8206///
8207/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8208/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8209/// which GitHub's word match would read as different words.
8210fn as_stored(value: &str) -> String {
8211 let encoded = Value::String(value.to_owned()).to_string();
8212 encoded[1..encoded.len() - 1].to_owned()
8213}
8214
8215/// The one narrower question a task query carrying a text, metadata or origin predicate is
8216/// sent as.
8217enum Narrowing {
8218 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8219 Origin(String),
8220 /// The board-scoped issue search narrowed by these qualifiers.
8221 Search(String),
8222}
8223
8224impl Narrowing {
8225 /// What this question is remembered under for the length of one command.
8226 fn key(&self) -> String {
8227 match self {
8228 Self::Origin(origin) => format!("origin {origin}"),
8229 Self::Search(also) => format!("search {also}"),
8230 }
8231 }
8232}
8233
8234/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8235enum Resumed {
8236 /// It reported another page, which starts after this cursor.
8237 More(String),
8238 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8239 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8240 /// can go on walking the other connection.
8241 Ended(Option<String>),
8242}
8243
8244impl Resumed {
8245 /// Whether the connection has another page.
8246 const fn has_more(&self) -> bool {
8247 matches!(self, Self::More(_))
8248 }
8249
8250 /// The cursor to send this connection next.
8251 fn cursor(self) -> Option<String> {
8252 match self {
8253 Self::More(next) => Some(next),
8254 Self::Ended(last) => last,
8255 }
8256 }
8257}
8258
8259/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8260/// with no cursor to it, or from a cursor that does not advance.
8261fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8262 let info = connection
8263 .get("pageInfo")
8264 .ok_or_else(|| SourceError::Malformed {
8265 message: "GitHub connection has no pageInfo".into(),
8266 })?;
8267 let end = optional_str(info, "endCursor")?;
8268 if required_bool(info, "hasNextPage")? {
8269 let next = end.ok_or_else(|| SourceError::Malformed {
8270 message: "GitHub connection reports another page and no endCursor".into(),
8271 })?;
8272 validate_cursor_progress(after, next)?;
8273 return Ok(Resumed::More(next.to_owned()));
8274 }
8275 Ok(Resumed::Ended(
8276 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8277 ))
8278}
8279
8280/// The board, and every item on it this source reports.
8281#[derive(Clone)]
8282struct Board {
8283 id: String,
8284 fields: Value,
8285 items: Vec<Resolved>,
8286}
8287
8288/// What a write needs of the board and nothing more: its node id and its field
8289/// definitions, in the shape a read of the board's own `fields` gives them.
8290///
8291/// Deliberately no items. A write decides which item it writes, which parent it files
8292/// under and which far ends it names by reading each of them by its own id; this is the
8293/// half of the board those reads cannot carry, and holding no item is what keeps it from
8294/// ever being asked whether an item is there.
8295#[derive(Clone)]
8296struct BoardFields {
8297 id: BoardId,
8298 fields: Value,
8299}
8300
8301/// A board's node id: what a field write and `addProjectV2ItemById` address.
8302///
8303/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8304/// refused where it is read, and one an item names blank is read as not named at all.
8305#[derive(Clone)]
8306struct BoardId(String);
8307
8308/// Where one write left its item, for the record the rest of the command reads it out of.
8309///
8310/// A named record rather than a tuple because the update arm and the create arm each fill
8311/// all four, and two `Option`s of different meaning side by side in a tuple are two
8312/// positions a reader has to count.
8313struct Landed {
8314 /// The issue's own node id, which is the [`NativeId`] this source reports.
8315 content_id: NativeId,
8316 /// The board item's id, which is what a field write addresses.
8317 // 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.
8318 item_id: String,
8319 /// The web address GitHub gave the issue, when it gave one.
8320 // 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.
8321 url: Option<String>,
8322 /// The issue's number on its repository, when GitHub reported one.
8323 number: Option<u64>,
8324}
8325
8326impl BoardId {
8327 fn parse(id: &str) -> Result<Self, SourceError> {
8328 if id.trim().is_empty() {
8329 return Err(SourceError::Malformed {
8330 message: "GitHub named a board with a blank node id".into(),
8331 });
8332 }
8333 Ok(Self(id.to_owned()))
8334 }
8335
8336 fn as_str(&self) -> &str {
8337 &self.0
8338 }
8339}
8340
8341impl Board {
8342 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8343 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8344 let nodes = fields
8345 .get("nodes")
8346 .and_then(Value::as_array)
8347 .ok_or_else(|| SourceError::Malformed {
8348 message: "GitHub project fields.nodes is not an array".into(),
8349 })?;
8350 Ok(nodes
8351 .iter()
8352 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8353 }
8354}
8355
8356/// One board item, resolved into everything this source reports about it.
8357#[derive(Clone)]
8358struct Resolved {
8359 item_id: String,
8360 id: NativeId,
8361 content_kind: ContentKind,
8362 kind: BoardKind,
8363 title: String,
8364 body: Option<String>,
8365 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8366 /// that changes the slot alone has to keep byte for byte outside it.
8367 raw_body: Option<String>,
8368 status: Status,
8369 /// The name of the board `Status` option this item sits in, as the board spells it.
8370 option: Option<String>,
8371 /// What its `Priority` field says, read through this instance's mapping.
8372 priority: HeldPriority,
8373 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8374 closed: bool,
8375 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8376 delivers: Vec<TaskRef>,
8377 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8378 /// task.
8379 delivered_by: Vec<TaskRef>,
8380 labels: Vec<Label>,
8381 parent: Option<NativeId>,
8382 // 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.
8383 origin: Option<String>,
8384 /// The issue's own number on its repository, as GitHub reports it.
8385 ///
8386 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8387 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8388 /// an issue this run created whose creating mutation answered without one, which is a
8389 /// response GitHub's own schema says cannot happen and which a landed write is not
8390 /// worth failing over. An `Issue` read off the board always has one.
8391 number: Option<u64>,
8392 url: Option<String>,
8393 created_at: Option<DateTime<Utc>>,
8394 updated_at: Option<DateTime<Utc>>,
8395 own_repository: Option<Repository>,
8396 repositories: Vec<Repository>,
8397 slot: BTreeMap<String, Value>,
8398 /// The node id of the board this item sits on, when the read that reached it said.
8399 board_id: Option<String>,
8400 /// The definition of every board field this item holds a value of, in the shape a read
8401 /// of the board's own `fields` gives one.
8402 ///
8403 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8404 /// which says nothing about whether the board has it.
8405 fields: Vec<Value>,
8406 /// Every field the board this item sits on defines, as its own read of the board's
8407 /// `fields` gives them — when the read that reached the item carried them, which a read
8408 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8409 /// of the board.
8410 board_fields: Option<Value>,
8411 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8412 /// selects one — when the read that reached it carried the connection to its end, which a
8413 /// read of it by its own id does for any issue blocked by no more than a page. What a
8414 /// write reconciles that relationship against, and what a read of its forward edges in
8415 /// the same command answers with.
8416 blocked_by: Option<Vec<Value>>,
8417}
8418
8419impl Resolved {
8420 /// The board this item's own read names it on, when that read named one this source can
8421 /// address.
8422 fn named_board(&self) -> Option<BoardId> {
8423 self.board_id
8424 .as_deref()
8425 .and_then(|id| BoardId::parse(id).ok())
8426 }
8427
8428 /// The board's id and every field it defines, when the read that reached this item
8429 /// carried both — which a read of it by its own id does.
8430 fn carried_board(&self) -> Option<BoardFields> {
8431 Some(BoardFields {
8432 id: self.named_board()?,
8433 fields: self.board_fields.clone()?,
8434 })
8435 }
8436
8437 /// Whether this item holds a value of the board field called `name`, and so carries
8438 /// that field's definition. `false` says nothing about whether the board has the field.
8439 fn defines(&self, name: &str) -> bool {
8440 self.fields
8441 .iter()
8442 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8443 }
8444
8445 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8446 /// in a field of its own, and none of the five keys that are only an encoding.
8447 ///
8448 /// The two delivery keys are left out for every kind, not only for a task: they are
8449 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8450 /// document carrying one holds nothing a caller's own metadata could mean by it.
8451 fn metadata(&self) -> BTreeMap<String, Value> {
8452 let mut metadata = self.slot.clone();
8453 metadata.remove(Repository::METADATA_KEY);
8454 metadata.remove(DependencyEdge::RECORDED_KEY);
8455 metadata.remove(ItemKind::METADATA_KEY);
8456 metadata.remove(TaskRef::DELIVERS_KEY);
8457 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8458 // The board field is the origin, and the body's copy of it is only a mirror for the
8459 // issue search to find: an item whose field holds none has none, whatever its body
8460 // says, so no reader ever sees two answers.
8461 metadata.remove(ORIGIN_KEY);
8462 if let Some(origin) = &self.origin {
8463 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8464 }
8465 metadata
8466 }
8467
8468 /// Where this item is, as a link a reader can open.
8469 ///
8470 /// A board is a hosted place and every issue on it has a web address, so that address
8471 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8472 /// of place it is, so a reader knows to open it rather than to read a file out. It
8473 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8474 /// reported before, and this says what that address *is*.
8475 ///
8476 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8477 /// rather than a third variant, which is the contract's "the source did not say". An
8478 /// issue this run created is not one of those: its address comes back from the
8479 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8480 /// rather than from whenever the board read catches up.
8481 fn location(&self) -> Option<Location> {
8482 self.url.clone().map(Location::Url)
8483 }
8484
8485 /// The short handle this board's backend shows people for a task: the issue's number
8486 /// alone, as a decimal string.
8487 ///
8488 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8489 /// value for this backend. A draft has no number and so no handle, which is the
8490 /// contract's *absent* rather than a handle of some other shape — and the native
8491 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8492 /// derives from.
8493 fn key(&self) -> Option<String> {
8494 self.number.map(|number| number.to_string())
8495 }
8496
8497 /// Whether its `Priority` field holds a value at all, mapped or not.
8498 fn holds_priority(&self) -> bool {
8499 self.priority != HeldPriority::Read(Priority::None)
8500 }
8501
8502 /// The task this item is.
8503 ///
8504 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8505 /// reading that as a level would be a guess, and reading it as `none` would let the next
8506 /// copy clear a priority a person set.
8507 fn task(&self) -> Result<Task, SourceError> {
8508 let priority = match &self.priority {
8509 HeldPriority::Read(priority) => *priority,
8510 HeldPriority::Unmapped(option) => {
8511 return Err(SourceError::Malformed {
8512 message: format!(
8513 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8514 this source's priority_mapping does not name, so its priority cannot be \
8515 read; next: name {option:?} under priority_mapping, or move the item to \
8516 a mapped option",
8517 self.id,
8518 self.number
8519 .map(|number| format!(" (#{number})"))
8520 .unwrap_or_default()
8521 ),
8522 });
8523 }
8524 };
8525 Ok(Task {
8526 id: self.id.clone(),
8527 key: self.key(),
8528 title: self.title.clone(),
8529 content: self.body.clone(),
8530 status: self.status.clone(),
8531 priority,
8532 labels: self.labels.clone(),
8533 project: self.parent.clone(),
8534 url: self.url.clone(),
8535 location: self.location(),
8536 created_at: self.created_at,
8537 updated_at: self.updated_at,
8538 metadata: self.metadata(),
8539 repositories: self.repositories.clone(),
8540 delivers: self.delivers.clone(),
8541 delivered_by: self.delivered_by.clone(),
8542 })
8543 }
8544
8545 fn project(&self) -> Project {
8546 Project {
8547 id: self.id.clone(),
8548 title: self.title.clone(),
8549 content: self.body.clone(),
8550 status: self.status.clone(),
8551 labels: self.labels.clone(),
8552 url: self.url.clone(),
8553 location: self.location(),
8554 created_at: self.created_at,
8555 updated_at: self.updated_at,
8556 metadata: self.metadata(),
8557 repositories: self.repositories.clone(),
8558 }
8559 }
8560
8561 /// The same issue as a document: the project it is filed under, and no status and no
8562 /// dependencies, because a document is not work.
8563 fn document(&self) -> Document {
8564 Document {
8565 id: self.id.clone(),
8566 title: self.title.clone(),
8567 content: self.body.clone(),
8568 project: self.parent.clone(),
8569 labels: self.labels.clone(),
8570 url: self.url.clone(),
8571 location: self.location(),
8572 created_at: self.created_at,
8573 updated_at: self.updated_at,
8574 metadata: self.metadata(),
8575 repositories: self.repositories.clone(),
8576 }
8577 }
8578}
8579
8580/// Where one targeted update moves an item's status, and which of its two halves move.
8581struct StatusMove {
8582 /// The board the item's `Status` field is on.
8583 board: BoardId,
8584 /// The `Status` field's id.
8585 field: String,
8586 /// The option's id.
8587 option: String,
8588 /// The option's name, as the board spells it.
8589 name: String,
8590 /// What the status asks of the issue's state.
8591 target: StatusTarget,
8592 /// The status the item reads as once it is there.
8593 landed: Status,
8594 /// Which of the status's two halves differ from what the item holds.
8595 moves: Moves,
8596}
8597
8598/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8599/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8600/// and is not a value of this type.
8601#[derive(Clone, Copy, PartialEq, Eq)]
8602enum Moves {
8603 /// The option alone.
8604 Option,
8605 /// The issue's state alone: open, closed, or closed with another reason.
8606 State,
8607 /// Both.
8608 Both,
8609}
8610
8611impl Moves {
8612 /// What differs, or `None` when nothing does.
8613 const fn of(option: bool, state: bool) -> Option<Self> {
8614 match (option, state) {
8615 (true, true) => Some(Self::Both),
8616 (true, false) => Some(Self::Option),
8617 (false, true) => Some(Self::State),
8618 (false, false) => None,
8619 }
8620 }
8621
8622 /// Whether the option moves.
8623 const fn option(self) -> bool {
8624 matches!(self, Self::Option | Self::Both)
8625 }
8626
8627 /// Whether the issue's state moves.
8628 const fn state(self) -> bool {
8629 matches!(self, Self::State | Self::Both)
8630 }
8631}
8632
8633/// What one write is, and the status that comes with being it.
8634///
8635/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8636/// status and a task or a project always has one, so "a document carrying a status" and
8637/// "a task carrying none" are states a write cannot be in rather than states every use
8638/// site below has to defend against.
8639enum Written<'a> {
8640 /// A document, which is not work and so has no status at all.
8641 Document,
8642 /// A task or a project, and the status it is being written with.
8643 Work(ItemKind, &'a Status),
8644}
8645
8646impl Written<'_> {
8647 /// Which of the board's three kinds this write is.
8648 const fn kind(&self) -> BoardKind {
8649 match self {
8650 Self::Document => BoardKind::Document,
8651 Self::Work(kind, _) => BoardKind::Work(*kind),
8652 }
8653 }
8654
8655 /// The status this write carries. A document carries none, so a write of one says
8656 /// nothing about the issue's open or closed state and selects no board `Status`
8657 /// option.
8658 const fn status(&self) -> Option<&Status> {
8659 match self {
8660 Self::Document => None,
8661 Self::Work(_, status) => Some(status),
8662 }
8663 }
8664
8665 /// The status this write carries with the kind whose half of `status_mapping` it is
8666 /// written through.
8667 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8668 match self {
8669 Self::Document => None,
8670 Self::Work(kind, status) => Some((*kind, status)),
8671 }
8672 }
8673}
8674
8675/// The item being written, in the one shape all three write methods reach.
8676struct Incoming<'a> {
8677 written: Written<'a>,
8678 /// The title a person wrote. A document's goes onto the issue with
8679 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8680 title: &'a str,
8681 content: Option<&'a str>,
8682 labels: &'a [Label],
8683 metadata: &'a BTreeMap<String, Value>,
8684 repositories: &'a [Repository],
8685 parent: Option<&'a NativeId>,
8686 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8687 /// what keeps either key out of their slot.
8688 delivers: &'a [TaskRef],
8689 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8690 delivered_by: &'a [TaskRef],
8691 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8692 /// project, a document, and every write to an instance with no `priority_mapping` —
8693 /// which is what keeps such a write's requests exactly what they were before.
8694 priority: Option<Priority>,
8695}
8696
8697/// What one write does to an item's `Priority` field.
8698enum PriorityWrite {
8699 /// Select this option of this field.
8700 Select {
8701 /// The `Priority` field's id.
8702 field: String,
8703 /// The mapped option's id.
8704 option: String,
8705 },
8706 /// Clear the field's value, which is what `none` is.
8707 Clear {
8708 /// The `Priority` field's id.
8709 field: String,
8710 },
8711}
8712
8713impl Incoming<'_> {
8714 /// The title this write puts on the issue.
8715 fn written_title(&self) -> String {
8716 match self.written {
8717 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8718 Written::Work(..) => self.title.to_owned(),
8719 }
8720 }
8721}
8722
8723#[derive(Clone, Copy, PartialEq, Eq)]
8724enum ContentKind {
8725 DraftIssue,
8726 Issue,
8727}
8728
8729/// What one board issue is: a document, or the work an [`ItemKind`] names.
8730///
8731/// A type of this source's own rather than an `ItemKind` with a third variant, because
8732/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8733/// document — the contract keeps a document out of that enum deliberately. Holding the
8734/// board's three answers in one value is what makes every place that asks "which is this?"
8735/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8736/// two thirds of the board.
8737#[derive(Clone, Copy, PartialEq, Eq)]
8738enum BoardKind {
8739 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8740 Document,
8741 /// Every other issue, and every draft.
8742 Work(ItemKind),
8743}
8744
8745impl BoardKind {
8746 /// Whose half of `status_mapping` an item of this kind reads its status through. A
8747 /// document has no status of its own, so the task half stands in for whatever the issue
8748 /// holds; nothing reports it.
8749 const fn status_kind(self) -> ItemKind {
8750 match self {
8751 Self::Document => ItemKind::Task,
8752 Self::Work(kind) => kind,
8753 }
8754 }
8755
8756 /// How a refusal names this kind to the person reading it.
8757 const fn describes(self) -> &'static str {
8758 match self {
8759 Self::Document => "document",
8760 Self::Work(kind) => kind.marker(),
8761 }
8762 }
8763}
8764
8765/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8766///
8767/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8768/// the shared cross-source journeys assert one answer to one question, so two sources
8769/// that disagree about what "carries the label bug" means fail them.
8770fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8771 let holds = |name: &String| {
8772 labels
8773 .iter()
8774 .any(|label| label.name.eq_ignore_ascii_case(name))
8775 };
8776 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8777 && filter.all_of.iter().all(holds)
8778 && !filter.none_of.iter().any(holds)
8779}
8780
8781/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8782/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8783fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8784 statuses.is_empty() || statuses.contains(&category)
8785}
8786
8787/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8788///
8789/// `content` is the item's own prose — the body with this source's trailing metadata
8790/// comment already taken off — so a search never matches an encoding the author of the
8791/// issue never wrote.
8792fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8793 let terms = query.terms.to_lowercase();
8794 let in_title = title.to_lowercase().contains(&terms);
8795 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8796 match query.fields {
8797 TextFields::Title => in_title,
8798 TextFields::Content => in_content,
8799 TextFields::TitleOrContent => in_title || in_content,
8800 }
8801}
8802
8803/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8804///
8805/// The project predicate is passed separately because a read narrowed to one project has
8806/// already answered it by asking *that project* for its own items — and re-applying it
8807/// there would compare the caller's selector, which may be a project's **name**, against
8808/// the id of the project that name resolved to, and keep nothing. Every other read passes
8809/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8810/// source really does apply.
8811fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8812 labels_match(&task.labels, &query.labels)
8813 && status_matches(task.status.category, &query.statuses)
8814 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8815 && match project {
8816 ProjectFilter::Any => true,
8817 ProjectFilter::Orphans => task.project.is_none(),
8818 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8819 }
8820 && query
8821 .text
8822 .as_ref()
8823 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8824 // Against the parsed metadata slot, and against the origin field, which is where
8825 // `Resolved::metadata` reads each of them from.
8826 && query.metadata_matches(&task.metadata)
8827 && query.origin_matches(&task.metadata)
8828}
8829
8830fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8831 labels_match(&project.labels, &query.labels)
8832 && status_matches(project.status.category, &query.statuses)
8833 && query
8834 .text
8835 .as_ref()
8836 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8837}
8838
8839/// The same three predicates a task query carries, minus the status filter.
8840///
8841/// A document is not work, so it has no status for one to compare against and the query
8842/// type carries none. The project predicate is the same one — a design issue filed under a
8843/// project issue is in that project, and one filed under nothing is in none — so it is
8844/// spelled the same way here rather than answered differently.
8845fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8846 labels_match(&document.labels, &query.labels)
8847 && match project {
8848 ProjectFilter::Any => true,
8849 ProjectFilter::Orphans => document.project.is_none(),
8850 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8851 }
8852 && query
8853 .text
8854 .as_ref()
8855 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8856}
8857
8858#[async_trait::async_trait]
8859impl TaskSource for GitHubProjectsSource {
8860 fn kind(&self) -> &'static str {
8861 KIND
8862 }
8863 fn capabilities(&self) -> Capabilities {
8864 Capabilities {
8865 projects: Support::Native,
8866 documents: Support::Native,
8867 comments: Support::Native,
8868 assets: Support::Unsupported,
8869 priority: if self.priorities.is_some() {
8870 Support::Native
8871 } else {
8872 Support::Unsupported
8873 },
8874 filter_by_priority: Support::Native,
8875 filter_by_comment_activity: Support::Native,
8876 filter_by_metadata: Support::Native,
8877 filter_by_origin: Support::Native,
8878 orphan_tasks: Support::Native,
8879 filter_by_label: Support::Native,
8880 filter_by_status: Support::Native,
8881 search_title: Support::Native,
8882 search_content: Support::Native,
8883 task_dependencies: DependencySupport::BothDirections,
8884 project_dependencies: DependencySupport::BothDirections,
8885 max_page_size: MAX_PAGE_SIZE,
8886 }
8887 }
8888 async fn health(&self) -> Result<Health, SourceError> {
8889 let board = self.board_page(None, 1).await?;
8890 Ok(Health {
8891 reachable: true,
8892 detail: Some(format!(
8893 "reading GitHub project {}/{} ({})",
8894 self.owner,
8895 self.project_number,
8896 required_str(&board, "title")?
8897 )),
8898 })
8899 }
8900 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8901 self.item_by_id(id)
8902 .await?
8903 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8904 .map(|item| item.task())
8905 .transpose()
8906 }
8907 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8908 Ok(self
8909 .item_by_id(id)
8910 .await?
8911 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8912 .map(|item| item.project()))
8913 }
8914 async fn query_tasks(
8915 &self,
8916 query: &TaskQuery,
8917 page: &PageRequest,
8918 ) -> Result<Page<Task>, SourceError> {
8919 validate_page(page)?;
8920 refuse_unsearchable(query)?;
8921 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8922 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8923 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8924 (Some(also), None) => Some(also),
8925 (None, Some(since)) => Some(updated_qualifier(since)),
8926 (None, None) => None,
8927 };
8928 if let Some(also) = qualifiers {
8929 return self.search_tasks(query, page, &also).await;
8930 }
8931 }
8932
8933 // A read narrowed to one project asks that project for its own tasks, so nothing
8934 // about it costs what the rest of the board holds. A read carrying a text, metadata
8935 // or origin predicate asks GitHub the narrower question those predicates are, and a
8936 // read narrowed to comment activity alone asks the board's own issue search for the
8937 // issues updated since, which is every issue a comment could have been written or
8938 // edited on since. Every other task read is a question about the whole board and is
8939 // answered by reading it.
8940 let (held, membership) = match (&query.project, query.commented_since) {
8941 (ProjectFilter::Is(project), _) => (
8942 self.project_children(project).await?,
8943 // Answered by where these items came from; see `task_matches`.
8944 &ProjectFilter::Any,
8945 ),
8946 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8947 match (self.narrowed(query).await?, since) {
8948 (Some(narrowed), _) => (narrowed, &query.project),
8949 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8950 (None, None) => (self.board().await?.items, &query.project),
8951 }
8952 }
8953 };
8954 // Filtered before paged: a page of a filtered result is a page of the survivors,
8955 // never the survivors of a page.
8956 let mut tasks = Vec::new();
8957 for item in held
8958 .iter()
8959 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8960 {
8961 let task = item.task()?;
8962 if task_matches(&task, query, membership)
8963 && self.commented_since(item, query.commented_since).await?
8964 {
8965 tasks.push(task);
8966 }
8967 }
8968 Ok(offset_page(
8969 tasks,
8970 numeric_cursor(page.cursor.as_ref())?,
8971 page.limit.min(MAX_PAGE_SIZE) as usize,
8972 ))
8973 }
8974 async fn query_projects(
8975 &self,
8976 query: &ProjectQuery,
8977 page: &PageRequest,
8978 ) -> Result<Page<Project>, SourceError> {
8979 validate_page(page)?;
8980 refuse_unsearchable_text(query.text.as_ref())?;
8981 // The projects a board holds are found by an issue search scoped to that board,
8982 // never by walking the board's own item connection: what tells a project from a
8983 // task is the `parent` each issue carries, which costs nothing to read. A query
8984 // carrying a text asks that search for the text too, so it reads the issues that
8985 // hold it rather than every issue of the board.
8986 let held = match self.text_searched(query.text.as_ref()).await? {
8987 Some(searched) => searched,
8988 None => self.board_issues().await?,
8989 };
8990 let projects = held
8991 .iter()
8992 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8993 .map(Resolved::project)
8994 .filter(|project| project_matches(project, query))
8995 .collect();
8996 Ok(offset_page(
8997 projects,
8998 numeric_cursor(page.cursor.as_ref())?,
8999 page.limit.min(MAX_PAGE_SIZE) as usize,
9000 ))
9001 }
9002 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
9003 Ok(self
9004 .item_by_id(id)
9005 .await?
9006 .filter(|item| item.kind == BoardKind::Document)
9007 .map(|item| item.document()))
9008 }
9009 async fn query_documents(
9010 &self,
9011 query: &DocumentQuery,
9012 page: &PageRequest,
9013 ) -> Result<Page<Document>, SourceError> {
9014 validate_page(page)?;
9015 // Narrowed to one project, this is the same sub-issue read a task list scoped to
9016 // that project makes — a document filed under a project is a sub-issue of it too,
9017 // and which of them come back is the kind this caller asked for. Unscoped, a query
9018 // carrying a text asks the board-scoped issue search for it, as a task query does,
9019 // and only one carrying none reads the board.
9020 let (held, membership) = match &query.project {
9021 ProjectFilter::Is(project) => (
9022 self.project_children(project).await?,
9023 // Answered by where these items came from; see `task_matches`.
9024 &ProjectFilter::Any,
9025 ),
9026 ProjectFilter::Any | ProjectFilter::Orphans => {
9027 refuse_unsearchable_text(query.text.as_ref())?;
9028 match self.text_searched(query.text.as_ref()).await? {
9029 Some(searched) => (searched, &query.project),
9030 None => (self.board().await?.items, &query.project),
9031 }
9032 }
9033 };
9034 // Filtered before paged, exactly as a task read is: a page of a filtered result is
9035 // a page of the survivors, never the survivors of a page.
9036 let documents = held
9037 .iter()
9038 .filter(|item| item.kind == BoardKind::Document)
9039 .map(Resolved::document)
9040 .filter(|document| document_matches(document, query, membership))
9041 .collect();
9042 Ok(offset_page(
9043 documents,
9044 numeric_cursor(page.cursor.as_ref())?,
9045 page.limit.min(MAX_PAGE_SIZE) as usize,
9046 ))
9047 }
9048 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
9049 validate_page(page)?;
9050 let offset = numeric_cursor(page.cursor.as_ref())?;
9051 let mut labels = self
9052 .board()
9053 .await?
9054 .items
9055 .into_iter()
9056 .flat_map(|item| item.labels)
9057 .fold(Vec::new(), |mut all, label| {
9058 if !all.iter().any(|x: &Label| x.id == label.id) {
9059 all.push(label);
9060 }
9061 all
9062 });
9063 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
9064 Ok(offset_page(
9065 labels,
9066 offset,
9067 page.limit.min(MAX_PAGE_SIZE) as usize,
9068 ))
9069 }
9070 async fn task_dependencies(
9071 &self,
9072 id: &NativeId,
9073 direction: Direction,
9074 page: &PageRequest,
9075 ) -> Result<Page<DependencyEdge>, SourceError> {
9076 self.dependencies(id, ItemKind::Task, direction, page).await
9077 }
9078 async fn project_dependencies(
9079 &self,
9080 id: &NativeId,
9081 direction: Direction,
9082 page: &PageRequest,
9083 ) -> Result<Page<DependencyEdge>, SourceError> {
9084 self.dependencies(id, ItemKind::Project, direction, page)
9085 .await
9086 }
9087
9088 fn writes(&self) -> WriteSupport {
9089 WriteSupport::Supported
9090 }
9091
9092 /// Create or update one task.
9093 ///
9094 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9095 /// neither may name the task itself or name one task twice — and land in the body's
9096 /// metadata slot under their reserved keys, in place of any caller metadata of those
9097 /// names.
9098 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9099 let near = write.target.as_ref().unwrap_or(&write.item.id);
9100 for (key, entries) in [
9101 (TaskRef::DELIVERS_KEY, &write.item.delivers),
9102 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9103 ] {
9104 TaskRef::listed(key, near, Some(&self.name), entries.clone())
9105 .map_err(|message| SourceError::Refused { message })?;
9106 }
9107 if self.priorities.is_none() && write.item.priority != Priority::None {
9108 return Err(self.holds_no_priority());
9109 }
9110 self.write_item(
9111 &Incoming {
9112 written: Written::Work(ItemKind::Task, &write.item.status),
9113 title: &write.item.title,
9114 content: write.item.content.as_deref(),
9115 labels: &write.item.labels,
9116 metadata: &write.item.metadata,
9117 repositories: &write.item.repositories,
9118 parent: write.item.project.as_ref(),
9119 delivers: &write.item.delivers,
9120 delivered_by: &write.item.delivered_by,
9121 priority: self.priorities.as_ref().map(|_| write.item.priority),
9122 },
9123 write.target.as_ref(),
9124 &write.depends_on,
9125 )
9126 .await
9127 }
9128
9129 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9130 self.write_item(
9131 &Incoming {
9132 written: Written::Work(ItemKind::Project, &write.item.status),
9133 title: &write.item.title,
9134 content: write.item.content.as_deref(),
9135 labels: &write.item.labels,
9136 metadata: &write.item.metadata,
9137 repositories: &write.item.repositories,
9138 parent: None,
9139 delivers: &[],
9140 delivered_by: &[],
9141 priority: None,
9142 },
9143 write.target.as_ref(),
9144 &write.depends_on,
9145 )
9146 .await
9147 }
9148
9149 /// Create or update one document, which is one issue titled the way this board spells
9150 /// a document.
9151 ///
9152 /// Everything else is exactly a task write: caller metadata goes to the same canonical
9153 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9154 /// or a field this board cannot carry is refused by name rather than dropped, a target
9155 /// naming an issue this board does not hold is refused rather than created, and an
9156 /// issue this call created is taken back when the rest of the write fails.
9157 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9158 // A document takes part in no dependency graph, so there is no far end to write
9159 // natively and none to record: a caller naming one is told so rather than having it
9160 // stored under the reserved key, where a later read would report an edge the
9161 // contract says cannot exist.
9162 if !write.depends_on.is_empty() {
9163 return Err(SourceError::Refused {
9164 message: format!(
9165 "this write names {} dependencies for a document, and a document takes \
9166 part in no dependency graph; next: put the dependency on the task or \
9167 project the document is about",
9168 write.depends_on.len()
9169 ),
9170 });
9171 }
9172 self.write_item(
9173 &Incoming {
9174 written: Written::Document,
9175 title: &write.item.title,
9176 content: write.item.content.as_deref(),
9177 labels: &write.item.labels,
9178 metadata: &write.item.metadata,
9179 repositories: &write.item.repositories,
9180 parent: write.item.project.as_ref(),
9181 delivers: &[],
9182 delivered_by: &[],
9183 priority: None,
9184 },
9185 write.target.as_ref(),
9186 &[],
9187 )
9188 .await
9189 }
9190
9191 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9192 /// which reads nothing; then the board's `Status` option. Over an existing item that is
9193 /// read off the item, as the write reads it, and the item is held among this command's
9194 /// resolved records so the write that follows reuses that read rather than repeating it;
9195 /// an item that does not carry the field takes the board's fields, which are held once
9196 /// read. A create is checked against the board's fields only when this command already
9197 /// holds them, because a create reads them together with its repository, in one request,
9198 /// and refuses a missing option before it writes anything.
9199 async fn check_status_write(
9200 &self,
9201 kind: ItemKind,
9202 category: StatusCategory,
9203 target: Option<&NativeId>,
9204 ) -> Result<(), SourceError> {
9205 let status = self.resolved_target(kind, category)?;
9206 if status.option().is_none() {
9207 return Ok(());
9208 }
9209 let fields = match target {
9210 Some(target) => {
9211 // A target this board does not hold is the write's own refusal to make.
9212 let Some(item) = self.bound_item(target).await? else {
9213 return Ok(());
9214 };
9215 self.resolved_cache()?.insert(target.clone(), item.clone());
9216 self.fields_for(Some(&item), true, false).await?.fields
9217 }
9218 None => {
9219 let held = self
9220 .board_cache()?
9221 .as_ref()
9222 .map(|board| board.fields.clone());
9223 match held.or_else(|| {
9224 self.fields_cache()
9225 .ok()
9226 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9227 }) {
9228 Some(fields) => fields,
9229 None => return Ok(()),
9230 }
9231 }
9232 };
9233 self.column_for(&fields, kind, category, &status)
9234 .map(|_| ())
9235 }
9236
9237 /// Set one task's status alone.
9238 ///
9239 /// An open target reopens a closed issue with an `updateIssue` carrying only its
9240 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9241 /// terminal target selects its mapped option, then closes with its fixed reason. No
9242 /// request carries a title, a body or a label. The status
9243 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9244 /// what a re-read reports.
9245 async fn set_task_status(
9246 &self,
9247 id: &NativeId,
9248 category: StatusCategory,
9249 ) -> Result<Option<Status>, SourceError> {
9250 self.set_status(id, category).await
9251 }
9252
9253 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9254 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9255 /// for `none`. Refused by an instance with no `priority_mapping`.
9256 async fn set_task_priority(
9257 &self,
9258 id: &NativeId,
9259 priority: Priority,
9260 ) -> Result<Option<Priority>, SourceError> {
9261 self.set_priority(id, priority).await
9262 }
9263
9264 /// Replace one task's content with a single body update that keeps the metadata slot
9265 /// byte for byte.
9266 async fn set_task_content(
9267 &self,
9268 id: &NativeId,
9269 content: &str,
9270 ) -> Result<Option<()>, SourceError> {
9271 self.replace_content(id, content).await
9272 }
9273
9274 /// Replace one task issue's content and its provenance slot entry with a single body
9275 /// update. The answers are not kept: see `replace_rendering`.
9276 async fn set_task_rendering(
9277 &self,
9278 id: &NativeId,
9279 content: &str,
9280 provenance: &Value,
9281 _answers: &BTreeMap<String, Value>,
9282 ) -> Result<Option<()>, SourceError> {
9283 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9284 .await
9285 }
9286
9287 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9288 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9289 async fn set_document_rendering(
9290 &self,
9291 id: &NativeId,
9292 content: &str,
9293 provenance: &Value,
9294 _answers: &BTreeMap<String, Value>,
9295 ) -> Result<Option<()>, SourceError> {
9296 self.replace_rendering(id, BoardKind::Document, content, provenance)
9297 .await
9298 }
9299
9300 /// Replace one project issue's content and its provenance slot entry, on exactly the
9301 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9302 async fn set_project_rendering(
9303 &self,
9304 id: &NativeId,
9305 content: &str,
9306 provenance: &Value,
9307 _answers: &BTreeMap<String, Value>,
9308 ) -> Result<Option<()>, SourceError> {
9309 self.replace_rendering(id, BoardKind::Work(ItemKind::Project), content, provenance)
9310 .await
9311 }
9312
9313 /// Apply a targeted update with one read of the item and a write only for what differs:
9314 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9315 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9316 async fn update_task(
9317 &self,
9318 id: &NativeId,
9319 update: &TaskUpdate,
9320 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9321 self.targeted_update(id, update).await
9322 }
9323
9324 /// Replace one task's `delivered_by` with a single body update that changes the
9325 /// metadata slot and nothing outside it.
9326 async fn set_delivered_by(
9327 &self,
9328 id: &NativeId,
9329 delivered_by: &[TaskRef],
9330 ) -> Result<Option<()>, SourceError> {
9331 self.replace_delivered_by(id, delivered_by).await
9332 }
9333
9334 /// Set one key of one task issue's metadata with a single body update that changes the
9335 /// metadata slot and nothing outside it — no title, label, state or board field request —
9336 /// and sends nothing when the task already holds that value under the key.
9337 async fn set_task_metadata(
9338 &self,
9339 id: &NativeId,
9340 key: &MetadataKey,
9341 value: &Value,
9342 ) -> Result<Option<Task>, SourceError> {
9343 Ok(self
9344 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9345 .await?
9346 .map(|item| item.task())
9347 .transpose()?)
9348 }
9349
9350 /// Set one key of one project issue's metadata, on exactly the terms of
9351 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9352 async fn set_project_metadata(
9353 &self,
9354 id: &NativeId,
9355 key: &MetadataKey,
9356 value: &Value,
9357 ) -> Result<Option<Project>, SourceError> {
9358 Ok(self
9359 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9360 .await?
9361 .map(|item| item.project()))
9362 }
9363
9364 /// Set one key of one design-document issue's metadata, on exactly the terms of
9365 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9366 async fn set_document_metadata(
9367 &self,
9368 id: &NativeId,
9369 key: &MetadataKey,
9370 value: &Value,
9371 ) -> Result<Option<Document>, SourceError> {
9372 Ok(self
9373 .set_slot_key(id, BoardKind::Document, key, value)
9374 .await?
9375 .map(|item| item.document()))
9376 }
9377
9378 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9379 self.delete_item(id).await
9380 }
9381
9382 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9383 self.delete_item(id).await
9384 }
9385
9386 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9387 self.delete_item(id).await
9388 }
9389
9390 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9391 ///
9392 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9393 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9394 ///
9395 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9396 /// board is the read of its comments. A draft this process already resolved is refused
9397 /// without one.
9398 async fn task_comments(
9399 &self,
9400 task: &NativeId,
9401 page: &PageRequest,
9402 ) -> Result<Option<Page<Comment>>, SourceError> {
9403 validate_page(page)?;
9404 let cached = self.resolved_cache()?.get(task).cloned();
9405 if let Some(item) = cached {
9406 if item.kind != BoardKind::Work(ItemKind::Task) {
9407 return Ok(None);
9408 }
9409 if item.content_kind == ContentKind::DraftIssue {
9410 return Err(self.draft_has_no_comments(task));
9411 }
9412 }
9413 match self.issue_detail(task, page).await? {
9414 Some(TaskDetailRead {
9415 comments: Some(comments),
9416 ..
9417 }) => comments,
9418 _ => Ok(None),
9419 }
9420 }
9421
9422 /// Every id's task, with the first page of its comments when `comments` names it:
9423 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9424 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9425 async fn get_task_details(
9426 &self,
9427 ids: &[NativeId],
9428 comments: Option<&PageRequest>,
9429 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9430 if let Some(page) = comments
9431 && let Err(error) = validate_page(page)
9432 {
9433 return ids.iter().map(|_| Err(error.clone())).collect();
9434 }
9435 match (ids, comments) {
9436 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9437 ([id], None) => vec![self.task_read(id).await],
9438 _ => self.issue_details(ids, comments).await,
9439 }
9440 }
9441
9442 /// Add one comment to the task's issue, as the account the token belongs to.
9443 ///
9444 /// The author is refused before anything is sent — not even the task is read — because
9445 /// no answer GitHub could give would make posting under another name than the one asked
9446 /// for the right outcome.
9447 async fn add_comment(
9448 &self,
9449 task: &NativeId,
9450 comment: &NewComment,
9451 ) -> Result<Option<Comment>, SourceError> {
9452 if let Some(author) = &comment.author {
9453 return Err(SourceError::Refused {
9454 message: format!(
9455 "source {} cannot post a comment as {author:?}: GitHub records the account \
9456 the token signs in as the author of every comment; next: leave --author \
9457 out, and the comment is posted as that account",
9458 self.name
9459 ),
9460 });
9461 }
9462 let Some(issue) = self.commented_issue(task).await? else {
9463 return Ok(None);
9464 };
9465 let data = self
9466 .graphql(
9467 graphql::ADD_COMMENT,
9468 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9469 )
9470 .await?;
9471 let subject = data
9472 .pointer("/addComment/subject")
9473 .filter(|value| !value.is_null())
9474 .ok_or_else(|| SourceError::Malformed {
9475 message: "GitHub comment addition returned no subject".into(),
9476 })?;
9477 if required_str(subject, "id")? != issue.0 {
9478 return Err(SourceError::Malformed {
9479 message: "GitHub comment addition answered about another issue".into(),
9480 });
9481 }
9482 let added = data
9483 .pointer("/addComment/commentEdge/node")
9484 .filter(|value| !value.is_null())
9485 .ok_or_else(|| SourceError::Malformed {
9486 message: "GitHub comment addition returned no comment".into(),
9487 })?;
9488 let added = comment_from(added)?;
9489 self.remember_commented(&issue)?;
9490 Ok(Some(added))
9491 }
9492
9493 async fn edit_comment(
9494 &self,
9495 task: &NativeId,
9496 comment: &NativeId,
9497 body: &CommentBody,
9498 ) -> Result<Option<Comment>, SourceError> {
9499 let Some(issue) = self.commented_issue(task).await? else {
9500 return Ok(None);
9501 };
9502 if !self.comment_is_on(&issue, comment).await? {
9503 return Ok(None);
9504 }
9505 let data = self
9506 .graphql(
9507 graphql::UPDATE_COMMENT,
9508 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9509 )
9510 .await?;
9511 let edited = data
9512 .pointer("/updateIssueComment/issueComment")
9513 .filter(|value| !value.is_null())
9514 .ok_or_else(|| SourceError::Malformed {
9515 message: "GitHub comment update returned no comment".into(),
9516 })?;
9517 let edited = comment_from(edited)?;
9518 if edited.id != *comment {
9519 return Err(SourceError::Malformed {
9520 message: "GitHub comment update returned the wrong comment".into(),
9521 });
9522 }
9523 self.remember_commented(&issue)?;
9524 Ok(Some(edited))
9525 }
9526
9527 async fn delete_comment(
9528 &self,
9529 task: &NativeId,
9530 comment: &NativeId,
9531 ) -> Result<Option<NativeId>, SourceError> {
9532 let Some(issue) = self.commented_issue(task).await? else {
9533 return Ok(None);
9534 };
9535 if !self.comment_is_on(&issue, comment).await? {
9536 return Ok(None);
9537 }
9538 let data = self
9539 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9540 .await?;
9541 // The payload says nothing about the comment it removed, so what is checked is that
9542 // GitHub answered the mutation at all rather than leaving it unanswered.
9543 data.get("deleteIssueComment")
9544 .filter(|value| !value.is_null())
9545 .ok_or_else(|| SourceError::Malformed {
9546 message: "GitHub comment deletion returned no payload".into(),
9547 })?;
9548 Ok(Some(comment.clone()))
9549 }
9550
9551 /// Every request this source has recorded, and what each of GitHub's two budgets was
9552 /// attributed — read off the same accounting the session report is rendered from, so
9553 /// the two cannot count one request two ways.
9554 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9555 Ok(Some(self.ledger.snapshot().metering()))
9556 }
9557
9558 /// Drop every item, search answer and board read this source holds, so the next command
9559 /// reads the board as a person has since left it.
9560 ///
9561 /// Every one of those is held on the assumption that nothing but this source writes the
9562 /// board while a command runs, which stops being true the moment the command is over: a
9563 /// body a person edited would be overwritten from the record held here, and a card they
9564 /// moved would be read as still where this source left it. The board's own field
9565 /// definitions go too, because a person can add or delete a `Status` option and a write
9566 /// resolved against the held list would not re-read on a miss. What stays is what stays
9567 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9568 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9569 /// accounting [`metering`](TaskSource::metering) answers from.
9570 ///
9571 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9572 /// refused, because clearing it is what puts it right.
9573 async fn end_command(&self) -> Result<(), SourceError> {
9574 fn clear<T: Default>(held: &Mutex<T>) {
9575 *held
9576 .lock()
9577 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9578 held.clear_poison();
9579 }
9580 clear(&self.created);
9581 clear(&self.updated);
9582 clear(&self.commented);
9583 clear(&self.board_cache);
9584 clear(&self.search_cache);
9585 clear(&self.narrowed_cache);
9586 clear(&self.search_next);
9587 clear(&self.resolved_cache);
9588 clear(&self.fields_cache);
9589 Ok(())
9590 }
9591}
9592
9593/// One issue comment as the contract carries it.
9594///
9595/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9596/// and when it answers an actor with no login, because either way the source did not say who
9597/// wrote it — which is what an absent author means, rather than an author called nothing.
9598fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9599 Ok(Comment {
9600 id: NativeId(required_str(value, "id")?.to_owned()),
9601 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9602 .map(str::to_owned),
9603 created_at: optional_time(value, "createdAt")?,
9604 updated_at: optional_time(value, "updatedAt")?,
9605 body: required_str(value, "body")?.to_owned(),
9606 url: optional_str(value, "url")?.map(str::to_owned),
9607 })
9608}
9609
9610/// The page of comments one issue node carries, resumed from `after`.
9611fn comment_page(
9612 node: &Value,
9613 issue: &str,
9614 after: Option<&str>,
9615) -> Result<Page<Comment>, SourceError> {
9616 let connection = node
9617 .get("comments")
9618 .filter(|value| !value.is_null())
9619 .ok_or_else(|| SourceError::Malformed {
9620 message: format!("GitHub issue {issue} answered with no comments connection"),
9621 })?;
9622 let items = optional_nodes(Some(connection), "issue comments")?
9623 .into_iter()
9624 .flatten()
9625 .map(comment_from)
9626 .collect::<Result<Vec<_>, _>>()?;
9627 let next = next_cursor(connection)?;
9628 if let Some(next) = &next {
9629 validate_cursor_progress(after, &next.0)?;
9630 }
9631 Ok(Page { items, next })
9632}
9633
9634/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9635/// end — `None` when it carried none, or a page with more past it.
9636fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9637 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9638 return Ok(None);
9639 };
9640 if next_cursor(connection)?.is_some() {
9641 return Ok(None);
9642 }
9643 Ok(Some(
9644 optional_nodes(Some(connection), "blocked-by issues")?
9645 .into_iter()
9646 .flatten()
9647 .cloned()
9648 .collect(),
9649 ))
9650}
9651
9652/// Where the recorded tail of a dependency walk resumes; see
9653/// [`GitHubProjectsSource::recorded_edges`].
9654const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9655
9656/// The board text field this source keeps a copy's origin in.
9657///
9658/// Named after the key it holds, and held to that name by the guard below rather than by
9659/// a reader noticing.
9660const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9661
9662/// The metadata key that field holds.
9663///
9664/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9665/// constructs or interprets the qualified id it carries. This source names it only to
9666/// route it — a short, typed value belongs in a typed field rather than in the body slot
9667/// a caller's own prose shares.
9668///
9669/// Restated rather than imported, because no plugin crate may depend on the engine. What
9670/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9671/// target in `check`: it reads the engine's own literal and fails naming the file and the
9672/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9673/// that creates a second item every run instead of finding the one it wrote — and that is
9674/// too late to learn it.
9675const ORIGIN_KEY: &str = "onetaskgraph.origin";
9676
9677/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9678///
9679/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9680/// is derived from the far end, never written down on the near item — so only a forward
9681/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9682/// it did not come from, and it is told so rather than answered with an empty page that
9683/// reads as a walk which ended.
9684fn recorded_offset(
9685 cursor: Option<&str>,
9686 direction: Direction,
9687) -> Result<Option<usize>, SourceError> {
9688 cursor
9689 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9690 .map(|offset| {
9691 if direction != Direction::DependsOn {
9692 return Err(SourceError::Config {
9693 message: format!(
9694 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9695 reverse dependency read never issues; resume it in the direction \
9696 that reported it"
9697 ),
9698 });
9699 }
9700 offset.parse().map_err(|_| SourceError::Config {
9701 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9702 })
9703 })
9704 .transpose()
9705}
9706
9707fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9708 let mut page = offset_page(edges, offset, limit.max(1));
9709 page.next = page
9710 .next
9711 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9712 page
9713}
9714
9715/// The kind of one issue reached through a dependency connection.
9716///
9717/// The same questions the board scan asks, over the fields the dependency document
9718/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9719/// then anything with sub-issues or the marker is a project.
9720///
9721/// # Errors
9722///
9723/// A far end this board holds as a document is refused rather than reported. The two
9724/// answers that are not refusals would both be wrong: reporting it as a task names an id
9725/// no task read of this source can find, and reporting it as a project names one no
9726/// project read can. There is no third value to return — `ItemKind` has no document
9727/// variant, because nothing may point at a document — so the relationship itself is what
9728/// the person is told about.
9729fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9730 let id = required_str(value, "id")?;
9731 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9732 return Err(SourceError::Refused {
9733 message: format!(
9734 "GitHub issue {id} is a document of this board — its title begins \
9735 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9736 on by one; next: remove that issue's blocking relationship on this board"
9737 ),
9738 });
9739 }
9740 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9741 if parent.is_some() {
9742 return Ok(ItemKind::Task);
9743 }
9744 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9745 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9746 message: format!("GitHub issue {id}: {message}"),
9747 })?;
9748 let sub_issues = sub_issue_total(value)?;
9749 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9750 ItemKind::Project
9751 } else {
9752 ItemKind::Task
9753 })
9754}
9755
9756/// The `IssueStateUpdateInput` one status target asks for.
9757///
9758/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9759/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9760/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9761/// would report a change forever. A document has no status at all, and asks for neither.
9762fn state_input(target: Option<&StatusTarget>) -> Value {
9763 match target {
9764 Some(StatusTarget::Terminal(_, reason)) => {
9765 json!({"value":"CLOSED","stateReason":reason.reason()})
9766 }
9767 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9768 // A document has no status, so a write of one says nothing about the issue's open
9769 // or closed state rather than forcing it open: `stateInput` is what carries that
9770 // instruction, and an explicit null asks for no change to it.
9771 None => Value::Null,
9772 }
9773}
9774
9775/// The metadata one write stores in the item's body slot.
9776///
9777/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9778/// rather than carried: the kind marker so an empty project stays readable, the
9779/// repository list only when it is not exactly the issue's own repository, and the far
9780/// ends no relationship here can name.
9781///
9782/// The copy origin is the one typed field that is also mirrored here, and only as a
9783/// mirror: it lands in the board's origin field as well, which stays the one every reader
9784/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9785/// and catches up with a write in seconds rather than minutes — can find the item by it.
9786/// A reader of the release before this one drops the slot's copy and reads the field, so an
9787/// item written here still reads with exactly one origin there.
9788fn slot_metadata(
9789 incoming: &Incoming<'_>,
9790 own_repository: Option<&Repository>,
9791 fallback: &[DependencyEdge],
9792) -> BTreeMap<String, Value> {
9793 let mut metadata = incoming.metadata.clone();
9794 match metadata.remove(ORIGIN_KEY) {
9795 Some(Value::String(origin)) if !origin.is_empty() => {
9796 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9797 }
9798 _ => {}
9799 }
9800 match incoming.written.kind() {
9801 BoardKind::Work(kind) => metadata.insert(
9802 ItemKind::METADATA_KEY.to_owned(),
9803 Value::String(kind.marker().to_owned()),
9804 ),
9805 // A document is told by its title, so it carries no kind marker: that key names
9806 // what a dependency endpoint points at, and nothing may point at a document.
9807 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9808 };
9809 let derivable = own_repository
9810 .map(|own| incoming.repositories == [own.clone()])
9811 .unwrap_or(incoming.repositories.is_empty());
9812 if derivable {
9813 metadata.remove(Repository::METADATA_KEY);
9814 } else {
9815 metadata.insert(
9816 Repository::METADATA_KEY.to_owned(),
9817 Value::Array(
9818 incoming
9819 .repositories
9820 .iter()
9821 .map(|repository| Value::String(repository.as_str().to_owned()))
9822 .collect(),
9823 ),
9824 );
9825 }
9826 // The typed lists are what land, whatever the caller's own metadata held under their
9827 // keys: a key of either name travelling beside the field would otherwise be a second
9828 // answer to the same question, and the field is the one the contract names.
9829 for (key, entries) in [
9830 (TaskRef::DELIVERS_KEY, incoming.delivers),
9831 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9832 ] {
9833 set_task_list(&mut metadata, key, entries);
9834 }
9835 record_edges(&mut metadata, fallback);
9836 metadata
9837}
9838
9839/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9840/// one slot's metadata, or no such key when there are none.
9841fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9842 if fallback.is_empty() {
9843 metadata.remove(DependencyEdge::RECORDED_KEY);
9844 } else {
9845 metadata.insert(
9846 DependencyEdge::RECORDED_KEY.to_owned(),
9847 Value::Array(
9848 fallback
9849 .iter()
9850 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9851 .collect(),
9852 ),
9853 );
9854 }
9855}
9856
9857/// Every label one item carries, from its content's own connection and nowhere else.
9858///
9859/// There is no second place to read one from: no document this source sends selects the
9860/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9861/// cannot carry one at all. The module documentation records the three schema facts that
9862/// settle it.
9863fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9864 optional_nodes(content.get("labels"), "content labels")?
9865 .into_iter()
9866 .flatten()
9867 .map(|v| {
9868 Ok(Label {
9869 id: NativeId(required_str(v, "id")?.to_owned()),
9870 name: required_str(v, "name")?.to_owned(),
9871 color: optional_str(v, "color")?.map(str::to_owned),
9872 })
9873 })
9874 .collect()
9875}
9876
9877/// The definition of each board field one item's values are values of, in the shape a read
9878/// of the board's own `fields` gives one.
9879///
9880/// A value names its field through a fragment on that field's own type, so the type is
9881/// known from which kind of value it is: a single-select value's field is a
9882/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9883/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9884fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9885 field_values
9886 .iter()
9887 .filter_map(|value| {
9888 let field = value.get("field")?.as_object()?;
9889 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9890 let typename = if value.get("text").is_some() {
9891 "ProjectV2Field"
9892 } else if value.get("name").is_some() {
9893 "ProjectV2SingleSelectField"
9894 } else {
9895 return None;
9896 };
9897 let mut defined = field.clone();
9898 defined.insert("__typename".to_owned(), json!(typename));
9899 Some(Value::Object(defined))
9900 })
9901 .collect()
9902}
9903
9904fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9905 let Some(node) = field_values
9906 .iter()
9907 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9908 else {
9909 return Ok(None);
9910 };
9911 Ok(optional_str(node, "text")?.map(str::to_owned))
9912}
9913
9914fn valid_github_owner(owner: &str) -> bool {
9915 !owner.is_empty()
9916 && owner.len() <= 39
9917 && !owner.starts_with('-')
9918 && !owner.ends_with('-')
9919 && !owner.contains("--")
9920 && owner
9921 .bytes()
9922 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9923}
9924
9925/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9926/// neither of the two names a path segment already means.
9927fn valid_github_repository_name(name: &str) -> bool {
9928 !name.is_empty()
9929 && name.len() <= 100
9930 && name != "."
9931 && name != ".."
9932 && name
9933 .bytes()
9934 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9935}
9936
9937fn valid_environment_name(name: &str) -> bool {
9938 let mut bytes = name.bytes();
9939 bytes
9940 .next()
9941 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9942 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9943}
9944
9945/// How many sub-issues one issue has.
9946///
9947/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9948/// absent or non-integer one is a response this source cannot read — and reading it as
9949/// zero would classify a project as a task, which is exactly the mistake the marker
9950/// exists to keep from happening quietly.
9951fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9952 let summary = issue
9953 .get("subIssuesSummary")
9954 .ok_or_else(|| SourceError::Malformed {
9955 message: "GitHub issue is missing subIssuesSummary".into(),
9956 })?;
9957 summary
9958 .get("total")
9959 .and_then(Value::as_u64)
9960 .ok_or_else(|| SourceError::Malformed {
9961 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9962 })
9963}
9964
9965/// One issue's own `number`.
9966///
9967/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9968/// an issue in this module asks for it. So a read of one that comes back without it, or
9969/// with something that is not an unsigned integer, is a response this source cannot read —
9970/// absence here is **not** "this issue has no number". A draft is the content that has
9971/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9972/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9973fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9974 issue
9975 .get("number")
9976 .and_then(Value::as_u64)
9977 .ok_or_else(|| SourceError::Malformed {
9978 message: "GitHub issue number is missing or is not an unsigned integer".into(),
9979 })
9980}
9981
9982/// The `number` a creating mutation answered with, and `None` when it answered without one;
9983/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9984fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9985 match created.get("number") {
9986 None | Some(Value::Null) => Ok(None),
9987 Some(value) => value
9988 .as_u64()
9989 .map(Some)
9990 .ok_or_else(|| SourceError::Malformed {
9991 message: "GitHub created issue number is not an unsigned integer".into(),
9992 }),
9993 }
9994}
9995
9996fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9997 value
9998 .get(field)
9999 .and_then(Value::as_str)
10000 .ok_or_else(|| SourceError::Malformed {
10001 message: format!("GitHub response is missing string field {field}"),
10002 })
10003}
10004
10005fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10006 let found = required_str(value, field)?;
10007 if found.trim().is_empty() {
10008 return Err(SourceError::Malformed {
10009 message: format!("GitHub response has blank string field {field}"),
10010 });
10011 }
10012 Ok(found)
10013}
10014
10015/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
10016/// needs one — Linear spells them too, in its own description field.
10017///
10018/// Restated rather than shared, because a plugin crate depends on the contract crate and
10019/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
10020/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
10021/// source round-trips its own writes perfectly well under its own spelling.
10022const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
10023const METADATA_CLOSE: &str = "\n-->";
10024
10025/// What the composer puts between a non-empty visible body and the slot, and the one thing
10026/// the parser takes off the visible body when it takes the slot off — exactly once, so every
10027/// other trailing byte of the body comes back as it was written.
10028// 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.
10029const METADATA_SEPARATOR: &str = "\n\n";
10030
10031/// The visible body and the metadata slot at the end of it.
10032///
10033/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
10034/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
10035/// own content and is left alone. The visible body is everything before the slot less the
10036/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
10037fn metadata_body(
10038 body: Option<String>,
10039) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
10040 let Some(body) = body else {
10041 return Ok((None, BTreeMap::new()));
10042 };
10043 let Some(slot) = slot_span(&body)? else {
10044 return Ok((Some(body), BTreeMap::new()));
10045 };
10046 let metadata =
10047 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
10048 SourceError::Malformed {
10049 message: format!(
10050 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
10051 ),
10052 }
10053 })?;
10054 let before = &body[..slot.start];
10055 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
10056 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
10057}
10058
10059/// Where the metadata slot sits in one body, as byte offsets into it.
10060struct SlotSpan {
10061 /// Where [`METADATA_OPEN`] begins.
10062 start: usize,
10063 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
10064 encoded_start: usize,
10065 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
10066 encoded_end: usize,
10067 /// Just past [`METADATA_CLOSE`].
10068 end: usize,
10069}
10070
10071/// The slot at the very end of `body`, or `None` when it has none.
10072///
10073/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
10074/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
10075/// slot.
10076fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
10077 let Some(start) = body.rfind(METADATA_OPEN) else {
10078 return Ok(None);
10079 };
10080 let encoded_start = start + METADATA_OPEN.len();
10081 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
10082 return Err(SourceError::Malformed {
10083 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
10084 });
10085 };
10086 let encoded_end = encoded_start + relative_end;
10087 let end = encoded_end + METADATA_CLOSE.len();
10088 if !body[end..].trim().is_empty() {
10089 return Ok(None);
10090 }
10091 Ok(Some(SlotSpan {
10092 start,
10093 encoded_start,
10094 encoded_end,
10095 end,
10096 }))
10097}
10098
10099/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10100/// slot as it was.
10101///
10102/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10103/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10104/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10105/// or alone in an empty body — and a body with no slot that is given no metadata is
10106/// returned as it is.
10107fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10108 let encoded = if metadata.is_empty() {
10109 None
10110 } else {
10111 Some(
10112 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10113 message: error.to_string(),
10114 })?,
10115 )
10116 };
10117 Ok(match (slot_span(body)?, encoded) {
10118 (Some(slot), Some(encoded)) => format!(
10119 "{}{encoded}{}",
10120 &body[..slot.encoded_start],
10121 &body[slot.encoded_end..]
10122 ),
10123 (Some(slot), None) => {
10124 let before = &body[..slot.start];
10125 format!(
10126 "{}{}",
10127 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10128 &body[slot.end..]
10129 )
10130 }
10131 (None, None) => body.to_owned(),
10132 (None, Some(encoded)) if body.is_empty() => {
10133 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10134 }
10135 (None, Some(encoded)) => {
10136 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10137 }
10138 })
10139}
10140
10141/// `body` with everything before its metadata slot replaced by `content`, and the slot
10142/// itself kept byte for byte.
10143///
10144/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10145/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10146/// `content` is empty — so a read of the result reports `content` as the visible body and
10147/// the slot's metadata exactly as it was.
10148fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10149 let Some(slot) = slot_span(body)? else {
10150 return Ok(content.to_owned());
10151 };
10152 let kept = &body[slot.start..];
10153 Ok(if content.is_empty() {
10154 kept.to_owned()
10155 } else {
10156 format!("{content}{METADATA_SEPARATOR}{kept}")
10157 })
10158}
10159
10160/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10161fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10162 if entries.is_empty() {
10163 metadata.remove(key);
10164 } else {
10165 metadata.insert(
10166 key.to_owned(),
10167 Value::Array(
10168 entries
10169 .iter()
10170 .map(|entry| Value::String(entry.as_str().to_owned()))
10171 .collect(),
10172 ),
10173 );
10174 }
10175}
10176
10177fn compose_body(
10178 content: Option<&str>,
10179 metadata: &BTreeMap<String, Value>,
10180) -> Result<Option<String>, SourceError> {
10181 let visible = content.unwrap_or_default();
10182 if metadata.is_empty() {
10183 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10184 }
10185 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10186 message: error.to_string(),
10187 })?;
10188 Ok(Some(if visible.is_empty() {
10189 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10190 } else {
10191 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10192 }))
10193}
10194
10195fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10196 value
10197 .get(field)
10198 .and_then(Value::as_bool)
10199 .ok_or_else(|| SourceError::Malformed {
10200 message: format!("GitHub response is missing boolean field {field}"),
10201 })
10202}
10203fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10204 match value.get(field) {
10205 None | Some(Value::Null) => Ok(None),
10206 Some(value) => value
10207 .as_str()
10208 .map(Some)
10209 .ok_or_else(|| SourceError::Malformed {
10210 message: format!("GitHub response field {field} is not a string or null"),
10211 }),
10212 }
10213}
10214fn optional_nodes<'a>(
10215 connection: Option<&'a Value>,
10216 name: &str,
10217) -> Result<Option<&'a Vec<Value>>, SourceError> {
10218 match connection {
10219 None | Some(Value::Null) => Ok(None),
10220 Some(value) => value
10221 .get("nodes")
10222 .and_then(Value::as_array)
10223 .map(Some)
10224 .ok_or_else(|| SourceError::Malformed {
10225 message: format!("GitHub {name}.nodes is not an array"),
10226 }),
10227 }
10228}
10229fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10230 let page_info = connection
10231 .get("pageInfo")
10232 .ok_or_else(|| SourceError::Malformed {
10233 message: format!("GitHub {name} has no pageInfo"),
10234 })?;
10235 if required_bool(page_info, "hasNextPage")? {
10236 return Err(SourceError::Malformed {
10237 message: format!(
10238 "GitHub {name} exceeds the supported nested connection size of {size}"
10239 ),
10240 });
10241 }
10242 Ok(())
10243}
10244fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10245 optional_str(value, field)?
10246 .map(|timestamp| {
10247 timestamp.parse().map_err(|error| SourceError::Malformed {
10248 message: format!("GitHub response field {field} is not a timestamp: {error}"),
10249 })
10250 })
10251 .transpose()
10252}
10253fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10254 if page.limit == 0 {
10255 Err(SourceError::Config {
10256 message: "page limit must be at least 1".into(),
10257 })
10258 } else {
10259 Ok(())
10260 }
10261}
10262fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10263 let page = connection
10264 .get("pageInfo")
10265 .filter(|value| value.is_object())
10266 .ok_or_else(|| SourceError::Malformed {
10267 message: "GitHub connection is missing pageInfo".into(),
10268 })?;
10269 if required_bool(page, "hasNextPage")? {
10270 let cursor = required_str(page, "endCursor")?;
10271 validate_cursor_progress(None, cursor)?;
10272 Ok(Some(Cursor(cursor.into())))
10273 } else {
10274 Ok(None)
10275 }
10276}
10277fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10278 if next.is_empty() || previous == Some(next) {
10279 Err(SourceError::Malformed {
10280 message: "GitHub pagination cursor is empty or did not advance".into(),
10281 })
10282 } else {
10283 Ok(())
10284 }
10285}
10286/// The version of this plugin's opaque narrowing-search cursor.
10287pub const SEARCH_CURSOR_VERSION: u32 = 4;
10288
10289#[derive(Serialize, Deserialize)]
10290#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10291enum SearchConnection {
10292 Initial {},
10293 Continuing { after: Cursor },
10294 Exhausted {},
10295}
10296impl SearchConnection {
10297 fn after(&self) -> Option<&str> {
10298 match self {
10299 Self::Continuing { after } => Some(&after.0),
10300 _ => None,
10301 }
10302 }
10303 fn exhausted(&self) -> bool {
10304 matches!(self, Self::Exhausted { .. })
10305 }
10306 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10307 /// plugin could have handed out: a page is resumed only part of the way through it — an
10308 /// offset of a whole page or more would skip rows nobody was given — an initial page
10309 /// only once some of it was handed out, and an exhausted connection has no page to be
10310 /// part of the way through.
10311 fn valid_resume(&self, offset: usize) -> bool {
10312 let within = offset < SEARCH_PAGE_SIZE as usize;
10313 match self {
10314 Self::Initial { .. } => offset > 0 && within,
10315 Self::Continuing { after } => !after.0.is_empty() && within,
10316 Self::Exhausted { .. } => offset == 0,
10317 }
10318 }
10319}
10320
10321/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10322#[derive(Serialize, Deserialize)]
10323#[serde(deny_unknown_fields)]
10324struct SearchPosition {
10325 version: u32,
10326 connection: SearchConnection,
10327 /// How many rows of the page `connection` starts were already handed out.
10328 #[serde(default, skip_serializing_if = "is_zero")]
10329 offset: usize,
10330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10331 seen: Vec<NativeId>,
10332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10333 own: Vec<NativeId>,
10334}
10335impl Default for SearchPosition {
10336 fn default() -> Self {
10337 Self {
10338 version: SEARCH_CURSOR_VERSION,
10339 connection: SearchConnection::Initial {},
10340 offset: 0,
10341 seen: Vec::new(),
10342 own: Vec::new(),
10343 }
10344 }
10345}
10346
10347fn is_zero(offset: &usize) -> bool {
10348 *offset == 0
10349}
10350
10351fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10352 cursor.map_or(Ok(0), |c| {
10353 c.0.parse().map_err(|_| SourceError::Config {
10354 message: "page cursor is invalid".into(),
10355 })
10356 })
10357}
10358fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10359 if offset > items.len() {
10360 return Page::last(vec![]);
10361 }
10362 let tail = items.split_off(offset);
10363 let mut selected = tail;
10364 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10365 selected.truncate(limit);
10366 Page {
10367 items: selected,
10368 next,
10369 }
10370}
10371
10372/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10373/// itself, for the two things no journey can observe.
10374///
10375/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
10376/// with and without the call, that a settlement, a board listing and a metadata search each
10377/// read afresh after it — the resolved records, the written-item overlay, the board and its
10378/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10379/// because a status write naming an option a person deleted is refused the same whether or
10380/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10381/// while one of its locks is held. So these assert those directly, and every other holder
10382/// beside them so a holder added later without a clear in the call fails here.
10383#[cfg(test)]
10384mod end_command_tests {
10385 use super::*;
10386
10387 struct Token;
10388
10389 impl SecretResolver for Token {
10390 fn get(&self, var: &str) -> Option<SecretString> {
10391 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10392 }
10393 }
10394
10395 fn source() -> GitHubProjectsSource {
10396 let config = serde_json::from_value(json!({
10397 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10398 // Nothing here is sent: the source is only built and its state inspected.
10399 "endpoint": "http://127.0.0.1:9/graphql",
10400 }))
10401 .expect("a usable configuration");
10402 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10403 .expect("the source builds")
10404 }
10405
10406 /// One issue as a board read answers it.
10407 fn resolved(source: &GitHubProjectsSource) -> Resolved {
10408 source
10409 .resolve(&json!({
10410 "id": "ITEM-1",
10411 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10412 "body": "what a person may since have edited", "state": "OPEN",
10413 "stateReason": null, "url": null, "number": 1,
10414 "subIssuesSummary": {"total": 0},
10415 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10416 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10417 }))
10418 .expect("the item reads")
10419 .expect("an issue")
10420 }
10421
10422 /// Hold something in every holder the call clears, and the repository id it keeps.
10423 fn fill(source: &GitHubProjectsSource) {
10424 let item = resolved(source);
10425 source.created.lock().unwrap().push(item.clone());
10426 source.updated.lock().unwrap().push(item.clone());
10427 *source.board_cache.lock().unwrap() = Some(Board {
10428 id: "PVT-board".into(),
10429 fields: json!({"nodes": []}),
10430 items: vec![item.clone()],
10431 });
10432 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10433 source
10434 .narrowed_cache
10435 .lock()
10436 .unwrap()
10437 .insert("status:todo".into(), vec![item.clone()]);
10438 source
10439 .search_next
10440 .lock()
10441 .unwrap()
10442 .insert("status:todo".into(), Some("cursor".into()));
10443 source
10444 .resolved_cache
10445 .lock()
10446 .unwrap()
10447 .insert(item.id.clone(), item);
10448 *source.fields_cache.lock().unwrap() = Some(BoardFields {
10449 id: BoardId::parse("PVT-board").unwrap(),
10450 fields: json!({"nodes": []}),
10451 });
10452 source
10453 .repository_cache
10454 .lock()
10455 .unwrap()
10456 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10457 }
10458
10459 fn assert_dropped(source: &GitHubProjectsSource) {
10460 assert!(source.created().unwrap().is_empty(), "created");
10461 assert!(source.updated().unwrap().is_empty(), "updated");
10462 assert!(source.board_cache().unwrap().is_none(), "board");
10463 assert!(source.search_cache.lock().unwrap().is_none(), "search");
10464 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10465 assert!(
10466 source.search_next.lock().unwrap().is_empty(),
10467 "search paging"
10468 );
10469 assert!(
10470 source.resolved_cache().unwrap().is_empty(),
10471 "resolved records"
10472 );
10473 assert!(source.fields_cache().unwrap().is_none(), "board fields");
10474 assert_eq!(
10475 source.repository_cache().unwrap().len(),
10476 1,
10477 "a repository's node id stays valid and is kept"
10478 );
10479 }
10480
10481 fn end(source: &GitHubProjectsSource) {
10482 tokio::runtime::Builder::new_current_thread()
10483 .build()
10484 .unwrap()
10485 .block_on(source.end_command())
10486 .expect("the command ends");
10487 }
10488
10489 #[test]
10490 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10491 let source = source();
10492 fill(&source);
10493 end(&source);
10494 assert_dropped(&source);
10495 }
10496
10497 #[test]
10498 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10499 fn poison<T: Send>(held: &Mutex<T>) {
10500 std::thread::scope(|scope| {
10501 let _ = scope
10502 .spawn(|| {
10503 let _guard = held.lock().unwrap();
10504 panic!("a failure while the lock is held");
10505 })
10506 .join();
10507 });
10508 assert!(held.is_poisoned());
10509 }
10510 let source = source();
10511 fill(&source);
10512 poison(&source.created);
10513 poison(&source.updated);
10514 poison(&source.board_cache);
10515 poison(&source.search_cache);
10516 poison(&source.narrowed_cache);
10517 poison(&source.search_next);
10518 poison(&source.resolved_cache);
10519 poison(&source.fields_cache);
10520 assert!(
10521 source.resolved_cache().is_err(),
10522 "a poisoned lock is refused before the call"
10523 );
10524 end(&source);
10525 assert_dropped(&source);
10526 }
10527}