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/tests/e2e/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//! | `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. |
138//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
139//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so neither `ProjectV2.items` nor any issue the search did not name is read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
140//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
141//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
142//! | `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. |
143//! | `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. |
144//! | `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. |
145//! | `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. |
146//! | `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. |
147//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
148//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
149//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
150//!
151//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
152//! source has documents and that its tasks have comments, both of which hold — and the three
153//! facts behind the uniform `Native` on the
154//! predicates beside it are recorded below rather than re-derived, because a reader who
155//! takes `Native` to mean *the remote service filters* will read that uniformity as a
156//! lie.
157//!
158//! First, the plugin contract defines `Support::Native` as *the source applies this
159//! predicate itself*, and says nothing about where it applies it. What the declaration
160//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
161//! — so that the engine may push it down and apply nothing of its own.
162//!
163//! Second, this source can keep that promise for every predicate at no additional API
164//! cost, because whichever of the reads below answers a query has already read every
165//! candidate that query will return before it filters anything. Filtering those items is
166//! in-process work over data already in hand.
167//!
168//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
169//! applied in process over what that question returned. A project filter has a relationship — a
170//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
171//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
172//! a search for metadata values, is the board-scoped issue search carrying the text and each
173//! value as quoted phrases; an origin is the board's own field filter over its origin field
174//! beside the same search for the id. **The text search narrows, and that is this source's
175//! declared semantics:** GitHub matches whole words where the substring rule this source and
176//! the local Markdown source confirm with would match inside one, so an item holding the text
177//! only inside a longer word is never a candidate. Every item returned does contain the text.
178//! A project query's text, and a document query's scoped to no project, is that same search
179//! and narrows on the same terms, its candidates confirmed by their kind as well.
180//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
181//! so those three are applied in process over the candidates, and a query carrying none of
182//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
183//! engine compensate for work this source has already done, and declaring `projects` native
184//! while ignoring the filter (which this source once did) silently returns another project's
185//! tasks, because the engine trusts the declaration and applies nothing locally.
186//!
187//! # The three ways this source reaches an item, and what each costs
188//!
189//! A board read is charged for what its *nested* connections could return rather than for
190//! what was asked, so one whole-board read costs the same whether the question was about
191//! one project or about all of them. That is why a question about one project is never
192//! answered by reading the board:
193//!
194//! | The question | What is sent | What it costs |
195//! | --- | --- | --- |
196//! | 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 |
197//! | 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 |
198//! | 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 |
199//! | 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 |
200//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
201//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
202//! | 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 |
203//! | 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 |
204//! | 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 |
205//! | 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 |
206//! | 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 |
207//! | 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 |
208//!
209//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
210//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
211//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
212//! equal to declared points equal to the row. They include the origin lookup and the
213//! field/repository discovery a create needs. A bound re-copy changes status, priority,
214//! content and metadata; comment recount means a subsequent detail read. Each request here
215//! costs one declared point. A membership beyond the embedded page can additionally require
216//! the one-point membership recovery described above. A bound re-copy of a task filed under a
217//! project adds one read, the engine confirming that project's link by its own id once per
218//! command; and the same-source far ends a write newly names — those that do not already block
219//! the item, whose own read answered for them — are read together by their own ids,
220//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
221//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
222//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
223//!
224//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
225//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
226//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
227//! holds both halves.
228//!
229//! **An existing item is written body last.** A bound re-copy and a `task update` send its
230//! board fields first — the `Status` option and the `Priority` together, in one request — then
231//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
232//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
233//! undoing an earlier field when a later one fails, so that order is what makes a write
234//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
235//! the one piece of metadata written before the body, an origin a copy re-points, is put back
236//! when a later write is refused — and when putting it back is refused too, the write's own
237//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
238//! of those writes in turn, whole and as one aliased field failing after the one before it.
239//!
240//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
241//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
242//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
243//!
244//! - **A board is accepted at creation but its item is not answered, so a create still files
245//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
246//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
247//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
248//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
249//! against a real board on 2026-10-01 with a create sending the board there and reading the
250//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
251//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
252//! refused "Content already exists in this project" — GitHub had filed the issue after
253//! answering, and refuses a second filing rather than answering with the item it holds. A
254//! create therefore sends no `projectV2Ids` and files the issue with
255//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
256//! real is the read before it: the board's fields and the repository's id together, in
257//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
258//! - **A comment still reads its target first, so a comment is 2 requests.**
259//! `AddCommentInput.subjectId: ID!` is declared there with
260//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
261//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
262//! project's issue, a document's issue, an issue on no board of this source and a pull
263//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
264//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
265//! refuses those by name.
266//!
267//! | Verb | Requests / points | Documents |
268//! | --- | --- | --- |
269//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
270//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
271//! | 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 |
272//! | 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 |
273//! | 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 |
274//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
275//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
276//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
277//! | recount | 1 | ISSUE_DETAIL |
278//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
279//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
280//! | content | 2 | ISSUE, UPDATE_ISSUE |
281//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
282//! | 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 |
283//! | record only | 1 | ISSUE |
284//!
285//! <!-- github-search-paging:start -->
286//! Board-scoped text, metadata, project-name and comment-activity searches send every
287//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
288//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
289//! needs rows. A page is never resized to the rows still needed: GitHub orders one
290//! search differently at different page sizes, so one fixed size makes a paged walk
291//! send exactly the requests one whole read sends, and the answer's order is the order
292//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
293//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
294//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
295//! returned and fetched pages: a limit is sliced from the pages it needs, and local
296//! confirmation can require more candidates than matching rows. Walking all pages
297//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
298//! cursor and how far into that page the last answer stopped, and resumes in the same
299//! process or a new one, without duplicates or gaps. It carries no rows: one process
300//! sends each page's search once, and a new process re-reads only the page it resumes
301//! in, then sends a further page once, never as a re-read, only when its limit still
302//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
303//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
304//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
305//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
306//! not required to include the original process's writes still omitted by the index.
307//! <!-- github-search-paging:end -->
308//!
309//! The board half of an issue — its board item's id, its `Status` option and this
310//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
311//! an item reached any of those ways resolves through the same
312//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
313//! same status, the same labels and the same qualified id. That connection comes back a
314//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
315//! on the page in hand and — only if that page reports more of the connection — in the
316//! last row's read of that one issue's memberships, resumed from the page's own cursor and
317//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
318//! report, which is what keeps an id naming another repository's issue from being answered
319//! as an item of this board; and because the page is where the search starts rather than
320//! where it ends, that answer is one about a connection read to exhaustion and never about
321//! an unread page. Nothing costs the extra read but an issue on more boards than a page
322//! holds: an issue this board really does not hold reports no next page, so its
323//! memberships are already exhausted where they arrived.
324//!
325//! **No document here selects the board's own `Labels` field, and nothing is lost by
326//! that.** An item's labels are read from its content alone, wherever that content is
327//! reached: the three documents above select `Issue.labels` on the fragment, and
328//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
329//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
330//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
331//! create one, and `ProjectV2FieldValue` — the whole of what
332//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
333//! it from the content, for every content type it exists on, and there is nothing it can
334//! hold that the content does not already say: for an `Issue` it *is* that issue's own
335//! labels, so selecting it beside them unions a set with itself.
336//!
337//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
338//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
339//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
340//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
341//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
342//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
343//! label set, and that set is the fixture issue's own, by
344//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
345//! item whose content is a draft reports an empty set, by
346//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
347//! selection is held over [`graphql::DOCUMENTS`] by
348//! `no_document_selects_the_boards_own_labels_field`.
349//!
350//! The whole-board row is still the board's own item connection, and deliberately: a
351//! **draft** board item is not an issue, so no search can list one, and the reads that have
352//! to answer for the whole board are the ones whose cost is the board's size anyway.
353//!
354//! **A question about one item this source already names by id never lists the board.**
355//! Whether that item is on this board, and what its board fields are, is answered by reading
356//! that item — its own `Issue.projectItems`, walked to exhaustion by
357//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
358//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
359//! holds. That covers a write's destination, the project a new item is filed under, a
360//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
361//! and the delete that takes back an item a copy made. What such a write needs of the board
362//! and the item does not carry — the board's id, the `Status` and origin field definitions —
363//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
364//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
365//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
366//! minutes. Scanning this host's 842-item board has refused a document copy and an update
367//! even though the items' own reads named that board. A scan there gives the wrong answer
368//! as well as paying for every page. So a `board.items` lookup does not belong on any of
369//! those paths.
370//!
371//! **What a read may return is capped too, and that cap is on the document rather than on
372//! the board.** GitHub limits the number of nodes **one query may return** to
373//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
374//! an error naming the connection the count crossed at, not a slow or a partial result.
375//! Every board this source reads is refused the same way, so no board is too big for these
376//! documents and none is small enough to save one that is over.
377//!
378//! The count is arithmetic over the document's own text: each connection contributes the
379//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
380//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
381//! restate them — `github-graphql-node-count` implements them, and
382//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
383//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
384//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
385//! same text on every run and fails naming any that reaches the limit — so a connection
386//! added to a shared fragment is caught there rather than by GitHub.
387//!
388//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
389//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
390//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
391//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
392//! effectively squared there, which is why it is the one the limit is most sensitive to.
393//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
394//! page of memberships misses is recovered by one further read rather than refused, so it
395//! buys a bound every read pays for at the price of a request only a multi-board issue
396//! pays.
397//!
398//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
399//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
400//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
401//! `cost` is rate-limit points, metered per hour across everything one credential does; it
402//! is what the two limiters [`Limiter`] tells apart meter, and a document under
403//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
404//! that second number, and `tests/point_cost.rs` pins every document in
405//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
406//! one under, the pin itself is the check. The credentialed lane reconciles both figures
407//! against GitHub's own, off a probe it already sends.
408//!
409//! **What is pinned that way is a per-document price and never a session's.** The record in
410//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
411//! **requests** and **worst-case nodes** — and neither is points. What one whole session
412//! consumes of the hourly point allowance is observable only from a credentialed run's own
413//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
414//! and what `tests/live.rs` prints at the end of every run.
415//!
416//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
417//!
418//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
419//! enumerations of a board can supply one alone.** Resolving a node id is strongly
420//! consistent, so a read by id and a project's own sub-issues are already current. The
421//! other two are not, and they are behind by different amounts and in different directions:
422//!
423//! - GitHub's **issue search** is an index and answers a write made moments ago with the
424//! value from before it — usually for a second or two.
425//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
426//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
427//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
428//! for *minutes*, while `Issue.projectItems` names the same membership at once.
429//!
430//! That second one is a measurement rather than a caution. This repository's own
431//! credentialed journey writes a project and waits for the board to report it, then writes a
432//! task and waits for the same thing seconds later on the same board: the project wait is
433//! answered through the search and converged in two or three attempts in each of three runs,
434//! and the task wait is answered through `ProjectV2.items` and converged in none of them
435//! inside thirty. Separately, an item added to a second and larger board was read back by
436//! `Issue.projectItems` on that board's own id while every one of that connection's nine
437//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
438//! through the lagging one alone is what had a board read deny an issue that had certainly
439//! landed on it.
440//!
441//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
442//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
443//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
444//! search reports what the projection is behind on. What closes the last
445//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
446//! source answers is completed with what this process itself wrote, so an item created
447//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
448//! nothing is written down, and the record dies with the process. **A wait that has to
449//! observe GitHub's own data cannot be answered from that record** — which is why the
450//! credentialed journey asks through a source built afresh, and why the union above rather
451//! than a longer wait is what makes such a wait converge.
452//!
453//! **A narrowed read is the same bargain, stated for each of the three predicates it
454//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
455//! than walking the board, and every such answer is completed with what this process wrote —
456//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
457//! each filtered by the same predicates as the rest — so an item this command wrote a moment
458//! ago is returned by a query that matches it whether or not the index has caught up. An item
459//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
460//! consistent. What is left is stated rather than papered over:
461//!
462//! | Read | Finds | Behind by |
463//! | --- | --- | --- |
464//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
465//! | 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 |
466//! | 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 |
467//! | origin, third read | this process's own writes | nothing |
468//!
469//! So an origin carrier another process added within the last second or two, before either
470//! index has it, can be missing from an origin query, and one written by the release before
471//! this one — its origin in the field alone — can be missing for as long as the board's own
472//! item connection is behind on it. A copy that must not duplicate its own earlier write
473//! relies on the link it records, not on either index. **A board draft is not an issue**, so
474//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
475//! lists one, the origin lookup drops any the board's own field filter names, and one this
476//! process wrote is not added back either.
477//!
478//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
479//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
480//! body's metadata slot, so the issue search can find it in seconds. The field is
481//! authoritative: this source reads an item's origin from the field alone, so a slot that
482//! disagrees with it, or holds one where the field holds none, is never read as a second
483//! origin — and the release before this one reads the slot, drops that key's copy for the
484//! field's, and sees the same one origin.
485//!
486//! Filtering happens before paging, so a page of a filtered result is a page of the
487//! survivors rather than the survivors of a page. Label matching and the substring rule a
488//! text candidate is confirmed by answer the same question the same way the local Markdown
489//! source's do; which candidates a text search has to confirm is GitHub's word match, which
490//! is the one place the two sources can answer the same text differently.
491//!
492//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
493//! has one source, `capabilities`, and the note above is the reasoning behind it rather
494//! than a second copy of it: without the three facts recorded here a reader takes the
495//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
496//! crate's own capabilities test, which pins every field of it against a fully spelled-out
497//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
498//! fails to compile there rather than going unasserted. -->
499//! The fixture-server tests above run wherever this crate is selected; the credentialed
500//! lane runs in the same required check, beside them, and can fail it — it verifies the
501//! 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,
502//! one filed under neither, a label on one of the three and a closed status on another —
503//! because that shape is what tells an honoured predicate from an ignored one: a board
504//! holding a single project answers a project filter the same way whether or not this
505//! source applies it, which is exactly how the defect above went unseen.
506//!
507//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
508//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
509//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
510//! nominated is what keeps a credentialed write lane off a board and a repository nobody
511//! nominated; it never asks GitHub which project was updated most recently. Before it
512//! starts, the lane also clears any item titled — and any repository label named — the way
513//! it titles and names its own artifacts, which is self-healing after an interrupted run:
514//! a process killed between its writes and its cleanup leaves artifacts the next run
515//! removes.
516//!
517//! # What a session of requests costs, and where the report is
518//!
519//! This source records **every** request it sends into [`accounting::Accounting`], at
520//! `send_once` — the one place a request leaves this crate, which is why a read path added
521//! later is counted without anybody remembering to count it. That is the whole of what this
522//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
523//! session's spend is arrived at, and what it deliberately does not know are set out.
524//!
525//! What one whole session of the live journey costs, counted that way against this crate's
526//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
527//! reduction it came out of, and with what it does and does not say about rate-limit points.
528//!
529//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
530//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
531//! code path — no environment variable, no feature, no build configuration — because an
532//! instrument nobody switches on measures nothing, and
533//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
534//! source's counts the whole session rather than this source's share. The credentialed lane
535//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
536//! passed or failed.
537//!
538//! **A live session refuses to start unless the account can afford it.** Before it does any
539//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
540//! GitHub documents as not counting against the REST rate limit and which answers both of
541//! its budgets at once — and starts only if, for each of them, what remains minus this
542//! session's estimated cost is still at least
543//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
544//! allowance. A session that cannot **declines**: it did not run, so it is
545//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
546//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
547//! waiting for the budget to come back. The estimate is derived offline from
548//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
549//! which is also where the published rule that model rests on is cited; the accounting
550//! above records the gate's own read like any other request, and
551//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
552//!
553//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
554//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
555//! text, which is what lets it run on every platform and on a pull request from a fork with
556//! no credential — and that is what actually stops a regression merging. But an offline
557//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
558//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
559//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
560//! number of nodes this query may return"* and whose `cost` is what that document would
561//! spend, both for a document **without executing it**, and the lane asks it for every query
562//! document this source sends, under the largest bindings this source sends, and fails when
563//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
564//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
565//! one; the offline pins still cover it. It records what those calls reported about the
566//! account's own allowance, because whether asking is free is a thing to observe rather than
567//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
568//! and `cost` is metered against an hourly allowance the accounting above reads off a
569//! credentialed run's own response headers.
570//!
571//! **GitHub has two rate limiters and this source is refused by both, so nothing here
572//! treats them as one thing.** The primary budget is the hourly allowance `gh api
573//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
574//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
575//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
576//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
577//! one part of.
578#![deny(missing_docs)]
579
580use std::collections::BTreeMap;
581use std::sync::{Arc, Mutex};
582use std::time::{Duration, Instant};
583
584use chrono::{DateTime, Utc};
585use onetaskgraph_plugin_api::{
586 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
587 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
588 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
589 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
590 SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
591 TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
592 UnmappedStatus, UpdatedField, WriteSupport,
593};
594use reqwest::{Client, StatusCode, Url};
595use schemars::{Schema, schema_for};
596use secrecy::{ExposeSecret, SecretString};
597use serde::{Deserialize, Serialize};
598use serde_json::{Value, json};
599
600pub mod accounting;
601
602use accounting::Accounting;
603
604/// The registry name for this plugin.
605pub const KIND: &str = "github-projects";
606/// GitHub's maximum connection page size.
607pub const MAX_PAGE_SIZE: u32 = 100;
608/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
609/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
610/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
611pub const SEARCH_PAGE_SIZE: u32 = 20;
612/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
613/// its comments: the largest batch the node-count model prices at one point.
614///
615/// Each aliased item is resolved once, and what GitHub charges for it is the connections
616/// under it — its labels, its page of board memberships, the field values of each of those
617/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
618/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
619/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
620/// one point and fails if one item more would still be priced at one.
621pub const DETAIL_BATCH: usize = 24;
622
623/// The most nodes any one document this source sends may be asked to return.
624///
625/// GitHub's own published per-query ceiling, taken from
626/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
627/// workspace cannot hold a stale copy of somebody else's number. A query above it is
628/// **refused before it is executed**, whoever is asking and whatever board they are
629/// asking about — so this is a bound on the documents rather than a budget that runs out.
630///
631/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
632/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
633/// everything the credential does — two numbers against two limits, and this constant
634/// bounds only the first. The second is computed offline too, per document:
635/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
636/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
637/// lane. There is no constant like this one to hold a price under, because points are an
638/// hourly allowance rather than a per-call bound.
639///
640/// Neither is a session's price. What `session-cost.md` records of a whole session is its
641/// **requests** and its **worst-case nodes**; what a whole session spends in points is
642/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
643/// [`accounting`]. The module section on the three ways this source reaches an item says how
644/// the count is arrived at, and which of the page sizes below decide it.
645pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
646
647/// Nested connection size for the connections that hang off one item.
648///
649/// It multiplies through every document that reaches an item under a page — the count
650/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
651/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
652/// every document under these constants and fails naming any that reaches the limit, so
653/// raising this is caught there rather than by GitHub.
654const NESTED_PAGE_SIZE: u32 = 50;
655/// How many of one issue's board memberships are read when an issue is reached directly.
656///
657/// An issue reached through a search or through its own node id carries its board half in
658/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
659/// under a page of issues, so every point of it multiplies through the whole document and
660/// is paid for whether or not any issue is on a second board — which is why it is
661/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
662///
663/// **Three, because what a page misses is now recovered rather than refused**, and the
664/// recovery is what the value is chosen against. An issue whose entry for this board sits
665/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
666/// that page's own cursor — so the value trades a bound every read pays for a request only
667/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
668/// boards would pay that request *per issue*, which is order N against the one page per
669/// hundred issues a read costs today. At three it is only reached by an issue on four or
670/// more boards at once, which keeps the recovery path exceptional rather than routine for
671/// a plausible deployment.
672const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
673/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
674/// its two connections for.
675///
676/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
677/// second is a duplicate a copy already takes the first of. Both connections are walked to
678/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
679/// never what the answer is. It is small because every point of it is paid on every lookup,
680/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
681/// worst-case nodes than the one whole-board read they replaced.
682const ORIGIN_PAGE_SIZE: u32 = 3;
683
684pub use github_graphql_node_count::{NodeCountError, Variables};
685
686/// The largest value this source can bind to each page-size variable its documents name.
687///
688/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
689/// constant above it wherever a caller's own limit could reach it — `$first` at
690/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
691/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
692/// not one configuration of it, which is what makes a bound computed under it a bound on
693/// every read.
694pub fn largest_page_sizes() -> Variables {
695 Variables::from([
696 ("first".to_owned(), MAX_PAGE_SIZE),
697 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
698 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
699 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
700 ])
701}
702
703/// The most nodes `document` could be asked to return, by GitHub's published rules.
704///
705/// Computed offline from the document's own text under [`largest_page_sizes`] — no
706/// network, no credential and no schema — by
707/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
708/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
709/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
710///
711/// # Errors
712///
713/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
714/// no single operation, or binds a page size this source does not name — each of which is
715/// a defect in the document rather than a number.
716pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
717 node_count(document, &largest_page_sizes())
718}
719
720/// The most rate-limit points one call of `document` could spend, by GitHub's published
721/// rules.
722///
723/// Computed offline from the document's own text under [`largest_page_sizes`] — no
724/// network, no credential and no schema — by
725/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
726/// This is `cost`, metered **per hour** against the allowance one credential shares across
727/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
728/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
729/// under, so what `tests/point_cost.rs` does with it is pin every document in
730/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
731/// figures against GitHub's own reported `cost`.
732///
733/// # Errors
734///
735/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
736/// no single operation, or binds a page size this source does not name — each of which is
737/// a defect in the document rather than a number.
738pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
739 github_graphql_node_count::point_cost(document, &largest_page_sizes())
740}
741
742/// The most nodes `document` could be asked to return under `variables`.
743///
744/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
745/// [`accounting`] is this under the bindings one request really sent — one spelling of the
746/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
747/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
748///
749/// # Errors
750///
751/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
752/// no single operation, or binds a page size `variables` does not name.
753pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
754 github_graphql_node_count::node_count(document, variables)
755}
756
757/// The issue-title prefix that makes a board issue a document.
758///
759/// A GitHub Projects board has no document type — it holds issues — so the discriminator
760/// is the title, and this is the whole of it: an issue whose title begins with these bytes
761/// is a document and every other issue is the task or project the sub-issue rule makes it.
762///
763/// It is spelled **once**, here, and read rather than restated everywhere else — including
764/// by the shared journeys, which take it from this constant so a board fixture cannot
765/// drift from what this source reads. `docs/metadata.md` records the two consequences that
766/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
767/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
768/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
769pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
770
771/// Exact GraphQL query documents issued by this plugin.
772///
773/// Keeping the production documents here lets the pinned-schema test validate the same
774/// bytes that are sent to GitHub, rather than a test-only copy which could drift
775/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
776/// field, and its guarded caller always supplies the complete existing option set with ids.
777pub mod graphql {
778 /// The board half of one item: the field values every document here reads it from.
779 ///
780 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
781 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
782 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
783 /// *the same value*, because
784 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
785 /// one path. Three spellings of it is what would drift, so there is one.
786 ///
787 /// The `Status` option and this source's own origin text field are the whole of it. It
788 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
789 /// content, so it holds nothing the content's own `labels` do not already say, and it
790 /// would sit a label connection two page sizes deep.
791 macro_rules! board_item_values {
792 () => {
793 r#"fieldValues(first:$nestedFirst){nodes{
794 ... on ProjectV2ItemFieldSingleSelectValue{name field{
795 ... on ProjectV2SingleSelectField{id name options{id name}}
796 }}
797 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
798 }pageInfo{hasNextPage}}"#
799 };
800 }
801
802 /// Everything this source reads about one issue, wherever it reaches that issue.
803 ///
804 /// A macro rather than a constant so the three documents below can `concat!` it: one
805 /// spelling of these fields is what makes an issue read through the board-scoped
806 /// search, through its own node id, and through its project's sub-issue relationship
807 /// resolve to *the same* item, which is the whole of what
808 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
809 ///
810 /// `projectItems` is what carries the board half of an issue: the board item's own id
811 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
812 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
813 /// issue rather than on the board, which is what makes the cost of a read proportional
814 /// to what was asked for instead of to the board's size.
815 ///
816 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
817 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
818 /// not on that page: a page here is where the search for the entry starts rather than
819 /// where it ends.
820 ///
821 /// It does **not** select the board's `Labels` field value, and that is the whole of
822 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
823 /// a label connection there sits under `fieldValues` under `projectItems` under a page
824 /// of issues, spending `$nestedFirst` twice down one path, and took
825 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
826 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
827 /// above, and that connection is where every label this source reports comes from. No
828 /// document in this module selects the board field any longer, [`BOARD`] included; the
829 /// module documentation records why nothing it could have held is lost.
830 macro_rules! board_issue {
831 () => {
832 concat!(
833 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
834 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
835 projectItems(first:$boardItems){nodes{id project{id number}
836 "#,
837 board_item_values!(),
838 r#"}pageInfo{hasNextPage endCursor}}}"#
839 )
840 };
841 }
842
843 /// Every issue of one board, found by a search scoped to that board.
844 ///
845 /// This is how the projects a board holds are listed, and it selects no `items`
846 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
847 /// container walked page by page, so nothing nested inside a board item is paid for.
848 /// Which of the issues it returns is a project is then read off `parent` — GitHub
849 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
850 /// discriminator has to be applied to the field, which is a scalar on the issue and
851 /// costs nothing.
852 pub const SEARCH_ISSUES: &str = concat!(
853 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
854 search(query:$search,type:$type,first:$first,after:$after){
855 pageInfo{hasNextPage endCursor}
856 nodes{__typename ...BoardIssue}
857 }
858 }"#,
859 board_issue!()
860 );
861
862 /// What a dependency read selects of each far end: enough to say which kind of item it
863 /// is, its body included for the kind marker.
864 macro_rules! related_issue {
865 () => {
866 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
867 };
868 }
869
870 /// One issue by its own node id, which is what a qualified id names here — with what a
871 /// write of it needs and the issue does not carry in `board_issue!`: the field
872 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
873 ///
874 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
875 /// answers a write made moments ago with the value from before it, and resolving a node
876 /// id does not.
877 ///
878 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
879 /// reads it by its own id, and with them that one read answers everything the write
880 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
881 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
882 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
883 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
884 /// they sit under one item, and this read is still one point.
885 pub const ISSUE: &str = concat!(
886 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
887 node(id:$id){__typename ...BoardIssue ... on Issue{
888 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
889 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
890 ... on ProjectV2Field{__typename id name}
891 }pageInfo{hasNextPage}}}}}
892 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
893 }}
894 }"#,
895 board_issue!(),
896 related_issue!()
897 );
898
899 /// One project's tasks: the sub-issues of the issue that project is.
900 ///
901 /// The work this costs is the project's own size. Nothing about it grows as the board
902 /// gains projects, or as those projects gain tasks.
903 pub const SUB_ISSUES: &str = concat!(
904 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
905 node(id:$id){__typename
906 ... on Issue{subIssues(first:$first,after:$after){
907 pageInfo{hasNextPage endCursor}
908 nodes{__typename ...BoardIssue}
909 }}}
910 }"#,
911 board_issue!()
912 );
913
914 /// What a read of the board's own `items` selects of each item's content.
915 ///
916 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
917 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
918 /// content by one spelling.
919 macro_rules! board_item_content {
920 () => {
921 r#" content{
922 ... 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}}}
923 ... on PullRequest{__typename id}
924 ... on DraftIssue{__typename id title body createdAt updatedAt}
925 }"#
926 };
927 }
928
929 /// Reads the board's fields and one page of its items.
930 pub const BOARD: &str = concat!(
931 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
932 owner:repositoryOwner(login:$owner){
933 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
934 }
935 } fragment Board on ProjectV2 { id title
936 fields(first:$nestedFirst){nodes{
937 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
938 ... on ProjectV2Field{__typename id name}
939 }pageInfo{hasNextPage}}
940 items(first:$first,after:$after){nodes{id "#,
941 board_item_values!(),
942 board_item_content!(),
943 r#"} pageInfo{hasNextPage endCursor}}
944 }"#
945 );
946
947 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
948 /// the board.
949 ///
950 /// **`originItems`** is the board's own items narrowed by its own field filter —
951 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
952 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
953 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
954 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
955 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
956 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
957 /// fresh `addProjectV2ItemById` the way that connection does.
958 ///
959 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
960 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
961 /// indexes that comment, and the index catches up with a write in a second or two rather
962 /// than in minutes, so it finds a carrier another process wrote that the first read is
963 /// still behind on.
964 ///
965 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
966 /// — and resumes from its own cursor; a connection already walked to its end is resumed
967 /// from its last cursor, which answers an empty page. Every candidate either read returns
968 /// is confirmed against its own origin field before it is reported, so a token match of
969 /// the search or anything else the filter admits never is.
970 ///
971 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
972 /// own whole reads counts this one among them.
973 pub const ORIGIN_LOOKUP: &str = concat!(
974 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
975 originItems:repositoryOwner(login:$owner){
976 ... on ProjectV2Owner{projectV2(number:$number){
977 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
978 board_item_values!(),
979 board_item_content!(),
980 r#"} pageInfo{hasNextPage endCursor}}
981 }}
982 }
983 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
984 pageInfo{hasNextPage endCursor}
985 nodes{__typename ...BoardIssue}
986 }
987 }"#,
988 board_issue!()
989 );
990
991 /// The board's own id and field definitions, and not one of its items.
992 ///
993 /// What a write needs of the board when the item it writes does not say: the id a field
994 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
995 /// origin fields. It selects no `items`, so what it costs is the board's field list
996 /// however many items the board holds — and it decides nothing about which items those
997 /// are, which is the question a read of one item by its own id answers instead.
998 ///
999 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1000 /// board's item reads by their root counts this one among them.
1001 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1002 boardFields:repositoryOwner(login:$owner){
1003 ... on ProjectV2Owner{projectV2(number:$number){id
1004 fields(first:$nestedFirst){nodes{
1005 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1006 ... on ProjectV2Field{__typename id name}
1007 }pageInfo{hasNextPage}}
1008 }}
1009 }
1010 }"#;
1011
1012 /// One board draft by its own node id, with the board item it sits in.
1013 ///
1014 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1015 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1016 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1017 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1018 /// board listing hands it to, and nothing has to list the board to find one.
1019 pub const DRAFT: &str = concat!(
1020 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1021 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1022 projectV2Items(first:$boardItems){nodes{id project{id number}
1023 "#,
1024 board_item_values!(),
1025 r#"}pageInfo{hasNextPage endCursor}}}}
1026 }"#
1027 );
1028
1029 /// One issue's board memberships alone, walked past the page a read of it carried.
1030 ///
1031 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1032 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1033 /// boards than that page holds may have this board's entry past its end. This asks that
1034 /// one issue for its memberships and nothing else — the caller already holds the issue —
1035 /// so an answer of "this board does not hold it" is only ever given about a connection
1036 /// read to exhaustion.
1037 ///
1038 /// It selects the board item's id, its project number and the same
1039 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1040 /// very same resolver: an issue recovered this way reports the same title, the same
1041 /// status, the same labels and the same qualified id as one whose entry was on the
1042 /// page.
1043 ///
1044 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1045 /// multiplies through it and the membership connection can be walked at
1046 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1047 /// further request for any issue a person really keeps.
1048 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1049 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1050 node(id:$id){
1051 ... on Issue{projectItems(first:$first,after:$after){
1052 nodes{id project{id number}
1053 "#,
1054 board_item_values!(),
1055 r#"}
1056 pageInfo{hasNextPage endCursor}}}
1057 }
1058 }"#
1059 );
1060 /// Resolves the configured repository's node id, which creating an issue requires.
1061 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1062 /// What creating an issue needs and has not read yet: the board's own id and field
1063 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1064 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1065 ///
1066 /// Sent at the point a create knows which repository it is for, when neither half is
1067 /// already known to this process; a create needing only one of them sends that one's own
1068 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1069 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1070 /// status.
1071 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1072 boardFields:repositoryOwner(login:$owner){
1073 ... on ProjectV2Owner{projectV2(number:$number){id
1074 fields(first:$nestedFirst){nodes{
1075 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1076 ... on ProjectV2Field{__typename id name}
1077 }pageInfo{hasNextPage}}
1078 }}
1079 }
1080 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1081 }"#;
1082 /// Reads both dependency directions for one issue, with each far end's own kind — and
1083 /// the issue's own body, which is where an edge to another source is recorded, so that
1084 /// half of a dependency read needs no second read of the issue or of the board.
1085 pub const ISSUE_DEPENDENCIES: &str = concat!(
1086 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1087 ... on Issue{body
1088 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1089 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1090 }}}"#,
1091 related_issue!()
1092 );
1093 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1094 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1095 /// answered when it was.
1096 pub const CREATE_ISSUE: &str =
1097 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1098 /// Puts an existing issue on the configured board.
1099 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1100 /// Updates an issue's visible fields and its open or closed state in one call.
1101 pub const UPDATE_ISSUE: &str =
1102 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1103 /// Updates an existing draft's user-visible fields.
1104 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1105 /// Updates a text or single-select value on one project item.
1106 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}}}}}}}}"#;
1107 /// Writes up to three board fields and an optional clear in one ordered mutation.
1108 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}}}"#;
1109 /// Clears one project item's value of one field, which is what a `none` priority is.
1110 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}}}}}}}}"#;
1111 /// Creates one single-select field with its options. Only the guarded field setup may use
1112 /// this document, and only for a field the board lacks.
1113 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1114 /// Replaces a single-select field's options. Only the guarded field setup — the
1115 /// `status-options` and `fields` operations — may use this document, because GitHub
1116 /// treats the input as the complete option list.
1117 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1118 /// A fresh snapshot of the Status field and every board item's assignment.
1119 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}}}}}}"#;
1120 /// Files one issue under another as a sub-issue, which is what project membership is.
1121 pub const ADD_SUB_ISSUE: &str =
1122 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1123 /// Takes one issue back out of its parent.
1124 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1125 /// Adds GitHub's native issue blocked-by relationship.
1126 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1127 /// Removes one native issue blocked-by relationship.
1128 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1129 /// Deletes one issue, which takes its board item with it.
1130 ///
1131 /// The engine sends this in one situation only: undoing a copy that could not finish,
1132 /// over the items that same copy created. Deleting the issue removes the board item
1133 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1134 pub const DELETE_ISSUE: &str =
1135 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1136
1137 /// Everything this source reads about one issue comment, wherever it reaches one.
1138 ///
1139 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1140 /// and a comment just edited are handed to one mapper, so they are selected by one
1141 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1142 /// longer exists, and `login` is the one member every kind of actor carries.
1143 macro_rules! issue_comment {
1144 () => {
1145 "id author{login} createdAt updatedAt body url"
1146 };
1147 }
1148
1149 /// One task's comments: a page of its issue's own `comments` connection.
1150 ///
1151 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1152 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1153 /// list every time somebody edited it; left unordered the connection answers in the order
1154 /// the comments were written, which is the order GitHub documents for the same collection
1155 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1156 /// node count and the caller's own page size is pushed straight down.
1157 pub const ISSUE_COMMENTS: &str = concat!(
1158 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1159 issue_comment!(),
1160 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1161 );
1162 /// One issue by its own node id, with a page of its comments: what `task show` and a
1163 /// comment listing read, in one request.
1164 ///
1165 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1166 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1167 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1168 /// connection there would multiply through both of those documents' price, and neither
1169 /// needs one.
1170 pub const ISSUE_DETAIL: &str = concat!(
1171 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1172 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173 issue_comment!(),
1174 r#"}pageInfo{hasNextPage endCursor}}}}
1175 }"#,
1176 board_issue!()
1177 );
1178
1179 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1180 /// page of its comments when `$comments` asks for them.
1181 macro_rules! issue_details_alias {
1182 ($n:literal) => {
1183 concat!(
1184 "\n i",
1185 stringify!($n),
1186 ":node(id:$id",
1187 stringify!($n),
1188 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1189 issue_comment!(),
1190 "}pageInfo{hasNextPage endCursor}}}}"
1191 )
1192 };
1193 }
1194
1195 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1196 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1197 ///
1198 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1199 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1200 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1201 /// neither — so every connection under it would be priced at nothing and the pin in
1202 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1203 /// one-item read the model already prices, so the batch costs what its aliases cost.
1204 ///
1205 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1206 /// slots it has no item for to the last item it does, and reads that item again; the
1207 /// price is the document's, whatever its variables, so a short batch costs what a full
1208 /// one does and nothing more.
1209 pub const ISSUE_DETAILS: &str = concat!(
1210 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!){"#,
1211 issue_details_alias!(0),
1212 issue_details_alias!(1),
1213 issue_details_alias!(2),
1214 issue_details_alias!(3),
1215 issue_details_alias!(4),
1216 issue_details_alias!(5),
1217 issue_details_alias!(6),
1218 issue_details_alias!(7),
1219 issue_details_alias!(8),
1220 issue_details_alias!(9),
1221 issue_details_alias!(10),
1222 issue_details_alias!(11),
1223 issue_details_alias!(12),
1224 issue_details_alias!(13),
1225 issue_details_alias!(14),
1226 issue_details_alias!(15),
1227 issue_details_alias!(16),
1228 issue_details_alias!(17),
1229 issue_details_alias!(18),
1230 issue_details_alias!(19),
1231 issue_details_alias!(20),
1232 issue_details_alias!(21),
1233 issue_details_alias!(22),
1234 issue_details_alias!(23),
1235 "\n }",
1236 board_issue!()
1237 );
1238
1239 /// Which issue one comment is on, read before that comment is edited or removed.
1240 ///
1241 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1242 /// comment id given against the wrong task would change a comment on another issue.
1243 pub const COMMENT_ISSUE: &str =
1244 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1245 /// Adds one comment to an issue, signed as the account the token belongs to.
1246 pub const ADD_COMMENT: &str = concat!(
1247 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1248 issue_comment!(),
1249 r#"}}}}"#
1250 );
1251 /// Replaces the body of one issue comment.
1252 pub const UPDATE_COMMENT: &str = concat!(
1253 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1254 issue_comment!(),
1255 r#"}}}"#
1256 );
1257 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1258 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1259
1260 /// Every document above, with what this source is doing when it sends one.
1261 ///
1262 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1263 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1264 /// document added later with "talking to GitHub" and never say so.
1265 ///
1266 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1267 /// const` here that this list omits, so the two cannot part — which is the same guard
1268 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1269 pub const DOCUMENTS: [(&str, &str); 33] = [
1270 (SEARCH_ISSUES, "searching this board's issues"),
1271 (ISSUE, "reading one issue"),
1272 (
1273 ISSUE_BOARD_ITEMS,
1274 "reading one issue's board memberships past the page it came with",
1275 ),
1276 (SUB_ISSUES, "reading a project's tasks"),
1277 (BOARD, "reading the board"),
1278 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1279 (BOARD_FIELDS, "reading the board's fields"),
1280 (DRAFT, "reading one draft"),
1281 (REPOSITORY, "reading the destination repository"),
1282 (
1283 CREATION_CONTEXT,
1284 "reading the board's fields and the destination repository",
1285 ),
1286 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1287 (CREATE_ISSUE, "creating an issue"),
1288 (ADD_TO_BOARD, "adding an issue to the board"),
1289 (UPDATE_ISSUE, "updating an issue"),
1290 (UPDATE_DRAFT, "updating a draft item"),
1291 (UPDATE_FIELD, "writing a board field"),
1292 (UPDATE_FIELDS, "writing board fields together"),
1293 (CLEAR_FIELD, "clearing a board field"),
1294 (
1295 CREATE_FIELD,
1296 "creating a board single-select field with its options",
1297 ),
1298 (
1299 STATUS_OPTIONS_SNAPSHOT,
1300 "snapshotting board Status options and assignments",
1301 ),
1302 (
1303 STATUS_OPTIONS_UPDATE,
1304 "safely replacing the board Status option list",
1305 ),
1306 (ADD_SUB_ISSUE, "filing an issue under its project"),
1307 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1308 (ADD_BLOCKED_BY, "recording a dependency"),
1309 (REMOVE_BLOCKED_BY, "removing a dependency"),
1310 (DELETE_ISSUE, "deleting an issue"),
1311 (ISSUE_COMMENTS, "reading a task's comments"),
1312 (ISSUE_DETAIL, "reading one issue with its comments"),
1313 (
1314 ISSUE_DETAILS,
1315 "reading a batch of issues with their comments",
1316 ),
1317 (COMMENT_ISSUE, "reading which issue a comment is on"),
1318 (ADD_COMMENT, "adding a comment"),
1319 (UPDATE_COMMENT, "editing a comment"),
1320 (DELETE_COMMENT, "deleting a comment"),
1321 ];
1322}
1323
1324/// Which of GitHub's two rate limiters refused a request.
1325///
1326/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1327/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1328/// the whole reason this is carried rather than collapsed into "rate limited".
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum Limiter {
1331 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1332 Primary,
1333 /// The burst limiter over content-generating requests, which nothing reports.
1334 Secondary,
1335}
1336
1337/// The wordings GitHub answers a secondary rate limit with.
1338///
1339/// It sends them under a forbidden status, under a too-many-requests status, and inside
1340/// the `errors` of a *successful* response, which is why the text is what this matches on
1341/// rather than the status. `abuse detection` is the wording GitHub used before the
1342/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1343/// what a burst of content creation is refused with.
1344///
1345/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1346/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1347/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1348/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1349pub const SECONDARY_WORDINGS: [&str; 5] = [
1350 "secondary rate limit",
1351 "temporarily blocked from content creation",
1352 "abuse detection",
1353 "submitted too quickly",
1354 "exceeded a secondary",
1355];
1356
1357/// The wordings GitHub answers an exhausted primary budget with.
1358///
1359/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1360/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1361/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1362/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1363/// two phrases is a substring of it, so without it that answer read as a refusal that will
1364/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1365/// one reason.
1366pub const PRIMARY_WORDINGS: [&str; 4] = [
1367 "api rate limit exceeded",
1368 "api rate limit already exceeded",
1369 "rate limit exceeded",
1370 "rate_limited",
1371];
1372
1373/// What a response *says about itself*, which is the only place a refusal can be read.
1374///
1375/// Deliberately not the whole response body. A board is a place people write about their
1376/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1377/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1378/// reported. So the item data is never read: what is read is GitHub's own REST-style
1379/// `message` envelope, which is what a forbidden status carries, and the `message` and
1380/// `type` of each GraphQL error, which is where a *successful* response says it.
1381///
1382/// A body that is not JSON at all has nothing structured to read, so only a failing
1383/// response's own text is taken — a successful response that is not JSON is malformed
1384/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1385fn refusal_wording(status: StatusCode, body: &str) -> String {
1386 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1387 return if status.is_success() {
1388 String::new()
1389 } else {
1390 body.to_owned()
1391 };
1392 };
1393 let mut said: Vec<&str> = parsed
1394 .get("message")
1395 .and_then(Value::as_str)
1396 .into_iter()
1397 .collect();
1398 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1399 for error in errors {
1400 said.extend(
1401 ["message", "type"]
1402 .into_iter()
1403 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1404 );
1405 }
1406 }
1407 said.join("; ")
1408}
1409
1410impl Limiter {
1411 /// Which limiter refused this response, or `None` when none of them did.
1412 ///
1413 /// The wording is read first and the status only decides what carries none of it,
1414 /// because GitHub answers a secondary limit with a forbidden status far more often
1415 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1416 /// really is a credential this token lacks.
1417 ///
1418 /// A response is a refusal because of its status or its own wording. A spent budget
1419 /// only ever explains one; it never turns an answer into a refusal.
1420 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1421 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1422 if SECONDARY_WORDINGS
1423 .iter()
1424 .any(|wording| normalized.contains(wording))
1425 {
1426 return Some(Self::Secondary);
1427 }
1428 if status == StatusCode::TOO_MANY_REQUESTS {
1429 return Some(Self::Primary);
1430 }
1431 // An exhausted budget *explains* a response that failed; it does not make one that
1432 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1433 // request the budget allowed as well as on the ones it then refuses, so reading
1434 // the header alone threw away a good answer — and, once refusals were retried,
1435 // replayed a request that had already taken effect.
1436 if !status.is_success() && budget_exhausted {
1437 return Some(Self::Primary);
1438 }
1439 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1440 // `errors` of an HTTP 200, where nothing about the status says so at all.
1441 if status.is_success()
1442 && PRIMARY_WORDINGS
1443 .iter()
1444 .any(|wording| normalized.contains(wording))
1445 {
1446 return Some(Self::Primary);
1447 }
1448 None
1449 }
1450
1451 /// What this limiter is called where an operator can look it up.
1452 const fn name(self) -> &'static str {
1453 match self {
1454 Self::Primary => "GitHub's primary API rate limit",
1455 Self::Secondary => "GitHub's secondary rate limit",
1456 }
1457 }
1458
1459 /// What the endpoint an operator would go and check says about this limiter.
1460 const fn where_to_look(self) -> &'static str {
1461 match self {
1462 Self::Primary => {
1463 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1464 comes back."
1465 }
1466 Self::Secondary => {
1467 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1468 primary budget and does not report this one, so budget showing there says \
1469 nothing about this refusal, and every further attempt extends it."
1470 }
1471 }
1472 }
1473
1474 /// The next step this limiter actually calls for.
1475 const fn what_to_do(self) -> &'static str {
1476 match self {
1477 Self::Primary => {
1478 "wait for the reset `gh api rate_limit` reports, then run the command again."
1479 }
1480 Self::Secondary => {
1481 "leave this board alone for a few minutes, then run the command again — or \
1482 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1483 }
1484 }
1485 }
1486}
1487
1488/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1489#[derive(Debug, Clone, Copy)]
1490struct Limited {
1491 limiter: Limiter,
1492 hint: Option<u64>,
1493}
1494
1495impl Limited {
1496 /// What the caller is told once this source has waited as long as it may.
1497 ///
1498 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1499 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1500 /// about *which* limiter it was makes it a different kind of failure. What differs is
1501 /// the operator's next step, and that is what the message carries — a secondary
1502 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1503 /// budget looks fine, and then back to retry the very burst that was refused.
1504 fn exhausted(
1505 self,
1506 doing: &str,
1507 waits: u32,
1508 waited: Duration,
1509 needed: Duration,
1510 budget: Duration,
1511 ) -> SourceError {
1512 SourceError::RateLimited {
1513 retry_after_seconds: self.hint,
1514 message: Some(format!(
1515 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1516 again, and the next wait of {} would take it past the {} one call may spend \
1517 waiting. {} next: {}",
1518 self.limiter.name(),
1519 plural(waits, "refusal"),
1520 seconds(waited),
1521 seconds(needed),
1522 seconds(budget),
1523 self.limiter.where_to_look(),
1524 self.limiter.what_to_do(),
1525 )),
1526 }
1527 }
1528}
1529
1530/// One HTTP attempt's result, with what its response said about the rate limit.
1531///
1532/// The two travel together so the record and the outcome are written from the same place:
1533/// what a response said about the budget is only readable while that response is in hand,
1534/// and what the attempt *meant* is only decidable once its body has been read.
1535struct Attempted {
1536 result: Result<Value, Attempt>,
1537 limits: accounting::RateLimit,
1538 /// GitHub's own reported cost for this call, for a document that asked for it.
1539 reported_cost: Option<u64>,
1540}
1541
1542/// One attempt's outcome: an error to report, or a rate limit to wait out.
1543enum Attempt {
1544 Failed(SourceError),
1545 Limited(Limited),
1546}
1547
1548fn plural(count: u32, thing: &str) -> String {
1549 if count == 1 {
1550 format!("{count} {thing}")
1551 } else {
1552 format!("{count} {thing}s")
1553 }
1554}
1555
1556fn seconds(duration: Duration) -> String {
1557 format!("{:.1}s", duration.as_secs_f64())
1558}
1559
1560/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1561///
1562/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1563/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1564/// header, and neither is what makes a response a refusal — so the whole cost of one this
1565/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1566/// instead. Refusing the response over the header would turn a readable refusal into an
1567/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1568fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1569 value
1570 .and_then(|value| value.to_str().ok())
1571 .and_then(|value| value.trim().parse::<u64>().ok())
1572}
1573
1574/// Every mutation this source sends creates content — an issue, a board item, a field of
1575/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1576/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1577/// and what the keyword says are the same set. That is what makes the keyword a sound test
1578/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1579/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1580fn is_mutation(query: &str) -> bool {
1581 query.trim_start().starts_with("mutation")
1582}
1583
1584/// What this source was doing, for a diagnostic that has to say so.
1585///
1586/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1587/// a document added without a description is caught by that list's own gate instead of
1588/// falling through to the vague arm below.
1589fn operation_description(query: &str) -> &'static str {
1590 graphql::DOCUMENTS
1591 .iter()
1592 .find(|(document, _)| *document == query)
1593 .map_or("talking to GitHub", |(_, doing)| *doing)
1594}
1595
1596/// GitHub's published ceiling on content-generating requests, per minute.
1597///
1598/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1599/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1600/// from it, so a pacing value checked only against itself cannot go stale here.
1601pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1602/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1603/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1604/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1605pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1606/// Shortest interval between two content-creating mutations, in milliseconds.
1607///
1608/// GitHub documents two secondary limits on content-generating requests:
1609/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1610/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1611/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1612/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1613/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1614/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1615/// deliberately *not* what this paces at. An installation that wants the hourly bound
1616/// honoured for a long sequence of copies says so through
1617/// `pacing.min_mutation_interval_ms`.
1618pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1619/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1620///
1621/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1622/// own advice for a secondary limit — wait, and wait longer each time — without spending
1623/// the first minute of a transient refusal doing nothing.
1624pub const RETRY_BACKOFF_MS: u64 = 1_000;
1625/// Total time one call may spend waiting out rate limits before it reports a failure.
1626///
1627/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1628/// short enough that a command an operator is watching returns. The bound is what makes
1629/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1630/// the limiter, not in a process nobody can tell from a wedged one.
1631pub const RETRY_BUDGET_MS: u64 = 120_000;
1632
1633fn default_token_env() -> String {
1634 "GH_PROJECTS_TOKEN".to_owned()
1635}
1636fn default_endpoint() -> String {
1637 "https://api.github.com/graphql".to_owned()
1638}
1639
1640/// The name of a `Status` single-select option on the board.
1641///
1642/// Validated on the way in rather than checked later, so a blank option name — which
1643/// nothing on a board can be — is a state this type cannot hold.
1644#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1645#[serde(try_from = "String")]
1646#[schemars(extend("minLength" = 1))]
1647pub struct ColumnName(String);
1648
1649impl ColumnName {
1650 /// The option name, as the board spells it.
1651 fn as_str(&self) -> &str {
1652 &self.0
1653 }
1654}
1655
1656impl TryFrom<String> for ColumnName {
1657 type Error = String;
1658
1659 fn try_from(name: String) -> Result<Self, Self::Error> {
1660 if name.trim().is_empty() {
1661 return Err("a status_mapping option name cannot be blank".to_owned());
1662 }
1663 Ok(Self(name))
1664 }
1665}
1666
1667/// The two closed states this product can mean.
1668///
1669/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1670/// work nor abandoned work, so nothing here ever writes it.
1671#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1672#[serde(rename_all = "kebab-case")]
1673pub enum ClosedState {
1674 /// `COMPLETED` — precisely done.
1675 Completed,
1676 /// `NOT_PLANNED` — precisely cancelled.
1677 NotPlanned,
1678}
1679
1680impl ClosedState {
1681 const fn reason(self) -> &'static str {
1682 match self {
1683 Self::Completed => "COMPLETED",
1684 Self::NotPlanned => "NOT_PLANNED",
1685 }
1686 }
1687}
1688
1689/// Configuration for one GitHub Projects v2 board.
1690#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1691#[serde(default, deny_unknown_fields)]
1692pub struct GitHubProjectsConfig {
1693 /// Login of the user or organization which owns the board.
1694 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1695 /// The project number shown in the board's GitHub URL.
1696 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1697 // 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.
1698 /// `owner/name` of the repository this source creates an issue in when the item's own
1699 /// `repositories` field does not decide it.
1700 ///
1701 /// An item naming exactly one repository is created there; a task or a document naming
1702 /// none or several is created in its parent project's repository; and a project, or a
1703 /// task or document with no parent, naming none or several is created here. A board
1704 /// has no repository of its own and `createIssue` requires one, so a write without
1705 /// this is refused naming the field. Reads never need it.
1706 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1707 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1708 /// Environment variable containing a fine-grained token with Projects and Issues
1709 /// read/write plus Pull requests read-only access for every repository represented on
1710 /// the board.
1711 #[serde(default = "default_token_env")]
1712 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1713 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1714 #[serde(default = "default_endpoint")]
1715 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1716 /// Per-instance mapping from a status category to the option of the board's one
1717 /// `Status` field it lands on, for a task and for a project.
1718 ///
1719 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1720 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1721 /// where a kind left out leaves the category unmapped for that kind. A category this
1722 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1723 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1724 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1725 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1726 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1727 /// either kind. No two categories may name one option for the same kind, ignoring case.
1728 /// `unknown` may name one existing option; every unknown word then lands on it and
1729 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1730 /// each unknown word because it never creates board options.
1731 #[serde(default)]
1732 pub status_mapping: StatusMapping,
1733 /// Per-instance mapping from a task's priority to an option of this board's
1734 /// single-select field named `Priority`.
1735 ///
1736 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1737 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1738 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1739 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1740 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1741 /// no two levels may name one option. Reads and writes never create the field or an
1742 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1743 /// the board lacks is refused pointing there.
1744 #[serde(default)]
1745 pub priority_mapping: Option<PriorityMappingConfig>,
1746 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1747 ///
1748 /// Every field keeps its shipped default when it is absent, and the defaults are
1749 /// GitHub's own published limits rather than taste. See [`Pacing`].
1750 #[serde(default)]
1751 pub pacing: PacingConfig,
1752}
1753
1754/// Which option of the board's `Priority` field each priority lands on.
1755///
1756/// One member per level rather than a map, so a key that is not a level is refused where
1757/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1758/// value in the field, not an option of it.
1759#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1760#[serde(default, deny_unknown_fields)]
1761pub struct PriorityMappingConfig {
1762 /// The option `urgent` lands on; `Urgent` when absent.
1763 pub urgent: Option<PriorityOptionName>,
1764 /// The option `high` lands on; `High` when absent.
1765 pub high: Option<PriorityOptionName>,
1766 /// The option `medium` lands on; `Medium` when absent.
1767 pub medium: Option<PriorityOptionName>,
1768 /// The option `low` lands on; `Low` when absent.
1769 pub low: Option<PriorityOptionName>,
1770}
1771
1772/// The name of an option of the board's `Priority` single-select field.
1773///
1774/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1775/// blank name.
1776#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1777#[serde(try_from = "String")]
1778#[schemars(extend("minLength" = 1))]
1779pub struct PriorityOptionName(String);
1780
1781impl PriorityOptionName {
1782 /// The option name, as the board spells it.
1783 fn as_str(&self) -> &str {
1784 &self.0
1785 }
1786}
1787
1788impl TryFrom<String> for PriorityOptionName {
1789 type Error = String;
1790
1791 fn try_from(name: String) -> Result<Self, Self::Error> {
1792 if name.trim().is_empty() {
1793 return Err("a priority_mapping option name cannot be blank".to_owned());
1794 }
1795 Ok(Self(name))
1796 }
1797}
1798
1799/// The name of the board field a priority is held in.
1800pub const PRIORITY_FIELD: &str = "Priority";
1801
1802/// The four priorities a board option can hold, in the order a new `Priority` field lists
1803/// them. `none` is not among them: it is the field holding no value.
1804///
1805/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1806/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1807/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1808/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1809pub const PRIORITY_LEVELS: [Priority; 4] = [
1810 Priority::Urgent,
1811 Priority::High,
1812 Priority::Medium,
1813 Priority::Low,
1814];
1815
1816/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1817/// see that list for what this pins.
1818#[must_use]
1819pub const fn level_position(priority: Priority) -> Option<usize> {
1820 match priority {
1821 Priority::None => None,
1822 Priority::Urgent => Some(0),
1823 Priority::High => Some(1),
1824 Priority::Medium => Some(2),
1825 Priority::Low => Some(3),
1826 }
1827}
1828
1829/// This instance's complete priority-to-option mapping, read in both directions.
1830///
1831/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1832/// two levels name one option.
1833#[derive(Debug, Clone)]
1834struct PriorityMapping {
1835 options: [PriorityOptionName; 4],
1836}
1837
1838impl PriorityMapping {
1839 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1840 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1841 let mapping = Self {
1842 options: [
1843 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1844 config.high.unwrap_or_else(|| shipped("High")),
1845 config.medium.unwrap_or_else(|| shipped("Medium")),
1846 config.low.unwrap_or_else(|| shipped("Low")),
1847 ],
1848 };
1849 for (index, option) in mapping.options.iter().enumerate() {
1850 if let Some(earlier) = mapping.options[..index]
1851 .iter()
1852 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1853 {
1854 return Err(SourceError::Config {
1855 message: format!(
1856 "priority_mapping of source {instance} sends both {} and {} to the board \
1857 option {:?}; one option cannot read back as two priorities",
1858 PRIORITY_LEVELS[earlier],
1859 PRIORITY_LEVELS[index],
1860 option.as_str()
1861 ),
1862 });
1863 }
1864 }
1865 Ok(mapping)
1866 }
1867
1868 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1869 fn option(&self, priority: Priority) -> Option<&str> {
1870 level_position(priority).map(|index| self.options[index].as_str())
1871 }
1872
1873 /// The priority a board option name reports, or `None` when nothing maps to it.
1874 fn priority_of(&self, option: &str) -> Option<Priority> {
1875 self.options
1876 .iter()
1877 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1878 .map(|index| PRIORITY_LEVELS[index])
1879 }
1880
1881 /// Every mapped option name, in the order a new `Priority` field lists them.
1882 fn names(&self) -> impl Iterator<Item = &str> {
1883 self.options.iter().map(PriorityOptionName::as_str)
1884 }
1885}
1886
1887/// What one item's `Priority` field says, read through this instance's mapping.
1888#[derive(Debug, Clone, PartialEq, Eq)]
1889enum HeldPriority {
1890 /// A priority this source reports: an option the mapping names, or no value (`none`).
1891 Read(Priority),
1892 /// An option the mapping does not name, which is never read as a level or as `none`.
1893 Unmapped(String),
1894}
1895
1896/// How fast this source writes, and how long it waits out a rate-limit refusal.
1897///
1898/// Configurable because a GitHub Enterprise installation sets its own limits and an
1899/// operator who has already been refused may want to go slower still — not because the
1900/// defaults are guesses.
1901#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1902#[serde(default, deny_unknown_fields)]
1903pub struct PacingConfig {
1904 /// Shortest interval between two content-creating mutations, in milliseconds.
1905 ///
1906 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1907 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1908 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.
1909 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1910 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1911 /// zero while there is a budget to spend, because a schedule of zero-length waits
1912 /// consumes none of it and so never ends.
1913 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.
1914 /// Total time one call may spend waiting out rate limits, in milliseconds.
1915 ///
1916 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1917 /// the bound is what makes this a wait rather than a hang.
1918 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.
1919}
1920
1921/// The largest any pacing setting may be, in milliseconds.
1922///
1923/// One hour. GitHub's own harshest published bound on content-generating requests works
1924/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1925/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1926/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1927/// and an interval beyond it is a command that never sends its second mutation. It also
1928/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1929/// what an `Instant` can hold on every platform.
1930pub const MAX_PACING_MS: u64 = 3_600_000;
1931
1932/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1933/// source holds.
1934#[derive(Debug, Clone, Copy)]
1935struct Pacing {
1936 min_mutation_interval: Duration,
1937 retry_backoff: Duration,
1938 retry_budget: Duration,
1939}
1940
1941impl Pacing {
1942 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1943 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1944 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1945 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1946 message: format!(
1947 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1948 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1949 GitHub's own harshest published limit"
1950 ),
1951 }),
1952 Some(value) => Ok(Duration::from_millis(value)),
1953 None => Ok(Duration::from_millis(default)),
1954 };
1955 let retry_backoff = bounded(
1956 config.retry_backoff_ms,
1957 RETRY_BACKOFF_MS,
1958 "retry_backoff_ms",
1959 )?;
1960 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1961 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1962 return Err(SourceError::Config {
1963 message: format!(
1964 "pacing.retry_backoff_ms of source {instance} is 0 while \
1965 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1966 none of that budget, so it would retry a refusal forever. Set a backoff of \
1967 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1968 waiting at all",
1969 retry_budget.as_millis()
1970 ),
1971 });
1972 }
1973 Ok(Self {
1974 min_mutation_interval: bounded(
1975 config.min_mutation_interval_ms,
1976 MIN_MUTATION_INTERVAL_MS,
1977 "min_mutation_interval_ms",
1978 )?,
1979 retry_backoff,
1980 retry_budget,
1981 })
1982 }
1983}
1984
1985/// Factory for [`GitHubProjectsSource`].
1986#[derive(Debug, Clone, Copy, Default)]
1987pub struct Plugin;
1988
1989impl SourcePlugin for Plugin {
1990 fn kind(&self) -> &'static str {
1991 KIND
1992 }
1993 fn config_schema(&self) -> Schema {
1994 schema_for!(GitHubProjectsConfig)
1995 }
1996 fn build(
1997 &self,
1998 name: &SourceName,
1999 config: &Value,
2000 secrets: &dyn SecretResolver,
2001 ) -> Result<Box<dyn TaskSource>, SourceError> {
2002 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2003 }
2004}
2005
2006impl Plugin {
2007 /// Build a source recording every request it sends into an accounting the caller holds.
2008 ///
2009 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2010 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2011 /// session total rather than two — see [`accounting`] and
2012 /// [`GitHubProjectsSource::recording_into`].
2013 ///
2014 /// # Errors
2015 ///
2016 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2017 /// [`SourceError::Config`] for configuration this plugin cannot use and
2018 /// [`SourceError::Auth`] for a credential it cannot find.
2019 pub fn build_recording_into(
2020 &self,
2021 name: &SourceName,
2022 config: &Value,
2023 secrets: &dyn SecretResolver,
2024 ledger: Arc<Accounting>,
2025 ) -> Result<Box<dyn TaskSource>, SourceError> {
2026 let config: GitHubProjectsConfig =
2027 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2028 message: format!("source {name}: {e}"),
2029 })?;
2030 let prefix = format!("source {name}: ");
2031 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2032 |error| match error {
2033 // The shared `StatusMapping::distinct` names the source itself.
2034 SourceError::Config { message } if message.starts_with(&prefix) => {
2035 SourceError::Config { message }
2036 }
2037 SourceError::Config { message } => SourceError::Config {
2038 message: format!("{prefix}{message}"),
2039 },
2040 SourceError::Auth { message } => SourceError::Auth {
2041 message: format!("source {name}: {message}"),
2042 },
2043 other => other,
2044 },
2045 )?;
2046 Ok(Box::new(source))
2047 }
2048}
2049
2050/// Where a status category lands on this board, once configuration is resolved.
2051#[derive(Debug, Clone, PartialEq, Eq)]
2052enum StatusTarget {
2053 /// Not usable against this instance for this kind, and why.
2054 Disabled(UnmappedStatus),
2055 /// The board's `Status` option of this name.
2056 Column(ColumnName),
2057 /// A closed issue, with both its board option and the reason that says which closed it means.
2058 // 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.
2059 Terminal(ColumnName, ClosedState),
2060}
2061
2062/// Every status category, in the order the vocabulary declares them.
2063///
2064/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2065/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2066/// added to the shared vocabulary fails to compile until it is named there, and this
2067/// crate's suite reconciles this list against that enum's own derived schema, which is
2068/// generated from the variants rather than written beside them. The schema is what
2069/// catches a list left one short — a list checking only the positions it already holds
2070/// would pass while every mapping indexed by the new position panicked.
2071pub const CATEGORIES: [StatusCategory; 8] = [
2072 StatusCategory::Draft,
2073 StatusCategory::Backlog,
2074 StatusCategory::Todo,
2075 StatusCategory::Queued,
2076 StatusCategory::InProgress,
2077 StatusCategory::Done,
2078 StatusCategory::Cancelled,
2079 StatusCategory::Unknown,
2080];
2081
2082/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2083#[must_use]
2084pub const fn category_position(category: StatusCategory) -> usize {
2085 match category {
2086 StatusCategory::Draft => 0,
2087 StatusCategory::Backlog => 1,
2088 StatusCategory::Todo => 2,
2089 StatusCategory::Queued => 3,
2090 StatusCategory::InProgress => 4,
2091 StatusCategory::Done => 5,
2092 StatusCategory::Cancelled => 6,
2093 StatusCategory::Unknown => 7,
2094 }
2095}
2096
2097/// The spelling a status category is configured and reported under.
2098fn category_name(category: StatusCategory) -> &'static str {
2099 match category {
2100 StatusCategory::Draft => "draft",
2101 StatusCategory::Backlog => "backlog",
2102 StatusCategory::Todo => "todo",
2103 StatusCategory::Queued => "queued",
2104 StatusCategory::InProgress => "in-progress",
2105 StatusCategory::Done => "done",
2106 StatusCategory::Cancelled => "cancelled",
2107 StatusCategory::Unknown => "unknown",
2108 }
2109}
2110
2111/// A shipped default's option name.
2112///
2113/// The literals below are this file's own and non-blank, and they are validated by the
2114/// one constructor a configured name goes through rather than beside it.
2115fn shipped_column(name: &'static str) -> ColumnName {
2116 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2117}
2118
2119/// The shipped default for one category this instance's `status_mapping` does not mention,
2120/// for either kind.
2121fn shipped_default(category: StatusCategory) -> StatusTarget {
2122 match category {
2123 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2124 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2125 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2126 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2127 StatusCategory::Done => {
2128 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2129 }
2130 StatusCategory::Cancelled => {
2131 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2132 }
2133 StatusCategory::Draft | StatusCategory::Unknown => {
2134 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2135 }
2136 }
2137}
2138
2139/// The two kinds a status is written and read for, each with its own half of the mapping.
2140const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2141
2142/// This instance's complete category-to-target mapping for each kind, read in both
2143/// directions.
2144///
2145/// One target per category per kind, held at that category's own [`category_position`], so
2146/// a category missing from the mapping, named twice in it, or filed out of order is a state
2147/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2148/// targets are options of the board's one `Status` field.
2149#[derive(Debug, Clone)]
2150struct BoardStatuses {
2151 tasks: [StatusTarget; CATEGORIES.len()],
2152 projects: [StatusTarget; CATEGORIES.len()],
2153}
2154
2155impl BoardStatuses {
2156 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2157 /// would read back from one option.
2158 ///
2159 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2160 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2161 /// omits unmapped rather than defaulted.
2162 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2163 let resolve_kind =
2164 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2165 // `CATEGORIES[position] == category` for every category — the crate's suite
2166 // asserts it — so mapping the list in order fills each category's own slot.
2167 let mut targets = CATEGORIES.map(shipped_default);
2168 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2169 if !configured.mentions(category) {
2170 continue;
2171 }
2172 *slot = match configured.name_for(category, kind) {
2173 Err(why) => StatusTarget::Disabled(why),
2174 Ok(name) => {
2175 let option = ColumnName::try_from(name.as_str().to_owned())
2176 .map_err(|message| SourceError::Config { message })?;
2177 match category {
2178 StatusCategory::Done => {
2179 StatusTarget::Terminal(option, ClosedState::Completed)
2180 }
2181 StatusCategory::Cancelled => {
2182 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2183 }
2184 _ => StatusTarget::Column(option),
2185 }
2186 }
2187 };
2188 }
2189 StatusMapping::distinct(
2190 instance,
2191 kind,
2192 CATEGORIES
2193 .iter()
2194 .zip(&targets)
2195 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2196 )?;
2197 Ok(targets)
2198 };
2199 Ok(Self {
2200 tasks: resolve_kind(ItemKind::Task)?,
2201 projects: resolve_kind(ItemKind::Project)?,
2202 })
2203 }
2204
2205 /// Every category's target for `kind`, in category order.
2206 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2207 match kind {
2208 ItemKind::Task => &self.tasks,
2209 ItemKind::Project => &self.projects,
2210 }
2211 }
2212
2213 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2214 &self.targets(kind)[category_position(category)]
2215 }
2216
2217 /// The category a board option name reports for `kind`, or `None` when nothing of that
2218 /// kind maps to it.
2219 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2220 CATEGORIES.into_iter().find(|category| {
2221 self.target(kind, *category)
2222 .option()
2223 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2224 })
2225 }
2226
2227 /// Every option name either kind maps a category to, each once ignoring case, in
2228 /// category order with a task's name before a project's — what the guarded setup asks
2229 /// the `Status` field to hold.
2230 fn wanted(&self) -> Vec<String> {
2231 let mut wanted: Vec<String> = Vec::new();
2232 for category in CATEGORIES {
2233 for kind in STATUS_KINDS {
2234 if let Some(name) = self.target(kind, category).option()
2235 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2236 {
2237 wanted.push(name.to_owned());
2238 }
2239 }
2240 }
2241 wanted
2242 }
2243
2244 /// The status an item of `kind` reports, from the three things a read of it says: its
2245 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2246 ///
2247 /// The closed state decides the category and the `Status` option decides the name, so
2248 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2249 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2250 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2251 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2252 /// it is read permissively rather than refused — reads are faithful, and refusals
2253 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2254 /// option that mapping does not name reads as `Unknown` under its own name.
2255 ///
2256 /// One function of those three rather than of a response, so a narrow status write can
2257 /// answer what a re-read would report by applying it to the state it has just written.
2258 fn status(
2259 &self,
2260 kind: ItemKind,
2261 option: Option<&str>,
2262 closed: bool,
2263 reason: Option<&str>,
2264 ) -> Status {
2265 if closed {
2266 let category = match reason {
2267 None | Some("COMPLETED") => StatusCategory::Done,
2268 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2269 Some(_) => StatusCategory::Unknown,
2270 };
2271 let fallback = match category {
2272 StatusCategory::Done => "Done",
2273 StatusCategory::Cancelled => "Cancelled",
2274 _ => "Closed",
2275 };
2276 return Status {
2277 category,
2278 name: option.unwrap_or(fallback).to_owned(),
2279 };
2280 }
2281 let name = option.unwrap_or("Open").to_owned();
2282 Status {
2283 category: self
2284 .category_of(kind, &name)
2285 .unwrap_or(StatusCategory::Unknown),
2286 name,
2287 }
2288 }
2289}
2290
2291impl BoardStatuses {
2292 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2293 /// case; a kind lacking none is left out.
2294 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2295 STATUS_KINDS
2296 .into_iter()
2297 .filter_map(|kind| {
2298 let missing: Vec<String> = self
2299 .targets(kind)
2300 .iter()
2301 .filter_map(StatusTarget::option)
2302 .filter(|wanted| {
2303 !existing
2304 .iter()
2305 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2306 })
2307 .map(str::to_owned)
2308 .collect();
2309 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2310 })
2311 .collect()
2312 }
2313}
2314
2315impl StatusTarget {
2316 /// The board option this target selects, or `None` for an unmapped one.
2317 fn option(&self) -> Option<&str> {
2318 match self {
2319 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2320 Self::Disabled(_) => None,
2321 }
2322 }
2323}
2324
2325// 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.
2326/// One repository this source can create an issue in, as `owner/name`.
2327///
2328/// Every `createIssue` this source sends names one of these: the item's own single
2329/// `repositories` entry, else its parent project issue's repository, else the configured
2330/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2331/// that choice and says what it refuses before `createIssue`.
2332// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2333#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2334struct RepositoryTarget {
2335 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2336 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2337}
2338
2339impl RepositoryTarget {
2340 fn parse(value: &str) -> Result<Self, SourceError> {
2341 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2342 message: format!(
2343 "repository must be spelled owner/name; {value:?} names no repository"
2344 ),
2345 })?;
2346 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2347 return Err(SourceError::Config {
2348 message: format!(
2349 "repository must be spelled owner/name with a GitHub login and one \
2350 repository name; {value:?} is not"
2351 ),
2352 });
2353 }
2354 Ok(Self {
2355 owner: owner.to_owned(),
2356 name: name.to_owned(),
2357 })
2358 }
2359
2360 /// The one host whose repositories this source creates issues in, spelled once: it is
2361 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2362 const HOST: &str = "github.com";
2363
2364 fn origin(&self) -> String {
2365 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2366 }
2367
2368 /// The repository a normalized origin names, or why it is none this source can create
2369 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2370 fn from_origin(origin: &Repository) -> Result<Self, String> {
2371 let not_here = || {
2372 format!(
2373 "{} is not a {}/owner/name repository",
2374 origin.as_str(),
2375 Self::HOST
2376 )
2377 };
2378 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2379 if host != Self::HOST {
2380 return Err(not_here());
2381 }
2382 Self::parse(rest).map_err(|_| not_here())
2383 }
2384
2385 fn slug(&self) -> String {
2386 format!("{}/{}", self.owner, self.name)
2387 }
2388}
2389
2390/// A source which reads GitHub afresh for every operation.
2391pub struct GitHubProjectsSource {
2392 /// This source's configured name, used both to tell a far end naming this source
2393 /// from one naming a system it knows nothing about, and to name the instance a
2394 /// status refusal is about.
2395 name: SourceName,
2396 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2397 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2398 repository: Option<RepositoryTarget>,
2399 endpoint: Url,
2400 token: SecretString,
2401 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2402 statuses: BoardStatuses,
2403 /// Where each priority lands on this board, or `None` when this instance holds none.
2404 priorities: Option<PriorityMapping>,
2405 client: Client,
2406 /// Every item this source has created in this command, in the order it created them —
2407 /// dropped by [`TaskSource::end_command`].
2408 ///
2409 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2410 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2411 /// a copy resolving a dependency on an item it had just created refused it as not
2412 /// found. A board read is completed from this — an item remembered here and absent from
2413 /// the read is added back, because the board really does hold it and only the read is
2414 /// behind.
2415 ///
2416 /// It is not a cache of a user's work: nothing is remembered that this process did not
2417 /// itself just write, it lives and dies with the process, and it is never consulted for
2418 /// an item this source did not create.
2419 created: Mutex<Vec<Resolved>>,
2420 /// Every item that already existed and that this source has written in this command, as
2421 /// it wrote it — dropped by [`TaskSource::end_command`].
2422 ///
2423 /// The other half of [`Self::created`], held on the same terms and for the reason a
2424 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2425 /// filter is an index behind a write this process made moments ago, so a query matching
2426 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2427 /// is remembered that this process did not itself just write.
2428 updated: Mutex<Vec<Resolved>>,
2429 /// How fast this source writes, and how long it waits out a refusal.
2430 pacing: Pacing,
2431 /// When the last content-creating mutation finished, or the moment the furthest-out
2432 /// reserved slot releases the next one, whichever is later — so the one after it can be
2433 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2434 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2435 /// what it is measured from.
2436 last_mutation: Mutex<Option<Instant>>,
2437 /// The board as this process last read it, for the length of one command — dropped by
2438 /// [`TaskSource::end_command`].
2439 ///
2440 /// A copy of a project used to re-read the whole board, paged, before writing each of
2441 /// its items, which is by far the largest part of a copy's request count and none of
2442 /// its work. Nothing else changes this board while a command runs — this source's own
2443 /// writes are the only writer — so one read answers them all.
2444 ///
2445 /// It is not a store of a user's work and it is not the cache the no-persistence
2446 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2447 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2448 /// an item this command created and then depends on resolves whether or not GitHub's
2449 /// own eventually-consistent read has caught up. A write to an item already on the
2450 /// board updates the entry here too, so what this holds is the last read plus this
2451 /// process's own writes rather than a snapshot taken before them.
2452 board_cache: Mutex<Option<Board>>,
2453 /// Every issue this board's own search reported, for the length of one command — dropped
2454 /// by [`TaskSource::end_command`].
2455 ///
2456 /// The second half of a board read, and cached for the same reason and on the same
2457 /// terms as the first: it lives and dies with the process, nothing is written down, and
2458 /// a write this process makes updates the entry here exactly as it updates the one in
2459 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2460 /// that lists this board's projects and its tasks pays for one search rather than two.
2461 search_cache: Mutex<Option<Vec<Resolved>>>,
2462 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2463 /// the length of one command — dropped by [`TaskSource::end_command`].
2464 ///
2465 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2466 /// and dies with the process, nothing is written down, a write this process makes
2467 /// updates the entry here as it updates the other two, and every answer is completed
2468 /// with this process's own writes each time it is given. A command that asks the same
2469 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2470 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2471 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2472 search_next: Mutex<BTreeMap<String, Option<String>>>,
2473 /// Records already resolved in this command, reused by writes and for comment identity.
2474 /// Explicit item reads still reach GitHub. Nothing is persisted, and
2475 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2476 /// its item as a person has since left it.
2477 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2478 /// The board's own id and field definitions as this process last read them on their
2479 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2480 ///
2481 /// What a write needs of the board and its item does not say, read once per command
2482 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2483 /// lives and dies with the process and nothing is written down. It holds no item and so
2484 /// can answer no question about one — see [`Self::board_fields`].
2485 fields_cache: Mutex<Option<BoardFields>>,
2486 /// Each destination repository's node id, resolved once per repository
2487 /// rather than per issue created.
2488 ///
2489 /// A repository's node id does not change, and re-reading it for every issue of a copy
2490 /// spent one request per item on an answer this source already had. It is a map rather
2491 /// than one entry because a copy files each item in the repository its own
2492 /// `repositories` field names, so a plan across five repositories asks GitHub five
2493 /// times and not once per item.
2494 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2495 /// What every request this source sends is recorded into.
2496 ///
2497 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2498 /// a request leaves this crate, so nothing has to be switched on for a session to be
2499 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2500 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2501 /// source's reads and writes — adds up one accounting instead of two. See
2502 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2503 ledger: Arc<Accounting>,
2504}
2505
2506/// GitHub's closed single-select color vocabulary.
2507#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2508#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2509pub enum StatusOptionColor {
2510 /// Gray.
2511 Gray,
2512 /// Blue.
2513 Blue,
2514 /// Green.
2515 Green,
2516 /// Yellow.
2517 Yellow,
2518 /// Purple.
2519 Purple,
2520 /// Red.
2521 Red,
2522 /// Orange.
2523 Orange,
2524 /// Pink.
2525 Pink,
2526}
2527
2528/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2529/// applies its additions.
2530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2531pub enum SetupMode {
2532 /// Read without mutation.
2533 Plan,
2534 /// Apply and verify.
2535 Apply,
2536}
2537
2538/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2539/// against it goes on compiling.
2540pub type StatusOptionsMode = SetupMode;
2541
2542/// The explicit result of the requested operation.
2543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2544#[serde(rename_all = "kebab-case")]
2545pub enum StatusOptionsOutcome {
2546 /// A read-only plan.
2547 Planned,
2548 /// Apply found nothing missing.
2549 Unchanged,
2550 /// Additions were applied and verified.
2551 Applied,
2552}
2553
2554/// A GitHub single-select option's opaque GraphQL node identifier.
2555#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2556#[serde(transparent)]
2557pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2558
2559impl TryFrom<String> for StatusOptionId {
2560 type Error = String;
2561
2562 fn try_from(id: String) -> Result<Self, Self::Error> {
2563 if id.trim().is_empty() {
2564 return Err("a GitHub Status option id cannot be blank".to_owned());
2565 }
2566 Ok(Self(id))
2567 }
2568}
2569
2570/// One existing or proposed option in a guarded Status-field update.
2571#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2572pub struct StatusOption {
2573 /// GitHub's stable id.
2574 pub id: StatusOptionId,
2575 /// The visible option name.
2576 pub name: ColumnName,
2577 /// GitHub's single-select color token.
2578 pub color: StatusOptionColor,
2579 /// The option description, including an empty one.
2580 pub description: String,
2581}
2582
2583/// One board item's Status assignment, retained as recovery data.
2584#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2585pub struct StatusAssignment {
2586 /// The project item id whose assignment this is.
2587 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2588 // carried verbatim as operator recovery data; introducing a semantic type would claim
2589 // validation rules GitHub does not publish and no operation here interprets.
2590 pub item_id: String,
2591 /// The selected option, absent when the item has no status.
2592 #[serde(skip_serializing_if = "Option::is_none")]
2593 pub option: Option<AssignedStatusOption>,
2594}
2595
2596/// The inseparable id and name of an assigned option.
2597#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2598pub struct AssignedStatusOption {
2599 /// GitHub's stable id.
2600 pub id: StatusOptionId,
2601 /// The visible name.
2602 pub name: ColumnName,
2603}
2604
2605/// The plan and verified outcome of reconciling configured Status options.
2606#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2607pub struct StatusOptionsReport {
2608 /// The configured source name.
2609 pub source: SourceName,
2610 /// Configured option names absent before the operation.
2611 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2612 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2613 // serialized string here preserves the report's intentionally simple public contract.
2614 pub missing: Vec<String>,
2615 /// What the requested operation did.
2616 pub outcome: StatusOptionsOutcome,
2617 /// The complete option list observed before any mutation.
2618 pub existing: Vec<StatusOption>,
2619}
2620
2621#[derive(Debug, Clone, PartialEq, Eq)]
2622struct StatusSnapshot {
2623 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2624 // passed back as the mutation's project identity; a newtype could enforce no stronger
2625 // invariant because GitHub publishes no grammar for it.
2626 board_id: String,
2627 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2628 // passed back as the mutation's field identity; a newtype could enforce no stronger
2629 // invariant because GitHub publishes no grammar for it.
2630 field_id: String,
2631 options: Vec<StatusOption>,
2632 assignments: Vec<StatusAssignment>,
2633}
2634
2635/// The name of the board field a status is held in.
2636const STATUS_FIELD: &str = "Status";
2637
2638/// Every item's value of each field `report` names, as it stood before the setup wrote
2639/// anything — what a person puts back when the setup is refused part way.
2640fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2641 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2642 .fields
2643 .iter()
2644 .map(|field| (field.field.name(), before.assignments(field.field)))
2645 .collect();
2646 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2647 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2648 })
2649}
2650
2651/// One board field the guarded setup reads and writes — every one it reads, and the only
2652/// ones it writes.
2653#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2654pub enum BoardField {
2655 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2656 Status,
2657 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2658 Priority,
2659}
2660
2661impl BoardField {
2662 /// The field's name on the board.
2663 #[must_use]
2664 pub const fn name(self) -> &'static str {
2665 match self {
2666 Self::Status => STATUS_FIELD,
2667 Self::Priority => PRIORITY_FIELD,
2668 }
2669 }
2670
2671 /// The field a board calls `name`, or `None` for one this setup does not own.
2672 fn named(name: &str) -> Option<Self> {
2673 [Self::Status, Self::Priority]
2674 .into_iter()
2675 .find(|field| field.name() == name)
2676 }
2677}
2678
2679/// What the guarded setup did to one field.
2680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2681#[serde(rename_all = "kebab-case")]
2682pub enum FieldOutcome {
2683 /// A read-only plan.
2684 Planned,
2685 /// Apply found the field there with every configured option.
2686 Unchanged,
2687 /// Missing options were added to the field that was there, and verified.
2688 Applied,
2689 /// The field was not there; it was created holding the configured options, and verified.
2690 Created,
2691}
2692
2693/// One field's plan, or its verified outcome.
2694#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2695pub struct FieldReport {
2696 /// Which field.
2697 pub field: BoardField,
2698 /// Whether the board had the field before the operation.
2699 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2700 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2701 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2702 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2703 // one constructor, and it derives `outcome` from `exists` in one match.
2704 pub exists: bool,
2705 /// Configured option names the field lacked before the operation — every one of them,
2706 /// in the order a new field lists them, when the field was not there at all.
2707 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2708 // mapping name and has therefore already passed its nonblank validation; the serialized
2709 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2710 pub missing: Vec<String>,
2711 /// For the `Status` field, which item kind each missing name is configured for: one
2712 /// entry per kind `status_mapping` names a missing option for, task before project, each
2713 /// listing that kind's missing names in category order. A name both kinds use is in
2714 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2715 /// `Priority`, which only a task holds.
2716 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2717 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2718 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2719 #[schemars(!skip_serializing_if)]
2720 pub kinds: Vec<KindMissing>,
2721 /// What the requested operation did.
2722 pub outcome: FieldOutcome,
2723 /// The field's complete option list observed before any mutation; empty when the field
2724 /// was not there.
2725 pub existing: Vec<StatusOption>,
2726}
2727
2728/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2729#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2730pub struct KindMissing {
2731 /// The kind these names are configured for.
2732 pub kind: ItemKind,
2733 /// The names that kind maps a category to and the field lacked, in category order.
2734 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2735 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2736 // intentionally simple public contract.
2737 pub missing: Vec<String>,
2738}
2739
2740/// The plan and verified outcome of setting up every field a source's configuration names.
2741#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2742pub struct FieldsReport {
2743 /// The configured source name.
2744 pub source: SourceName,
2745 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2746 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2747 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2748 // per field would change a published JSON shape. The states the list could hold and the
2749 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2750 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2751 pub fields: Vec<FieldReport>,
2752}
2753
2754/// Which options one field is configured with, in the order a new field would list them.
2755struct FieldPlan {
2756 field: BoardField,
2757 wanted: Vec<String>,
2758}
2759
2760/// One single-select field as the guarded setup snapshots it.
2761#[derive(Debug, Clone, PartialEq, Eq)]
2762struct SnapshotField {
2763 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2764 // passed back as the mutation's field identity; a newtype could enforce no stronger
2765 // invariant because GitHub publishes no grammar for it.
2766 field_id: String,
2767 options: Vec<StatusOption>,
2768}
2769
2770/// Every single-select field of a board and every item's value of each.
2771#[derive(Debug, Clone, PartialEq, Eq)]
2772struct BoardSnapshot {
2773 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2774 // passed back as the mutation's project identity; a newtype could enforce no stronger
2775 // invariant because GitHub publishes no grammar for it.
2776 board_id: String,
2777 fields: BTreeMap<BoardField, SnapshotField>,
2778 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2779 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2780}
2781
2782impl BoardSnapshot {
2783 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2784 /// carries.
2785 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2786 self.items
2787 .iter()
2788 .map(|(item_id, values)| StatusAssignment {
2789 item_id: item_id.clone(),
2790 option: values.get(&field).cloned(),
2791 })
2792 .collect()
2793 }
2794}
2795
2796impl GitHubProjectsSource {
2797 /// Report missing configured Status options and, when `apply` is true, add them with
2798 /// a whole-list mutation that preserves every existing id and verifies the result.
2799 ///
2800 /// # Errors
2801 ///
2802 /// Refuses a board without a single-select `Status` field. A post-write difference in
2803 /// any pre-existing option id or item assignment is refused with the complete pre-write
2804 /// assignment snapshot in the diagnostic for recovery.
2805 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2806 // successful mutation, both drift refusals, source selection, missing Status, casing,
2807 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2808 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2809 // responses from entering the defensive malformed-response branches below.
2810 pub async fn status_options(
2811 &self,
2812 mode: StatusOptionsMode,
2813 ) -> Result<StatusOptionsReport, SourceError> {
2814 let before = self.status_snapshot().await?;
2815 // A terminal category's option is as configured as an open one's: a terminal
2816 // write validates it before closing and refuses when the board lacks it. Both
2817 // kinds' names are options of the one field, so both are asked for.
2818 let missing = self
2819 .statuses
2820 .wanted()
2821 .into_iter()
2822 .filter(|wanted| {
2823 !before
2824 .options
2825 .iter()
2826 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2827 })
2828 .collect::<Vec<_>>();
2829 let report = StatusOptionsReport {
2830 source: self.name.clone(),
2831 missing: missing.clone(),
2832 outcome: match (mode, missing.is_empty()) {
2833 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2834 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2835 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2836 },
2837 existing: before.options.clone(),
2838 };
2839 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2840 return Ok(report);
2841 }
2842 let mut options = before
2843 .options
2844 .iter()
2845 .map(|option| {
2846 json!({
2847 "id": option.id, "name": option.name, "color": option.color,
2848 "description": option.description,
2849 })
2850 })
2851 .collect::<Vec<_>>();
2852 options.extend(missing.iter().map(|name| {
2853 json!({
2854 "name": name, "color": "GRAY", "description": ""
2855 })
2856 }));
2857 self.graphql(
2858 graphql::STATUS_OPTIONS_UPDATE,
2859 json!({"input": {
2860 "projectId": before.board_id, "fieldId": before.field_id,
2861 "singleSelectOptions": options,
2862 }}),
2863 )
2864 .await?;
2865 let after = self.status_snapshot().await?;
2866 let options_preserved = before
2867 .options
2868 .iter()
2869 .all(|old| after.options.iter().any(|new| new == old));
2870 let additions_present = missing.iter().all(|wanted| {
2871 after
2872 .options
2873 .iter()
2874 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2875 });
2876 if !options_preserved || !additions_present || after.assignments != before.assignments {
2877 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2878 SourceError::Malformed {
2879 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2880 }
2881 })?;
2882 return Err(SourceError::Refused {
2883 message: format!(
2884 "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}"
2885 ),
2886 });
2887 }
2888 Ok(report)
2889 }
2890
2891 /// A fresh snapshot of the Status field and every board item's assignment of it.
2892 ///
2893 /// # Errors
2894 ///
2895 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2896 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2897 // Status alone, as this operation has always read it: a `Priority` field is another
2898 // operation's, so nothing about it can refuse this one.
2899 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2900 let field = board
2901 .fields
2902 .remove(&BoardField::Status)
2903 .ok_or_else(|| self.no_status_field())?;
2904 Ok(StatusSnapshot {
2905 assignments: board.assignments(BoardField::Status),
2906 board_id: board.board_id,
2907 field_id: field.field_id,
2908 options: field.options,
2909 })
2910 }
2911
2912 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2913 fn no_status_field(&self) -> SourceError {
2914 SourceError::Refused {
2915 message: format!("source {} board has no Status field", self.name),
2916 }
2917 }
2918
2919 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2920 // the real CLI loopback journey, including pagination. The individual malformed guards
2921 // are defensive validation of a schema-pinned third-party response, not separate user
2922 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2923 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2924 /// every board item's value of each, walked to the end of the board's items. A field not
2925 /// in `owned` is read past whatever it holds.
2926 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2927 let mut after: Option<String> = None;
2928 let mut snapshot: Option<BoardSnapshot> = None;
2929 loop {
2930 let data = self
2931 .graphql(
2932 graphql::STATUS_OPTIONS_SNAPSHOT,
2933 json!({
2934 "owner": self.owner, "number": self.project_number,
2935 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2936 }),
2937 )
2938 .await?;
2939 let board = data
2940 .pointer("/owner/projectV2")
2941 .filter(|board| board.is_object())
2942 .ok_or_else(|| SourceError::Refused {
2943 message: format!(
2944 "source {} has no accessible GitHub Projects board",
2945 self.name
2946 ),
2947 })?;
2948 if board
2949 .pointer("/fields/pageInfo/hasNextPage")
2950 .and_then(Value::as_bool)
2951 != Some(false)
2952 {
2953 return Err(SourceError::Malformed {
2954 message:
2955 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2956 .into(),
2957 });
2958 }
2959 let mut fields = BTreeMap::new();
2960 // Only the fields this setup owns, by name: a node the single-select fragment did not
2961 // match carries no name, and a person's own single-select field — a `Size`, a
2962 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2963 // `Status` or `Priority` field without its options is malformed, not absent.
2964 // 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.
2965 for (owned, field) in board
2966 .pointer("/fields/nodes")
2967 .and_then(Value::as_array)
2968 .ok_or_else(|| SourceError::Malformed {
2969 message: "GitHub project fields.nodes is not an array".into(),
2970 })?
2971 .iter()
2972 .filter_map(|field| {
2973 let named = BoardField::named(field.get("name")?.as_str()?)?;
2974 owned.contains(&named).then_some((named, field))
2975 })
2976 {
2977 let options = field
2978 .get("options")
2979 .and_then(Value::as_array)
2980 .ok_or_else(|| SourceError::Malformed {
2981 message: "GitHub single-select field options is not an array".into(),
2982 })?
2983 .iter()
2984 .map(|option| {
2985 Ok(StatusOption {
2986 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2987 .map_err(|message| SourceError::Malformed { message })?,
2988 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2989 .map_err(|message| SourceError::Malformed {
2990 message: format!(
2991 "GitHub single-select option name is invalid: {message}"
2992 ),
2993 })?,
2994 color: serde_json::from_value(
2995 option.get("color").cloned().unwrap_or(Value::Null),
2996 )
2997 .map_err(|error| {
2998 SourceError::Malformed {
2999 message: format!(
3000 "GitHub single-select option color is invalid: {error}"
3001 ),
3002 }
3003 })?,
3004 description: optional_str(option, "description")?
3005 .unwrap_or_default()
3006 .to_owned(),
3007 })
3008 })
3009 .collect::<Result<Vec<_>, SourceError>>()?;
3010 let snapshot = SnapshotField {
3011 field_id: required_nonblank_str(field, "id")?.to_owned(),
3012 options,
3013 };
3014 // A board's field names are unique, so a second one is an answer that cannot
3015 // say which field the setup would act on — refused rather than one chosen.
3016 if fields.insert(owned, snapshot).is_some() {
3017 return Err(SourceError::Malformed {
3018 message: format!(
3019 "GitHub answered two {} fields for this board",
3020 owned.name()
3021 ),
3022 });
3023 }
3024 }
3025 let board_id = required_nonblank_str(board, "id")?.to_owned();
3026 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3027 board_id,
3028 fields,
3029 items: Vec::new(),
3030 });
3031 let items = board
3032 .pointer("/items/nodes")
3033 .and_then(Value::as_array)
3034 .ok_or_else(|| SourceError::Malformed {
3035 message: "GitHub project items.nodes is not an array".into(),
3036 })?;
3037 for item in items {
3038 let field_values =
3039 item.get("fieldValues")
3040 .ok_or_else(|| SourceError::Malformed {
3041 message: "GitHub project item is missing fieldValues".into(),
3042 })?;
3043 if field_values
3044 .pointer("/pageInfo/hasNextPage")
3045 .and_then(Value::as_bool)
3046 != Some(false)
3047 {
3048 return Err(SourceError::Malformed {
3049 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3050 });
3051 }
3052 let values = item
3053 .pointer("/fieldValues/nodes")
3054 .and_then(Value::as_array)
3055 .ok_or_else(|| SourceError::Malformed {
3056 message: "GitHub project item fieldValues.nodes is not an array".into(),
3057 })?;
3058 let item_id = required_nonblank_str(item, "id")?;
3059 let mut assigned = BTreeMap::new();
3060 for value in values {
3061 let Some(field) = value
3062 .pointer("/field/name")
3063 .and_then(Value::as_str)
3064 .and_then(BoardField::named)
3065 .filter(|field| owned.contains(field))
3066 else {
3067 continue;
3068 };
3069 let held = assigned.insert(
3070 field,
3071 AssignedStatusOption {
3072 id: StatusOptionId::try_from(
3073 required_str(value, "optionId")?.to_owned(),
3074 )
3075 .map_err(|message| SourceError::Malformed { message })?,
3076 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3077 .map_err(|message| SourceError::Malformed {
3078 message: format!(
3079 "GitHub assigned {} name is invalid: {message}",
3080 field.name()
3081 ),
3082 })?,
3083 },
3084 );
3085 // An item holds one value of a field, so a second one leaves no way to
3086 // tell which it holds — and a verification or recovery built on either
3087 // could restore the wrong one.
3088 if held.is_some() {
3089 return Err(SourceError::Malformed {
3090 message: format!(
3091 "GitHub answered two {} values for board item {item_id}",
3092 field.name()
3093 ),
3094 });
3095 }
3096 }
3097 current.items.push((item_id.to_owned(), assigned));
3098 }
3099 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3100 message: "GitHub project is missing items".into(),
3101 })?;
3102 let has_next = page
3103 .pointer("/pageInfo/hasNextPage")
3104 .and_then(Value::as_bool)
3105 .ok_or_else(|| SourceError::Malformed {
3106 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3107 })?;
3108 if !has_next {
3109 break;
3110 }
3111 let next =
3112 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3113 validate_cursor_progress(after.as_deref(), next)?;
3114 after = Some(next.to_owned());
3115 }
3116 snapshot.ok_or_else(|| SourceError::Malformed {
3117 message: "GitHub returned no board field snapshot".into(),
3118 })
3119 }
3120 // llmlint: ignore-end[changed_behavior_has_e2e]
3121
3122 /// Report every board field this source's configuration names and, with
3123 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3124 /// the `Priority` field when the board has none.
3125 ///
3126 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3127 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3128 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3129 /// color and description: the whole option list goes back with every existing id, because
3130 /// a re-minted id clears every item's value.
3131 ///
3132 /// # Errors
3133 ///
3134 /// Refuses a board without a single-select `Status` field. After an apply the board is
3135 /// read again, and a pre-existing option or any item's value of either field that moved is
3136 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3137 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3138 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3139 // with no Status field and a non-github-projects source through the compiled CLI against
3140 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3141 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3142 let owned: Vec<BoardField> = if self.priorities.is_some() {
3143 vec![BoardField::Status, BoardField::Priority]
3144 } else {
3145 vec![BoardField::Status]
3146 };
3147 let before = self.board_snapshot(&owned).await?;
3148 let mut plans = vec![FieldPlan {
3149 field: BoardField::Status,
3150 wanted: self.statuses.wanted(),
3151 }];
3152 if !before.fields.contains_key(&BoardField::Status) {
3153 return Err(self.no_status_field());
3154 }
3155 if let Some(mapping) = &self.priorities {
3156 plans.push(FieldPlan {
3157 field: BoardField::Priority,
3158 wanted: mapping.names().map(str::to_owned).collect(),
3159 });
3160 }
3161 // The snapshot reads single-select fields alone, so a field it did not find may still
3162 // be on the board under the name, of another type: creating one beside it would fail
3163 // part way, or leave two fields of one name. Asked of the board's own field list, and
3164 // only when a field is missing.
3165 if plans
3166 .iter()
3167 .any(|plan| !before.fields.contains_key(&plan.field))
3168 {
3169 let board = self.board_fields().await?;
3170 for plan in plans
3171 .iter()
3172 .filter(|plan| !before.fields.contains_key(&plan.field))
3173 {
3174 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3175 return Err(SourceError::Refused {
3176 message: format!(
3177 "source {}'s board has a {} field that is not a single-select field \
3178 (it is a {}), so it cannot hold this source's options; next: rename \
3179 or remove that field, then run this again",
3180 self.name,
3181 plan.field.name(),
3182 optional_str(field, "__typename")?.unwrap_or("field of another type")
3183 ),
3184 });
3185 }
3186 }
3187 }
3188 let mut reports = Vec::new();
3189 for plan in &plans {
3190 let held = before.fields.get(&plan.field);
3191 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3192 let mut missing: Vec<String> = Vec::new();
3193 for wanted in &plan.wanted {
3194 let present = existing
3195 .iter()
3196 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3197 || missing
3198 .iter()
3199 .any(|named| named.eq_ignore_ascii_case(wanted));
3200 if !present {
3201 missing.push(wanted.clone());
3202 }
3203 }
3204 let kinds = match plan.field {
3205 BoardField::Status => self.statuses.missing_by_kind(&existing),
3206 BoardField::Priority => Vec::new(),
3207 };
3208 reports.push(FieldReport {
3209 field: plan.field,
3210 exists: held.is_some(),
3211 kinds,
3212 outcome: match (mode, held.is_some(), missing.is_empty()) {
3213 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3214 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3215 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3216 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3217 },
3218 missing,
3219 existing,
3220 });
3221 }
3222 let report = FieldsReport {
3223 source: self.name.clone(),
3224 fields: reports,
3225 };
3226 let writes: Vec<&FieldReport> = report
3227 .fields
3228 .iter()
3229 .filter(|field| !field.missing.is_empty() || !field.exists)
3230 .collect();
3231 if mode == SetupMode::Plan || writes.is_empty() {
3232 return Ok(report);
3233 }
3234 let mut landed: Vec<&str> = Vec::new();
3235 for field in &writes {
3236 let added = field
3237 .missing
3238 .iter()
3239 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3240 let sent = match before.fields.get(&field.field) {
3241 Some(held) => {
3242 let mut options = held
3243 .options
3244 .iter()
3245 .map(|option| {
3246 json!({
3247 "id": option.id, "name": option.name, "color": option.color,
3248 "description": option.description,
3249 })
3250 })
3251 .collect::<Vec<_>>();
3252 options.extend(added);
3253 self.graphql(
3254 graphql::STATUS_OPTIONS_UPDATE,
3255 json!({"input": {
3256 "projectId": before.board_id, "fieldId": held.field_id,
3257 "singleSelectOptions": options,
3258 }}),
3259 )
3260 .await
3261 }
3262 None => {
3263 self.graphql(
3264 graphql::CREATE_FIELD,
3265 json!({"input": {
3266 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3267 "name": field.field.name(),
3268 "singleSelectOptions": added.collect::<Vec<_>>(),
3269 }}),
3270 )
3271 .await
3272 }
3273 };
3274 // A mutation that failed does not establish that GitHub left its field as it was,
3275 // so every failure from here on carries the recovery data a drift refusal does.
3276 match sent {
3277 Ok(_) => landed.push(field.field.name()),
3278 Err(error) => {
3279 let changed = if landed.is_empty() {
3280 String::new()
3281 } else {
3282 format!("changed the {} field and then ", landed.join(" and "))
3283 };
3284 return Err(SourceError::Refused {
3285 message: format!(
3286 "the guarded field setup {changed}failed on the {} field, which it may \
3287 have changed part way: {error}; the pre-write item assignments \
3288 are:\n{}",
3289 field.field.name(),
3290 recovery(&report, &before)?
3291 ),
3292 });
3293 }
3294 }
3295 }
3296 // The board has been written, so a verification read that fails leaves it unverified
3297 // rather than unchanged, and says what to put back.
3298 let after = match self.board_snapshot(&owned).await {
3299 Ok(after) => after,
3300 Err(error) => {
3301 return Err(SourceError::Refused {
3302 message: format!(
3303 "the guarded field setup changed the {} field and then could not read the \
3304 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3305 landed.join(" and "),
3306 recovery(&report, &before)?
3307 ),
3308 });
3309 }
3310 };
3311 let mut moved = Vec::new();
3312 for field in &report.fields {
3313 let name = field.field.name();
3314 let now = after
3315 .fields
3316 .get(&field.field)
3317 .map(|held| held.options.as_slice())
3318 .unwrap_or_default();
3319 if !field.existing.iter().all(|old| now.contains(old)) {
3320 moved.push(format!(
3321 "a pre-existing {name} option id, name, color or description"
3322 ));
3323 }
3324 if !field.missing.iter().all(|wanted| {
3325 now.iter()
3326 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3327 }) {
3328 moved.push(format!("an added {name} option"));
3329 }
3330 if after.assignments(field.field) != before.assignments(field.field) {
3331 moved.push(format!("an item's {name} value"));
3332 }
3333 }
3334 if !moved.is_empty() {
3335 return Err(SourceError::Refused {
3336 message: format!(
3337 "GitHub changed {} after the guarded field setup; the pre-write item \
3338 assignments are:\n{}",
3339 moved.join(", "),
3340 recovery(&report, &before)?
3341 ),
3342 });
3343 }
3344 Ok(report)
3345 }
3346
3347 /// Validate configuration and capture the named credential without exposing it.
3348 ///
3349 /// # Errors
3350 ///
3351 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3352 /// [`SourceError::Auth`] when the named credential is missing or empty.
3353 pub fn new(
3354 name: &SourceName,
3355 config: GitHubProjectsConfig,
3356 secrets: &dyn SecretResolver,
3357 ) -> Result<Self, SourceError> {
3358 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3359 }
3360
3361 /// The same, recording every request it sends into an accounting the caller holds too.
3362 ///
3363 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3364 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3365 /// up — passes the one it records those into, so the session total accounts for the
3366 /// whole session rather than for this source's share of it.
3367 ///
3368 /// # Errors
3369 ///
3370 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3371 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3372 pub fn recording_into(
3373 name: &SourceName,
3374 config: GitHubProjectsConfig,
3375 secrets: &dyn SecretResolver,
3376 ledger: Arc<Accounting>,
3377 ) -> Result<Self, SourceError> {
3378 if !valid_github_owner(&config.owner) {
3379 return Err(SourceError::Config {
3380 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3381 });
3382 }
3383 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3384 return Err(SourceError::Config {
3385 message: format!("project_number must be between 1 and {}", i32::MAX),
3386 });
3387 }
3388 if !valid_environment_name(&config.token_env) {
3389 return Err(SourceError::Config {
3390 message: "token_env must be a valid environment-variable name".into(),
3391 });
3392 }
3393 let repository = config
3394 .repository
3395 .as_deref()
3396 .map(RepositoryTarget::parse)
3397 .transpose()?;
3398 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3399 message: format!("endpoint is not a valid URL: {e}"),
3400 })?;
3401 if endpoint.scheme() != "https"
3402 && !(endpoint.scheme() == "http"
3403 && endpoint
3404 .host_str()
3405 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3406 {
3407 return Err(SourceError::Config {
3408 message:
3409 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3410 .into(),
3411 });
3412 }
3413 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3414 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),
3415 })?;
3416 Ok(Self {
3417 name: name.clone(),
3418 owner: config.owner,
3419 project_number: config.project_number,
3420 repository,
3421 endpoint,
3422 token,
3423 credential_name: config.token_env,
3424 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3425 priorities: config
3426 .priority_mapping
3427 .map(|mapping| PriorityMapping::resolve(mapping, name))
3428 .transpose()?,
3429 client: Client::builder()
3430 .user_agent("onetaskgraph")
3431 .build()
3432 .map_err(|e| SourceError::Config {
3433 message: format!("cannot build HTTP client: {e}"),
3434 })?,
3435 created: Mutex::new(Vec::new()),
3436 updated: Mutex::new(Vec::new()),
3437 pacing: Pacing::resolve(config.pacing, name)?,
3438 last_mutation: Mutex::new(None),
3439 board_cache: Mutex::new(None),
3440 search_cache: Mutex::new(None),
3441 narrowed_cache: Mutex::new(BTreeMap::new()),
3442 resolved_cache: Mutex::new(BTreeMap::new()),
3443 search_next: Mutex::new(BTreeMap::new()),
3444 fields_cache: Mutex::new(None),
3445 repository_cache: Mutex::new(BTreeMap::new()),
3446 ledger,
3447 })
3448 }
3449
3450 /// A snapshot of every request this source has sent, and what each cost.
3451 ///
3452 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3453 /// of them can sit side by side. When this source was built with
3454 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3455 /// point of building it that way.
3456 #[must_use]
3457 pub fn accounting(&self) -> accounting::Session {
3458 self.ledger.snapshot()
3459 }
3460
3461 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3462 /// rate limit rather than handing it straight back as an error.
3463 ///
3464 /// Retrying is safe for every document here, including the mutations, and the reason
3465 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3466 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3467 /// this replays has already taken effect. An outcome this source cannot know — the
3468 /// send failed, or the body could not be read, so the mutation may well have landed —
3469 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3470 /// attempt. A duplicate write would come from replaying one of those, and none is
3471 /// replayed.
3472 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3473 if is_mutation(query)
3474 && ![
3475 graphql::ADD_COMMENT,
3476 graphql::UPDATE_COMMENT,
3477 graphql::DELETE_COMMENT,
3478 ]
3479 .contains(&query)
3480 {
3481 let mut cache = self.resolved_cache()?;
3482 for argument in ["input", "second", "third", "clear"] {
3483 if let Some(input) = variables.get(argument) {
3484 cache.retain(|id, item| {
3485 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3486 input
3487 .get(key)
3488 .and_then(Value::as_str)
3489 .is_some_and(|value| value == id.0 || value == item.item_id)
3490 })
3491 });
3492 }
3493 }
3494 }
3495 let doing = operation_description(query);
3496 let mut waited = Duration::ZERO;
3497 let mut waits = 0_u32;
3498 let mut backoff = self.pacing.retry_backoff;
3499 loop {
3500 if is_mutation(query) {
3501 let spacing = self.reserve_mutation_slot();
3502 if !spacing.is_zero() {
3503 tokio::time::sleep(spacing).await;
3504 }
3505 }
3506 let attempt = self.send_once(query, &variables).await;
3507 if is_mutation(query) {
3508 self.finish_mutation();
3509 }
3510 let limited = match attempt {
3511 Ok(data) => return Ok(data),
3512 Err(Attempt::Failed(error)) => return Err(error),
3513 Err(Attempt::Limited(limited)) => limited,
3514 };
3515 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3516 // move that extends a secondary limit, so a hint below the schedule's own next
3517 // wait is raised to it.
3518 let wait = match limited.hint {
3519 Some(hint) => Duration::from_secs(hint).max(backoff),
3520 None => backoff,
3521 };
3522 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3523 // A wait of nothing spends none of the budget, so it is exhaustion rather
3524 // than a retry. `Pacing::resolve` rules out every way of configuring one
3525 // except a budget of zero, where reporting the first refusal is the ask.
3526 if wait.is_zero() || wait > remaining {
3527 return Err(limited.exhausted(
3528 doing,
3529 waits,
3530 waited,
3531 wait,
3532 self.pacing.retry_budget,
3533 ));
3534 }
3535 tokio::time::sleep(wait).await;
3536 waited += wait;
3537 waits += 1;
3538 backoff = backoff.saturating_mul(2);
3539 }
3540 }
3541
3542 /// The next moment a content-creating mutation may leave this source, as a wait from
3543 /// now.
3544 ///
3545 /// The slot is reserved under the lock and the waiting happens outside it, so two
3546 /// callers take two slots rather than the same one — and no lock is held across an
3547 /// await.
3548 ///
3549 /// The moment it is spaced from is the previous mutation's *completion*, which
3550 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3551 /// own is the wrong thing to measure from.
3552 fn reserve_mutation_slot(&self) -> Duration {
3553 if self.pacing.min_mutation_interval.is_zero() {
3554 return Duration::ZERO;
3555 }
3556 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3557 // it would turn an earlier failure into a second one for no gain.
3558 let mut last = self
3559 .last_mutation
3560 .lock()
3561 .unwrap_or_else(std::sync::PoisonError::into_inner);
3562 let now = Instant::now();
3563 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3564 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3565 let at = last.map_or(now, |previous| {
3566 previous
3567 .checked_add(self.pacing.min_mutation_interval)
3568 .map_or(now, |earliest| earliest.max(now))
3569 });
3570 *last = Some(at);
3571 at.saturating_duration_since(now)
3572 }
3573
3574 /// Record that a content-creating mutation has finished, so the next one is spaced
3575 /// from here rather than from the moment this one was released.
3576 ///
3577 /// This source can only choose when a request *departs*; the limiter counts when it
3578 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3579 /// departure from the last therefore hands the limiter a gap of the interval less that
3580 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3581 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3582 /// slower machine while passing on a quick one.
3583 ///
3584 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3585 /// previous request had already arrived before its response came back, so its arrival
3586 /// is no later than this moment, and the next mutation is released at least the
3587 /// interval after this moment and arrives no earlier than it is released: the gap the
3588 /// limiter measures is therefore at least the interval, whatever transit costs and on
3589 /// whatever platform. The price is that a mutation's own round trip no longer counts
3590 /// towards its spacing, which makes this source slightly slower than the configured
3591 /// rate rather than slightly faster — the safe side of a limit that punishes being
3592 /// wrong by refusing reads for the next fifty minutes.
3593 ///
3594 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3595 /// and one that never left costs only a wait nobody needed.
3596 fn finish_mutation(&self) {
3597 if self.pacing.min_mutation_interval.is_zero() {
3598 return;
3599 }
3600 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3601 let mut last = self
3602 .last_mutation
3603 .lock()
3604 .unwrap_or_else(std::sync::PoisonError::into_inner);
3605 let now = Instant::now();
3606 // `max` rather than an assignment: a concurrent caller may already have reserved a
3607 // slot further out, and completing this request must never pull that slot back in.
3608 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3609 }
3610
3611 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3612 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3613 ///
3614 /// This is the one place a request leaves this crate, which is why the accounting is
3615 /// here rather than at each of the callers: a read path added later is counted without
3616 /// anybody remembering to count it, and
3617 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3618 /// when one is not.
3619 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3620 let Attempted {
3621 result,
3622 limits,
3623 reported_cost,
3624 } = self.attempt(query, variables).await;
3625 // No `otherwise` name: every document this source sends is one of its own, and the
3626 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3627 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3628 let outcome = match &result {
3629 Ok(_) => accounting::Outcome::Answered,
3630 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3631 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3632 };
3633 self.ledger.record(sending.finished(outcome, limits));
3634 result
3635 }
3636
3637 /// The attempt itself, with what its response said about the rate limit alongside.
3638 ///
3639 /// The two are returned together rather than recorded here because every one of the
3640 /// early exits below is a different outcome, and a record written at each of them is a
3641 /// record one of them can be added without.
3642 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3643 let mut limits = accounting::RateLimit::default();
3644 let mut reported_cost = None;
3645 let result = self
3646 .attempted(query, variables, &mut limits, &mut reported_cost)
3647 .await;
3648 Attempted {
3649 result,
3650 limits,
3651 reported_cost,
3652 }
3653 }
3654
3655 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3656 async fn attempted(
3657 &self,
3658 query: &str,
3659 variables: &Value,
3660 limits: &mut accounting::RateLimit,
3661 reported_cost: &mut Option<u64>,
3662 ) -> Result<Value, Attempt> {
3663 let response = self
3664 .client
3665 .post(self.endpoint.clone())
3666 .bearer_auth(self.token.expose_secret())
3667 .json(&json!({"query": query, "variables": variables}))
3668 .send()
3669 .await
3670 .map_err(|e| {
3671 Attempt::Failed(SourceError::Unavailable {
3672 message: format!("GitHub GraphQL request failed: {e}"),
3673 })
3674 })?;
3675 let status = response.status();
3676 let header = |name: &str| whole_seconds(response.headers().get(name));
3677 *limits = accounting::RateLimit::read(|name| {
3678 response
3679 .headers()
3680 .get(name)
3681 .and_then(|value| value.to_str().ok())
3682 .map(str::to_owned)
3683 });
3684 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3685 // that are not text at all — is "not known to be exhausted". This never makes a
3686 // response a refusal on its own: it says which limiter a refusal is attributed to
3687 // and where its hint comes from, so a value this cannot read costs a hint rather
3688 // than an answer.
3689 let exhausted = response
3690 .headers()
3691 .get("x-ratelimit-remaining")
3692 .and_then(|value| value.to_str().ok())
3693 == Some("0");
3694 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3695 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3696 // which is the same question answered as an absolute time. Nothing else here is a
3697 // hint, and a schedule is what answers a refusal that carries none.
3698 let hint = header("retry-after").or_else(|| {
3699 exhausted
3700 .then(|| header("x-ratelimit-reset"))
3701 .flatten()
3702 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3703 });
3704 // Read before it is parsed, because the evidence which tells a secondary rate
3705 // limit from a rejected credential is in the body of a response whose status says
3706 // only "forbidden" — and a non-success response was never parsed at all.
3707 let body = response.text().await.map_err(|e| {
3708 Attempt::Failed(SourceError::Unavailable {
3709 message: format!("GitHub GraphQL response could not be read: {e}"),
3710 })
3711 })?;
3712 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3713 return Err(Attempt::Limited(Limited { limiter, hint }));
3714 }
3715 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3716 return Err(Attempt::Failed(SourceError::Auth {
3717 message: format!(
3718 "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"
3719 ),
3720 }));
3721 }
3722 if !status.is_success() {
3723 return Err(Attempt::Failed(SourceError::Unavailable {
3724 message: format!("GitHub GraphQL returned HTTP {status}"),
3725 }));
3726 }
3727 // GitHub reports what a call cost only when the document asked it to, and no
3728 // document this source sends does — so this is `None` here and carries the figure
3729 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3730 // pick up is a `dryRun` probe's cost, which is some other document's.
3731 *reported_cost = serde_json::from_str::<Value>(&body)
3732 .ok()
3733 .as_ref()
3734 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3735 .and_then(Value::as_u64);
3736 self.answer(&body).map_err(Attempt::Failed)
3737 }
3738
3739 /// What one successful HTTP response says, once its GraphQL errors are read.
3740 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3741 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3742 message: format!("GitHub returned invalid JSON: {e}"),
3743 })?;
3744 let errors = body
3745 .get("errors")
3746 .map(|value| {
3747 value.as_array().ok_or_else(|| SourceError::Malformed {
3748 message: "GitHub response errors is not an array".into(),
3749 })
3750 })
3751 .transpose()?;
3752 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3753 let messages = errors
3754 .iter()
3755 .filter_map(|e| e.get("message").and_then(Value::as_str))
3756 .collect::<Vec<_>>()
3757 .join("; ");
3758 let message = if messages.is_empty() {
3759 "GitHub returned GraphQL errors".into()
3760 } else {
3761 messages
3762 };
3763 let normalized = message.to_ascii_lowercase();
3764 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3765 return Err(SourceError::Auth {
3766 message: format!(
3767 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3768 self.credential_name
3769 ),
3770 });
3771 }
3772 return Err(SourceError::Refused { message });
3773 }
3774 body.get("data")
3775 .filter(|data| data.is_object())
3776 .cloned()
3777 .ok_or_else(|| SourceError::Malformed {
3778 message: "GitHub response has no data object".into(),
3779 })
3780 }
3781
3782 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3783 // GraphQL cannot independently page them inside the outer item page. This source page is
3784 // deliberately bounded at that published maximum; the live drift journey exercises it.
3785 async fn board_page(
3786 &self,
3787 items_after: Option<&str>,
3788 items_first: u32,
3789 ) -> Result<Value, SourceError> {
3790 let data = self
3791 .graphql(
3792 graphql::BOARD,
3793 json!({"owner":self.owner,"number":self.project_number,
3794 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3795 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3796 )
3797 .await?;
3798 data.pointer("/owner/projectV2")
3799 .filter(|v| !v.is_null())
3800 .cloned()
3801 .ok_or_else(|| SourceError::Refused {
3802 message: format!(
3803 "GitHub project {}/{} was not found or is not visible to the token",
3804 self.owner, self.project_number
3805 ),
3806 })
3807 }
3808
3809 /// The search that finds the issues of this board, narrowed by `also` when it is
3810 /// given.
3811 ///
3812 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3813 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3814 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3815 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3816 /// from a task by the `parent` field each issue carries rather than by the search.
3817 fn board_search(&self, also: Option<&str>) -> String {
3818 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3819 match also {
3820 Some(also) => format!("{scope} {also}"),
3821 None => scope,
3822 }
3823 }
3824
3825 /// One issue this source reached directly, as the board item a read of the board would
3826 /// have produced — or `None` when this board does not hold it.
3827 ///
3828 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3829 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3830 /// item's own id, that item's field values, and the issue as its content. One resolver
3831 /// for both routes is what makes an issue read through a search, through its own node
3832 /// id, or through its project's sub-issues report the same title, the same status, the
3833 /// same labels and the same qualified id.
3834 ///
3835 /// An issue with no entry for *this* board is not this source's to report, which is
3836 /// what keeps an id naming some other repository's issue from being answered as an item
3837 /// of this board. That answer is given about an **exhausted** connection and never
3838 /// about an unread page: the entry is looked for on the page in hand, and only if that
3839 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3840 /// rest of it.
3841 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3842 if optional_str(issue, "__typename")? != Some("Issue") {
3843 return Ok(None);
3844 }
3845 let memberships = issue
3846 .get("projectItems")
3847 .ok_or_else(|| SourceError::Malformed {
3848 message: "GitHub issue is missing projectItems".into(),
3849 })?;
3850 let nodes = memberships
3851 .get("nodes")
3852 .and_then(Value::as_array)
3853 .ok_or_else(|| SourceError::Malformed {
3854 message: "GitHub issue projectItems.nodes is not an array".into(),
3855 })?;
3856 let held = match self.board_entry(nodes) {
3857 Some(held) => held.clone(),
3858 None => {
3859 let info = memberships
3860 .get("pageInfo")
3861 .ok_or_else(|| SourceError::Malformed {
3862 message: "GitHub issue projectItems has no pageInfo".into(),
3863 })?;
3864 // The page held no entry for this board. Whether that means the issue is
3865 // not on it is a question about the rest of the connection, and only a
3866 // connection with no rest answers it here.
3867 if !required_bool(info, "hasNextPage")? {
3868 return Ok(None);
3869 }
3870 let cursor = required_str(info, "endCursor")?;
3871 validate_cursor_progress(None, cursor)?;
3872 let issue_id = required_str(issue, "id")?;
3873 match self.board_membership(issue_id, cursor).await? {
3874 Some(held) => held,
3875 None => return Ok(None),
3876 }
3877 }
3878 };
3879 let item = json!({
3880 "id": required_str(&held, "id")?,
3881 "project": held.get("project"),
3882 "fieldValues": held.get("fieldValues"),
3883 "content": issue,
3884 });
3885 self.resolve(&item)
3886 }
3887
3888 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3889 ///
3890 /// One spelling of *which membership is this board's*, so the page a read carries and
3891 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3892 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3893 nodes.iter().find(|node| {
3894 node.pointer("/project/number").and_then(Value::as_u64)
3895 == Some(u64::from(self.project_number))
3896 })
3897 }
3898
3899 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3900 ///
3901 /// The recovery read: a page of memberships that holds no entry for this board says
3902 /// nothing about the memberships past it, so the connection is walked to exhaustion
3903 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3904 /// positive answer — the whole connection was read and no entry named this board —
3905 /// rather than a failure, and the walk is held to
3906 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3907 /// with a cursor that does not advance is refused instead of spun on.
3908 async fn board_membership(
3909 &self,
3910 issue: &str,
3911 after: &str,
3912 ) -> Result<Option<Value>, SourceError> {
3913 let mut after = after.to_owned();
3914 loop {
3915 let data = self
3916 .graphql(
3917 graphql::ISSUE_BOARD_ITEMS,
3918 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3919 "nestedFirst":NESTED_PAGE_SIZE}),
3920 )
3921 .await?;
3922 let Some(connection) = data
3923 .pointer("/node/projectItems")
3924 .filter(|value| !value.is_null())
3925 else {
3926 // The id resolved to nothing, or to something with no memberships to walk —
3927 // which is the same answer as a connection holding no entry for this board.
3928 return Ok(None);
3929 };
3930 let nodes = connection
3931 .get("nodes")
3932 .and_then(Value::as_array)
3933 .ok_or_else(|| SourceError::Malformed {
3934 message: "GitHub issue projectItems.nodes is not an array".into(),
3935 })?;
3936 if let Some(held) = self.board_entry(nodes) {
3937 return Ok(Some(held.clone()));
3938 }
3939 let info = connection
3940 .get("pageInfo")
3941 .ok_or_else(|| SourceError::Malformed {
3942 message: "GitHub issue projectItems has no pageInfo".into(),
3943 })?;
3944 let next = required_bool(info, "hasNextPage")?
3945 .then(|| required_str(info, "endCursor"))
3946 .transpose()?;
3947 match next {
3948 Some(next) => {
3949 validate_cursor_progress(Some(&after), next)?;
3950 after = next.to_owned();
3951 }
3952 None => return Ok(None),
3953 }
3954 }
3955 }
3956
3957 /// One page of a board-scoped issue search, and where the next page resumes.
3958 async fn search_page(
3959 &self,
3960 search: &str,
3961 first: u32,
3962 after: Option<&str>,
3963 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3964 let data = self
3965 .graphql(
3966 graphql::SEARCH_ISSUES,
3967 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3968 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3969 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3970 )
3971 .await?;
3972 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3973 message: "GitHub search response has no search connection".into(),
3974 })?;
3975 let mut found = Vec::new();
3976 for node in connection
3977 .get("nodes")
3978 .and_then(Value::as_array)
3979 .ok_or_else(|| SourceError::Malformed {
3980 message: "GitHub search nodes is not an array".into(),
3981 })?
3982 {
3983 if let Some(resolved) = self.resolve_issue(node).await? {
3984 found.push(resolved);
3985 }
3986 }
3987 let info = connection
3988 .get("pageInfo")
3989 .ok_or_else(|| SourceError::Malformed {
3990 message: "GitHub search connection has no pageInfo".into(),
3991 })?;
3992 let next = required_bool(info, "hasNextPage")?
3993 .then(|| required_str(info, "endCursor"))
3994 .transpose()?
3995 .map(str::to_owned);
3996 if let Some(next) = &next {
3997 validate_cursor_progress(after, next)?;
3998 }
3999 Ok((found, next))
4000 }
4001
4002 /// Every issue this board holds, completed with what this run wrote.
4003 ///
4004 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4005 /// is an index and is eventually consistent, so an issue this run created seconds ago
4006 /// can be absent from it, and a project listed straight after being written would
4007 /// otherwise be missing from its own board. What is added back is only what this
4008 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4009 /// process.
4010 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4011 let found = self.searched_issues().await?;
4012 self.completed_with_written(found, |_| true)
4013 }
4014
4015 /// Every issue this board's own search reports, walked to exhaustion, read once per
4016 /// source.
4017 ///
4018 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4019 /// needs it too and the two would otherwise walk the same search twice in one command.
4020 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4021 /// is.
4022 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4023 let cached = self.search_cache()?.clone();
4024 if let Some(held) = cached {
4025 return Ok(held);
4026 }
4027 let mut after: Option<String> = None;
4028 let mut found = Vec::new();
4029 let search = self.board_search(None);
4030 loop {
4031 let (page, next) = self
4032 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4033 .await?;
4034 found.extend(page);
4035 match next {
4036 Some(next) => after = Some(next),
4037 None => break,
4038 }
4039 }
4040 *self.search_cache()? = Some(found.clone());
4041 Ok(found)
4042 }
4043
4044 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4045 fn search_cache(
4046 &self,
4047 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4048 self.search_cache
4049 .lock()
4050 .map_err(|_| SourceError::Unavailable {
4051 message: "this source's view of the board's issues was left inconsistent by an \
4052 earlier failure; next: run the command again"
4053 .into(),
4054 })
4055 }
4056
4057 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4058 /// report.
4059 ///
4060 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4061 /// at all: the search index is behind, and a node read of an item filed moments ago can
4062 /// be too.
4063 fn completed_with_written(
4064 &self,
4065 mut found: Vec<Resolved>,
4066 keep: impl Fn(&Resolved) -> bool,
4067 ) -> Result<Vec<Resolved>, SourceError> {
4068 for own in self.created()?.iter().filter(|own| keep(own)) {
4069 if !found.iter().any(|item| item.id == own.id) {
4070 found.push(own.clone());
4071 }
4072 }
4073 Ok(found)
4074 }
4075
4076 /// What resolving one node id reached.
4077 ///
4078 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4079 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4080 /// is completed by a read of the draft itself rather than reported as nothing.
4081 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4082 let asked = self
4083 .graphql(
4084 graphql::ISSUE,
4085 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4086 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4087 )
4088 .await;
4089 let data = match asked {
4090 Ok(data) => data,
4091 // A string that is not a node id at all is not a failure to report: it is an id
4092 // this board does not hold, which is what every read of one already answers.
4093 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4094 Err(error) => return Err(error),
4095 };
4096 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4097 return Ok(Reached::Nothing);
4098 };
4099 if optional_str(node, "__typename")? == Some("DraftIssue") {
4100 return Ok(Reached::Draft);
4101 }
4102 Ok(match self.resolve_issue(node).await? {
4103 Some(item) => Reached::Held(Box::new(item)),
4104 None => Reached::Nothing,
4105 })
4106 }
4107
4108 /// One item of this board by its own id, whatever kind it is.
4109 ///
4110 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4111 /// run wrote is read first, because a node read of an item created moments ago can
4112 /// still be behind the board field values written onto it — see [`Self::created`].
4113 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4114 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4115 return Ok(Some(own.clone()));
4116 }
4117 match self.reach(id).await? {
4118 Reached::Held(item) => Ok(Some(*item)),
4119 Reached::Nothing => Ok(None),
4120 Reached::Draft => self.draft_by_id(id).await,
4121 }
4122 }
4123
4124 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4125 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4126 /// than one request per id.
4127 ///
4128 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4129 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4130 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4131 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4132 /// is completed by a read of the draft, exactly as there.
4133 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4134 let mut found: Vec<Option<Option<Resolved>>> = {
4135 let created = self.created()?;
4136 ids.iter()
4137 .map(|id| {
4138 created
4139 .iter()
4140 .find(|own| own.id == *id)
4141 .map(|own| Some(own.clone()))
4142 })
4143 .collect()
4144 };
4145 let unread: Vec<NativeId> = ids
4146 .iter()
4147 .zip(&found)
4148 .filter(|(_, found)| found.is_none())
4149 .map(|(id, _)| id.clone())
4150 .collect();
4151 let mut read = Vec::with_capacity(unread.len());
4152 if let [one] = unread.as_slice() {
4153 read.push(self.item_by_id(one).await?);
4154 } else {
4155 for batch in unread.chunks(DETAIL_BATCH) {
4156 let data = match self
4157 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4158 .await
4159 {
4160 Ok(data) => data,
4161 Err(error) if unresolvable_node(&error) => {
4162 for id in batch {
4163 read.push(self.item_by_id(id).await?);
4164 }
4165 continue;
4166 }
4167 Err(error) => return Err(error),
4168 };
4169 for (slot, id) in batch.iter().enumerate() {
4170 let node =
4171 data.get(format!("i{slot}"))
4172 .ok_or_else(|| SourceError::Malformed {
4173 message: format!(
4174 "GitHub answered a batch read with no item for {}",
4175 id.0
4176 ),
4177 })?;
4178 read.push(if node.is_null() {
4179 None
4180 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4181 self.draft_by_id(id).await?
4182 } else {
4183 if optional_str(node, "__typename")? == Some("Issue")
4184 && required_str(node, "id")? != id.0
4185 {
4186 return Err(SourceError::Malformed {
4187 message: format!(
4188 "GitHub answered the read of {} with issue {}",
4189 id.0,
4190 required_str(node, "id")?
4191 ),
4192 });
4193 }
4194 self.resolve_issue(node).await?
4195 });
4196 }
4197 }
4198 }
4199 let mut read = read.into_iter();
4200 Ok(found
4201 .iter_mut()
4202 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4203 .collect())
4204 }
4205
4206 fn resolved_cache(
4207 &self,
4208 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4209 self.resolved_cache
4210 .lock()
4211 .map_err(|_| SourceError::Unavailable {
4212 message: "resolved item records were left inconsistent; run the command again"
4213 .into(),
4214 })
4215 }
4216
4217 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4218 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4219 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4220 let cached = self.resolved_cache()?.get(id).cloned();
4221 match cached {
4222 Some(item) => Ok(Some(item)),
4223 None => self.item_by_id(id).await,
4224 }
4225 }
4226
4227 /// One board draft by its own id, with the board item it sits in — or `None` when no
4228 /// item of this board is that draft's.
4229 ///
4230 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4231 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4232 /// links a draft to one board item, so the page this read carries is the whole of that
4233 /// connection, and a page that reports more than it holds is refused rather than read
4234 /// as an answer about memberships nobody read.
4235 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4236 let data = self
4237 .graphql(
4238 graphql::DRAFT,
4239 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4240 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4241 )
4242 .await?;
4243 // Gone between the two reads is an answer — the draft is no longer there. Anything
4244 // else than the draft [`Self::reach`] was just told this id is, is not one.
4245 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4246 return Ok(None);
4247 };
4248 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4249 return Err(SourceError::Malformed {
4250 message: format!(
4251 "GitHub answered {} as a draft and then as something else",
4252 id.0
4253 ),
4254 });
4255 }
4256 if required_str(draft, "id")? != id.0 {
4257 return Err(SourceError::Malformed {
4258 message: format!("GitHub answered a different draft for {}", id.0),
4259 });
4260 }
4261 let memberships = draft
4262 .get("projectV2Items")
4263 .ok_or_else(|| SourceError::Malformed {
4264 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4265 })?;
4266 let nodes = memberships
4267 .get("nodes")
4268 .and_then(Value::as_array)
4269 .ok_or_else(|| SourceError::Malformed {
4270 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4271 })?;
4272 let info = memberships
4273 .get("pageInfo")
4274 .ok_or_else(|| SourceError::Malformed {
4275 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4276 })?;
4277 // Read whether or not this board's entry is on the page: a page claiming more than
4278 // the one item GitHub links a draft to is a malformed answer either way.
4279 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4280 return Err(SourceError::Malformed {
4281 message: format!(
4282 "GitHub draft {} reports more board items than the one GitHub links a draft \
4283 to",
4284 id.0
4285 ),
4286 });
4287 }
4288 if let Some(node) = nodes.first()
4289 && node
4290 .pointer("/project/number")
4291 .and_then(Value::as_u64)
4292 .is_none()
4293 {
4294 return Err(SourceError::Malformed {
4295 message: format!(
4296 "GitHub draft {} board item has no numeric project number",
4297 id.0
4298 ),
4299 });
4300 }
4301 let Some(held) = self.board_entry(nodes) else {
4302 return Ok(None);
4303 };
4304 if required_str(
4305 held.get("project").ok_or_else(|| SourceError::Malformed {
4306 message: format!("GitHub draft {} board item has no project", id.0),
4307 })?,
4308 "id",
4309 )? != self.board_fields().await?.id.as_str()
4310 {
4311 return Ok(None);
4312 }
4313 let item = json!({
4314 "id": required_str(held, "id")?,
4315 "project": held.get("project"),
4316 "fieldValues": held.get("fieldValues"),
4317 "content": draft,
4318 });
4319 self.resolve(&item)
4320 }
4321
4322 /// The board's own id and field definitions, for a write whose item does not carry
4323 /// them — never its items.
4324 ///
4325 /// A board this command has already listed supplies them, since it read them beside its
4326 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4327 /// is consulted about which items the board holds: see the module documentation for
4328 /// why a question about one known item is answered by reading that item.
4329 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4330 if let Some(board) = self.board_cache()?.as_ref() {
4331 return Ok(BoardFields {
4332 id: BoardId::parse(&board.id)?,
4333 fields: board.fields.clone(),
4334 });
4335 }
4336 if let Some(held) = self.fields_cache()?.clone() {
4337 return Ok(held);
4338 }
4339 let data = self
4340 .graphql(
4341 graphql::BOARD_FIELDS,
4342 json!({"owner":self.owner,"number":self.project_number,
4343 "nestedFirst":NESTED_PAGE_SIZE}),
4344 )
4345 .await?;
4346 self.fields_read(&data)
4347 }
4348
4349 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4350 /// the rest of this command.
4351 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4352 let board = data
4353 .pointer("/boardFields/projectV2")
4354 .filter(|value| !value.is_null())
4355 .ok_or_else(|| SourceError::Refused {
4356 message: format!(
4357 "GitHub project {}/{} was not found or is not visible to the token",
4358 self.owner, self.project_number
4359 ),
4360 })?;
4361 let read = BoardFields {
4362 id: BoardId::parse(required_str(board, "id")?)?,
4363 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4364 };
4365 *self.fields_cache()? = Some(read.clone());
4366 Ok(read)
4367 }
4368
4369 /// Read what creating an issue in `repository` needs and this command has not read yet —
4370 /// the board's fields and the repository's node id — in one request when it needs both.
4371 ///
4372 /// When either is already known this sends nothing, and the other is read by its own
4373 /// document where it is asked for, so no create reads anything twice.
4374 async fn creation_context(
4375 &self,
4376 repository: &RepositoryTarget,
4377 incoming: &Incoming<'_>,
4378 ) -> Result<(), SourceError> {
4379 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4380 if fields_known || self.repository_cache()?.contains_key(repository) {
4381 return Ok(());
4382 }
4383 let data = self
4384 .graphql(
4385 graphql::CREATION_CONTEXT,
4386 json!({"owner":self.owner,"number":self.project_number,
4387 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4388 "repositoryName":repository.name}),
4389 )
4390 .await?;
4391 self.fields_read(&data)?;
4392 self.repository_read(&data, repository, incoming)?;
4393 Ok(())
4394 }
4395
4396 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4397 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4398 self.fields_cache
4399 .lock()
4400 .map_err(|_| SourceError::Unavailable {
4401 message: "this source's view of the board's fields was left inconsistent by an \
4402 earlier failure; next: run the command again"
4403 .into(),
4404 })
4405 }
4406
4407 /// What a write to `item` needs of the board, read off that item when it says enough and
4408 /// off [`Self::board_fields`] when it does not.
4409 ///
4410 /// A node read of an item names its board and carries the definition of every field it
4411 /// holds a value of — so an item naming its board, holding a value of the origin field,
4412 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4413 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4414 /// of may still be on the board, and a view reading it as absent would refuse a write the
4415 /// board can take or skip a field write the board needs, so such an item — and a create,
4416 /// which has no item yet — takes the board's fields from their own read instead.
4417 async fn fields_for(
4418 &self,
4419 item: Option<&Resolved>,
4420 writes_status: bool,
4421 selects_priority: bool,
4422 ) -> Result<BoardFields, SourceError> {
4423 if let Some(board) = item.and_then(Resolved::carried_board) {
4424 return Ok(board);
4425 }
4426 if let Some(item) = item
4427 && let Some(board_id) = item.named_board()
4428 && item.defines(ORIGIN_FIELD)
4429 && (!writes_status || item.defines("Status"))
4430 && (!selects_priority || item.defines(PRIORITY_FIELD))
4431 {
4432 return Ok(BoardFields {
4433 id: board_id,
4434 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4435 });
4436 }
4437 self.board_fields().await
4438 }
4439
4440 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4441 /// when that id names nothing here with a sub-issue relationship to walk.
4442 ///
4443 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4444 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4445 /// empty vector is a project that holds nothing.
4446 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4447 let mut after: Option<String> = None;
4448 let mut children = Vec::new();
4449 loop {
4450 let asked = self
4451 .graphql(
4452 graphql::SUB_ISSUES,
4453 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4454 "nestedFirst":NESTED_PAGE_SIZE,
4455 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4456 )
4457 .await;
4458 let data = match asked {
4459 Ok(data) => data,
4460 // A string that is not a node id at all is not a failure to report: it is
4461 // the ordinary answer to a selector naming a project by its name.
4462 Err(error) if unresolvable_node(&error) => return Ok(None),
4463 Err(error) => return Err(error),
4464 };
4465 let Some(connection) = data
4466 .pointer("/node/subIssues")
4467 .filter(|value| !value.is_null())
4468 else {
4469 // No such node, or one with no sub-issue relationship — a board draft is
4470 // the one this board can really hold.
4471 return Ok(None);
4472 };
4473 for node in connection
4474 .get("nodes")
4475 .and_then(Value::as_array)
4476 .ok_or_else(|| SourceError::Malformed {
4477 message: "GitHub subIssues.nodes is not an array".into(),
4478 })?
4479 {
4480 if let Some(resolved) = self.resolve_issue(node).await? {
4481 children.push(resolved);
4482 }
4483 }
4484 let info = connection
4485 .get("pageInfo")
4486 .ok_or_else(|| SourceError::Malformed {
4487 message: "GitHub subIssues connection has no pageInfo".into(),
4488 })?;
4489 let next = required_bool(info, "hasNextPage")?
4490 .then(|| required_str(info, "endCursor"))
4491 .transpose()?;
4492 match next {
4493 Some(next) => {
4494 validate_cursor_progress(after.as_deref(), next)?;
4495 after = Some(next.to_owned());
4496 }
4497 None => return Ok(Some(children)),
4498 }
4499 }
4500 }
4501
4502 /// Which issue of this board a project *name* is, or `None` when none is.
4503 ///
4504 /// One bounded query which filters on that name at the server, rather than a walk of
4505 /// every issue the board holds. The name is compared again here: the qualifier narrows
4506 /// what GitHub sends, and this source decides what it names.
4507 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4508 let search = self.board_search(Some(&title_qualifier(name)));
4509 let mut after = None;
4510 loop {
4511 let (candidates, next) = self
4512 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4513 .await?;
4514 if let Some(item) = candidates.into_iter().find(|item| {
4515 item.kind == BoardKind::Work(ItemKind::Project)
4516 && item.title.eq_ignore_ascii_case(name)
4517 }) {
4518 return Ok(Some(item.id));
4519 }
4520 match next {
4521 Some(next) => after = Some(next),
4522 None => return Ok(None),
4523 }
4524 }
4525 }
4526
4527 /// Everything filed under one project of this board: the sub-issues of the issue that
4528 /// project is.
4529 ///
4530 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4531 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4532 /// gains projects, or as another project gains tasks.
4533 ///
4534 /// A qualified id names the issue and is asked for its sub-issues directly: one
4535 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4536 /// read as a project *name*, which costs the one bounded search
4537 /// [`Self::project_by_name`] makes.
4538 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4539 let (project, children) = match self.sub_issues(selector).await? {
4540 Some(children) => (selector.clone(), children),
4541 None => match self.project_by_name(&selector.0).await? {
4542 Some(project) => {
4543 let children = self.sub_issues(&project).await?.unwrap_or_default();
4544 (project, children)
4545 }
4546 None => return Ok(Vec::new()),
4547 },
4548 };
4549 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4550 }
4551
4552 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4553 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4554 ///
4555 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4556 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4557 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4558 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4559 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4560 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4561 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4562 /// rather than silently narrowing a caller's answer.
4563 ///
4564 /// The instant is written to the second, rounded down, which can only widen what the
4565 /// search returns; confirmation against each candidate's own comments is what makes the
4566 /// answer exact. The search is an index that lags a write by a second or two — the module
4567 /// documentation records it — so a caller that asks again from its last instant should
4568 /// overlap the two by more than that.
4569 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4570 let found = self.searched(&updated_qualifier(since)).await?;
4571 self.completed_with_written(found, |_| true)
4572 }
4573
4574 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4575 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4576 ///
4577 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4578 /// own record is the fresher of the two.
4579 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4580 let search = self.board_search(Some(also));
4581 let mut after: Option<String> = None;
4582 let mut found = Vec::new();
4583 loop {
4584 let (page, next) = self
4585 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4586 .await?;
4587 found.extend(page);
4588 match next {
4589 Some(next) => after = Some(next),
4590 None => return Ok(found),
4591 }
4592 }
4593 }
4594
4595 /// A bounded task answer; the versioned cursor carries the connection position, how
4596 /// many rows of the page starting there were already handed out, and the own-write ids
4597 /// already observed, including across a new source instance.
4598 ///
4599 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4600 /// sliced from the pages it needs; why is the module documentation's paging contract.
4601 async fn search_tasks(
4602 &self,
4603 query: &TaskQuery,
4604 page: &PageRequest,
4605 also: &str,
4606 ) -> Result<Page<Task>, SourceError> {
4607 let mut position = match &page.cursor {
4608 None => SearchPosition::default(),
4609 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4610 .ok()
4611 .filter(|position| {
4612 position.version == SEARCH_CURSOR_VERSION
4613 && position.connection.valid_resume(position.offset)
4614 })
4615 .ok_or_else(|| SourceError::Config {
4616 message: "page cursor is invalid".into(),
4617 })?,
4618 };
4619 let search = self.board_search(Some(also));
4620 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4621 let own = self.with_own_writes(Vec::new())?;
4622 for item in &own {
4623 if !position.own.contains(&item.id) {
4624 position.own.push(item.id.clone());
4625 }
4626 }
4627 let mut tasks = Vec::new();
4628 while !position.connection.exhausted() && tasks.len() < limit {
4629 let first = SEARCH_PAGE_SIZE;
4630 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4631 let key =
4632 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4633 .expect("search page key is serializable");
4634 let cached = if query.commented_since.is_none() {
4635 self.narrowed_cache()?.get(&key).cloned()
4636 } else {
4637 None
4638 };
4639 let (found, next) = match cached {
4640 Some(found) => {
4641 let next = self
4642 .search_next
4643 .lock()
4644 .map_err(|_| SourceError::Unavailable {
4645 message:
4646 "search pagination was left inconsistent; run the command again"
4647 .into(),
4648 })?
4649 .get(&key)
4650 .cloned()
4651 .flatten();
4652 (found, next)
4653 }
4654 None => {
4655 let (found, next) = self
4656 .search_page(&search, first, position.connection.after())
4657 .await?;
4658 if query.commented_since.is_none() {
4659 self.search_next
4660 .lock()
4661 .map_err(|_| SourceError::Unavailable {
4662 message:
4663 "search pagination was left inconsistent; run the command again"
4664 .into(),
4665 })?
4666 .insert(key.clone(), next.clone());
4667 self.narrowed_cache()?.insert(key, found.clone());
4668 }
4669 (found, next)
4670 }
4671 };
4672 let rows = found.len();
4673 for mut item in found.into_iter().skip(position.offset) {
4674 if tasks.len() == limit {
4675 break;
4676 }
4677 position.offset += 1;
4678 if position.own.contains(&item.id) {
4679 if position.seen.contains(&item.id) {
4680 continue;
4681 }
4682 position.seen.push(item.id.clone());
4683 let updated_at = item.updated_at;
4684 let Some(written) = self.search_written(&own, &item.id).await? else {
4685 continue;
4686 };
4687 item = written;
4688 item.updated_at = item.updated_at.max(updated_at);
4689 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4690 }
4691 if item.kind == BoardKind::Work(ItemKind::Task) {
4692 let task = item.task()?;
4693 if task_matches(&task, query, &query.project)
4694 && self.commented_since(&item, query.commented_since).await?
4695 {
4696 tasks.push(task);
4697 }
4698 }
4699 }
4700 if position.offset < rows {
4701 continue;
4702 }
4703 position.offset = 0;
4704 position.connection = match next {
4705 Some(after) => SearchConnection::Continuing {
4706 after: Cursor(after),
4707 },
4708 None => SearchConnection::Exhausted {},
4709 };
4710 }
4711 if position.connection.exhausted() {
4712 for id in position.own.clone() {
4713 if position.seen.contains(&id) {
4714 continue;
4715 }
4716 if tasks.len() == limit {
4717 break;
4718 }
4719 position.seen.push(id.clone());
4720 let Some(item) = self.search_written(&own, &id).await? else {
4721 continue;
4722 };
4723 if item.kind == BoardKind::Work(ItemKind::Task) {
4724 let task = item.task()?;
4725 if task_matches(&task, query, &query.project)
4726 && self.commented_since(&item, query.commented_since).await?
4727 {
4728 tasks.push(task);
4729 }
4730 }
4731 }
4732 }
4733 let more = !position.connection.exhausted()
4734 || position.own.iter().any(|id| !position.seen.contains(id));
4735 Ok(Page {
4736 items: tasks,
4737 next: more.then(|| {
4738 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4739 }),
4740 })
4741 }
4742
4743 /// A resumed process has the ids but no write records; resolve only a record the
4744 /// current page needs, by its uncached node read rather than the lagging search index.
4745 async fn search_written(
4746 &self,
4747 own: &[Resolved],
4748 id: &NativeId,
4749 ) -> Result<Option<Resolved>, SourceError> {
4750 match own.iter().find(|item| item.id == *id) {
4751 Some(item) => Ok(Some(item.clone())),
4752 None => self.item_by_id(id).await,
4753 }
4754 }
4755
4756 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4757 /// without enumerating the board — or `None` for a query carrying none of the three, which
4758 /// keeps the reads it always had.
4759 ///
4760 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4761 /// because it names at most a handful of items. Text and metadata are answered by one
4762 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4763 /// further by `updated:>=` when the query also asks for comment activity, since both
4764 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4765 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4766 ///
4767 /// Completed with what this process wrote, its own record winning over the index's copy
4768 /// of the same item: see [`Self::with_own_writes`].
4769 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4770 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4771 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4772 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4773 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4774 None => qualifiers,
4775 }),
4776 (None, None) => return Ok(None),
4777 };
4778 // A question about comment activity is asked afresh every time, as it always was: it
4779 // is the one a caller polls from one source while waiting for the index, and an
4780 // answer held from the first poll would be the answer to every later one.
4781 let key = query.commented_since.is_none().then(|| asked.key());
4782 let cached = match &key {
4783 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4784 None => None,
4785 };
4786 let found = match cached {
4787 Some(found) => found,
4788 None => {
4789 let found = match &asked {
4790 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4791 Narrowing::Search(also) => self.searched(also).await?,
4792 };
4793 if let Some(key) = key {
4794 self.narrowed_cache()?.insert(key, found.clone());
4795 }
4796 found
4797 }
4798 };
4799 self.with_own_writes(found).map(Some)
4800 }
4801
4802 /// The candidates for a project or unscoped document query carrying a searchable text,
4803 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4804 /// which keeps the read it always had.
4805 ///
4806 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4807 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4808 /// costs is the issues that match and never the board. Its answer is held for the command
4809 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4810 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4811 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4812 /// process wrote: see [`Self::with_own_writes`].
4813 async fn text_searched(
4814 &self,
4815 text: Option<&TextQuery>,
4816 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4817 let Some(also) = text_qualifiers(text) else {
4818 return Ok(None);
4819 };
4820 let key = Narrowing::Search(also.clone()).key();
4821 let cached = self.narrowed_cache()?.get(&key).cloned();
4822 let found = match cached {
4823 Some(found) => found,
4824 None => {
4825 let found = self.searched(&also).await?;
4826 self.narrowed_cache()?.insert(key, found.clone());
4827 found
4828 }
4829 };
4830 self.with_own_writes(found).map(Some)
4831 }
4832
4833 /// Every item of this board that may carry `origin` — a superset of those that do — found
4834 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4835 ///
4836 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4837 /// which reads the field every carrier holds, whichever release wrote it — and the
4838 /// board-scoped issue search for the same id as a phrase in the body, where this source
4839 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4840 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4841 /// query's, exactly.
4842 ///
4843 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4844 /// already ended is sent its last cursor again, which answers an empty page, so the one
4845 /// document serves every page of either. What the two leave is stated in the module
4846 /// documentation: a carrier another process added within the last second or two, before
4847 /// either index has it.
4848 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4849 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4850 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4851 let mut items_after: Option<String> = None;
4852 let mut search_after: Option<String> = None;
4853 let mut found: Vec<Resolved> = Vec::new();
4854 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4855 if !found.iter().any(|held| held.id == resolved.id) {
4856 found.push(resolved);
4857 }
4858 };
4859 loop {
4860 let data = self
4861 .graphql(
4862 graphql::ORIGIN_LOOKUP,
4863 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4864 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4865 "itemsAfter":items_after,"searchAfter":search_after,
4866 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4867 "duplicates":true}),
4868 )
4869 .await?;
4870 let items = data
4871 .pointer("/originItems/projectV2/items")
4872 .filter(|value| !value.is_null())
4873 .ok_or_else(|| SourceError::Refused {
4874 message: format!(
4875 "GitHub project {}/{} was not found or is not visible to the token",
4876 self.owner, self.project_number
4877 ),
4878 })?;
4879 for item in optional_nodes(Some(items), "project items")?
4880 .into_iter()
4881 .flatten()
4882 {
4883 // The board's own items list its drafts too, and a draft is not an issue: no
4884 // narrowed read answers with one, whatever its origin field holds.
4885 if let Some(resolved) = self.resolve(item)?
4886 && resolved.content_kind == ContentKind::Issue
4887 {
4888 keep(resolved, &mut found);
4889 }
4890 }
4891 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4892 message: "GitHub search response has no search connection".into(),
4893 })?;
4894 for node in optional_nodes(Some(searched), "search")?
4895 .into_iter()
4896 .flatten()
4897 {
4898 if let Some(resolved) = self.resolve_issue(node).await? {
4899 keep(resolved, &mut found);
4900 }
4901 }
4902 let items_next = resumed(items, items_after.as_deref())?;
4903 let search_next = resumed(searched, search_after.as_deref())?;
4904 if !items_next.has_more() && !search_next.has_more() {
4905 return Ok(found);
4906 }
4907 items_after = items_next.cursor();
4908 search_after = search_next.cursor();
4909 }
4910 }
4911
4912 /// `found`, with every item this process created or wrote in its place, and every one of
4913 /// them the read did not report added.
4914 ///
4915 /// This process's own record wins over the read's copy of the same item, because a read
4916 /// of an item written moments ago can still be behind what was written onto it — the
4917 /// origin field included, which is the one a narrowed read is confirmed against — and a
4918 /// read that still names an item under a predicate this process's write moved it out of
4919 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4920 /// last saw the item change, which is what a comment-activity read rules a candidate out
4921 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4922 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4923 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4924 // A board draft is not an issue, so no narrowed read returns one, and this process
4925 // having written one does not make it an answer either.
4926 let own: Vec<Resolved> = self
4927 .created()?
4928 .iter()
4929 .chain(self.updated()?.iter())
4930 .filter(|own| own.content_kind == ContentKind::Issue)
4931 .cloned()
4932 .collect();
4933 for mut own in own {
4934 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4935 match found.iter_mut().find(|read| read.id == own.id) {
4936 Some(read) => {
4937 own.updated_at = own.updated_at.max(read.updated_at);
4938 *read = own;
4939 }
4940 None => found.push(own),
4941 }
4942 }
4943 Ok(found)
4944 }
4945
4946 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4947 /// there is no instant to hold it to.
4948 ///
4949 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4950 /// or after the instant moved it there: an issue not updated since holds no such comment,
4951 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4952 /// only as far as the first that matches. A board draft is not an issue and has no
4953 /// comments, so it never matches.
4954 async fn commented_since(
4955 &self,
4956 item: &Resolved,
4957 since: Option<DateTime<Utc>>,
4958 ) -> Result<bool, SourceError> {
4959 let Some(since) = since else {
4960 return Ok(true);
4961 };
4962 if item.content_kind == ContentKind::DraftIssue
4963 || item.updated_at.is_some_and(|updated| updated < since)
4964 {
4965 return Ok(false);
4966 }
4967 let query = TaskQuery {
4968 commented_since: Some(since),
4969 ..TaskQuery::default()
4970 };
4971 let mut after: Option<String> = None;
4972 loop {
4973 let data = self
4974 .graphql(
4975 graphql::ISSUE_COMMENTS,
4976 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4977 )
4978 .await?;
4979 let Some(connection) = data
4980 .get("node")
4981 .filter(|value| !value.is_null())
4982 .and_then(|node| node.get("comments"))
4983 .filter(|value| !value.is_null())
4984 else {
4985 // Removed since the search reported it: no longer an issue with comments.
4986 return Ok(false);
4987 };
4988 let comments = optional_nodes(Some(connection), "issue comments")?
4989 .into_iter()
4990 .flatten()
4991 .map(comment_from)
4992 .collect::<Result<Vec<_>, _>>()?;
4993 if query.comments_match(&comments) {
4994 return Ok(true);
4995 }
4996 match next_cursor(connection)? {
4997 Some(next) => {
4998 validate_cursor_progress(after.as_deref(), &next.0)?;
4999 after = Some(next.0);
5000 }
5001 None => return Ok(false),
5002 }
5003 }
5004 }
5005
5006 /// Every item on the board: the union of both enumerations GitHub offers of one.
5007 ///
5008 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5009 /// board **draft** and reads the board's own fields beside its items, and only the search
5010 /// reports an item that connection is behind on. The module documentation is where the lag and the
5011 /// measurements behind it are written down.
5012 ///
5013 /// A search result is admitted on the same terms as any other issue this source reaches
5014 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5015 /// names *this* board — so an issue the index still believes is here after it was taken
5016 /// off is refused rather than reported.
5017 ///
5018 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5019 /// which is what the cache could otherwise have broken.
5020 async fn board(&self) -> Result<Board, SourceError> {
5021 let cached = self.board_cache()?.clone();
5022 let mut board = match cached {
5023 Some(board) => board,
5024 None => {
5025 let read = self.read_board().await?;
5026 *self.board_cache()? = Some(read.clone());
5027 read
5028 }
5029 };
5030 for held in self.searched_issues().await? {
5031 if !board.items.iter().any(|item| item.id == held.id) {
5032 board.items.push(held);
5033 }
5034 }
5035 for own in self.created()?.iter() {
5036 if !board.items.iter().any(|item| item.id == own.id) {
5037 board.items.push(own.clone());
5038 }
5039 }
5040 Ok(board)
5041 }
5042
5043 /// This process's own view of the board, or the refusal a poisoned lock is.
5044 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5045 self.board_cache
5046 .lock()
5047 .map_err(|_| SourceError::Unavailable {
5048 message: "this source's view of the board was left inconsistent by an earlier \
5049 failure; next: run the command again"
5050 .into(),
5051 })
5052 }
5053
5054 /// Bring this process's own view of the board up to an item it has just written.
5055 ///
5056 /// A created item goes to `created`, which is what completes a board read GitHub's own
5057 /// eventual consistency has left behind. An item that was already there is replaced
5058 /// where it sits, so a second write of it in the same command reads its real parent
5059 /// rather than the one it had before the first write.
5060 ///
5061 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5062 /// that wins: an item this same run created is held in `created` and not in the cached
5063 /// board, and `board` completes the cached board *from* `created`, so replacing only
5064 /// the cached copy of such an item replaces nothing and the read still reports the
5065 /// title it was created with. The search is the third, and it is the one an item the
5066 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5067 /// source is least able to re-read, so leaving it out would put the stale title back on
5068 /// the only items the completion in [`Self::board`] exists for.
5069 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5070 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5071 if created {
5072 self.created()?.push(item);
5073 return Ok(());
5074 }
5075 {
5076 let mut own = self.created()?;
5077 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5078 *held = item;
5079 return Ok(());
5080 }
5081 }
5082 {
5083 let mut own = self.updated()?;
5084 match own.iter_mut().find(|held| held.id == item.id) {
5085 Some(held) => *held = item.clone(),
5086 None => own.push(item.clone()),
5087 }
5088 }
5089 if let Some(board) = self.board_cache()?.as_mut()
5090 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5091 {
5092 *held = item.clone();
5093 }
5094 if let Some(found) = self.search_cache()?.as_mut()
5095 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5096 {
5097 *held = item.clone();
5098 }
5099 for found in self.narrowed_cache()?.values_mut() {
5100 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5101 *held = item.clone();
5102 }
5103 }
5104 Ok(())
5105 }
5106
5107 /// Forget one item this process has just deleted, from every half of its own view.
5108 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5109 self.resolved_cache()?.remove(id);
5110 self.created()?.retain(|own| own.id != *id);
5111 self.updated()?.retain(|own| own.id != *id);
5112 if let Some(board) = self.board_cache()?.as_mut() {
5113 board.items.retain(|item| item.id != *id);
5114 }
5115 if let Some(found) = self.search_cache()?.as_mut() {
5116 found.retain(|item| item.id != *id);
5117 }
5118 for found in self.narrowed_cache()?.values_mut() {
5119 found.retain(|item| item.id != *id);
5120 }
5121 Ok(())
5122 }
5123
5124 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5125 fn narrowed_cache(
5126 &self,
5127 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5128 self.narrowed_cache
5129 .lock()
5130 .map_err(|_| SourceError::Unavailable {
5131 message: "this source's view of a narrowed read was left inconsistent by an \
5132 earlier failure; next: run the command again"
5133 .into(),
5134 })
5135 }
5136
5137 /// Every page of the board, read from GitHub.
5138 async fn read_board(&self) -> Result<Board, SourceError> {
5139 let mut after: Option<String> = None;
5140 let mut items = Vec::new();
5141 let mut board;
5142 loop {
5143 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5144 for item in page
5145 .pointer("/items/nodes")
5146 .and_then(Value::as_array)
5147 .ok_or_else(|| SourceError::Malformed {
5148 message: "GitHub project items.nodes is not an array".into(),
5149 })?
5150 {
5151 if let Some(resolved) = self.resolve(item)? {
5152 items.push(resolved);
5153 }
5154 }
5155 let info = page
5156 .pointer("/items/pageInfo")
5157 .ok_or_else(|| SourceError::Malformed {
5158 message: "GitHub project items have no pageInfo".into(),
5159 })?;
5160 let has_next = required_bool(info, "hasNextPage")?;
5161 let next = has_next
5162 .then(|| required_str(info, "endCursor"))
5163 .transpose()?;
5164 board = page.clone();
5165 match next {
5166 Some(next) => {
5167 validate_cursor_progress(after.as_deref(), next)?;
5168 after = Some(next.to_owned());
5169 }
5170 None => break,
5171 }
5172 }
5173 Ok(Board {
5174 id: required_str(&board, "id")?.to_owned(),
5175 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5176 items,
5177 })
5178 }
5179
5180 /// The existing items this source has written, for completing a narrowed read that is
5181 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5182 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5183 self.updated.lock().map_err(|_| SourceError::Unavailable {
5184 message: "this source's record of what it wrote in this run was left inconsistent \
5185 by an earlier failure; next: run the command again"
5186 .into(),
5187 })
5188 }
5189
5190 /// The items this source has created, for completing a board read that is behind.
5191 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5192 self.created.lock().map_err(|_| SourceError::Unavailable {
5193 message: "this source's record of what it created in this run was left \
5194 inconsistent by an earlier failure; next: run the command again"
5195 .into(),
5196 })
5197 }
5198
5199 /// One board item as this source reports it, or `None` for content it ignores.
5200 ///
5201 /// A pull request is neither a project nor a task — it is somebody's change, not a
5202 /// unit of plan — and an item whose content the token cannot see has nothing to
5203 /// report at all.
5204 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5205 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5206 message: "GitHub project item is missing content".into(),
5207 })?;
5208 if content.is_null() {
5209 return Ok(None);
5210 }
5211 let content_kind = match required_str(content, "__typename")? {
5212 "Issue" => ContentKind::Issue,
5213 "DraftIssue" => ContentKind::DraftIssue,
5214 _ => return Ok(None),
5215 };
5216 let field_values = item
5217 .get("fieldValues")
5218 .ok_or_else(|| SourceError::Malformed {
5219 message: "GitHub project item is missing fieldValues".into(),
5220 })?;
5221 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5222 let nodes = field_values
5223 .get("nodes")
5224 .and_then(Value::as_array)
5225 .ok_or_else(|| SourceError::Malformed {
5226 message: "GitHub project item fieldValues.nodes is not an array".into(),
5227 })?;
5228 if let Some(labels) = content.get("labels") {
5229 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5230 }
5231 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5232 let (body, slot) = metadata_body(raw_body.clone())?;
5233 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5234 .map(|id| NativeId(id.to_owned()));
5235 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5236 // to read one from; it is a task, and never a project.
5237 let sub_issues = match content_kind {
5238 ContentKind::Issue => sub_issue_total(content)?,
5239 ContentKind::DraftIssue => 0,
5240 };
5241 let content_id = required_str(content, "id")?;
5242 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5243 message: format!("GitHub issue {content_id}: {message}"),
5244 })?;
5245 let raw_title = required_str(content, "title")?;
5246 // The design prefix is read *first*, before either of the two rules that separate
5247 // a project from a task. A document is not work whatever sub-issues it has and
5248 // whatever marker it carries, and reading the prefix later would make a design
5249 // issue with none of either an empty project.
5250 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5251 BoardKind::Document
5252 } else if parent.is_some() {
5253 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5254 // under a project is that project's task even when it has sub-issues of its
5255 // own.
5256 BoardKind::Work(ItemKind::Task)
5257 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5258 BoardKind::Work(ItemKind::Project)
5259 } else {
5260 BoardKind::Work(ItemKind::Task)
5261 };
5262 // The title a person wrote, which for a document is the one without the prefix —
5263 // the same way `content` above is the body without this source's metadata slot.
5264 let title = match kind {
5265 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5266 BoardKind::Work(_) => raw_title.to_owned(),
5267 };
5268 let own_repository = content
5269 .pointer("/repository/nameWithOwner")
5270 .and_then(Value::as_str)
5271 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5272 .transpose()
5273 .map_err(|message| SourceError::Malformed { message })?;
5274 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5275 Repository::from_metadata(&slot)
5276 .map_err(|message| SourceError::Malformed { message })?
5277 } else {
5278 own_repository.clone().into_iter().collect()
5279 };
5280 let id = NativeId(content_id.to_owned());
5281 // Read only for a task, because only a task has either list: a project or a
5282 // document holding one of these keys holds nothing this source reports, and the
5283 // keys are left out of its caller-visible metadata all the same.
5284 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5285 let listed = |key: &str| {
5286 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5287 .map_err(|message| SourceError::Malformed { message })
5288 };
5289 (
5290 listed(TaskRef::DELIVERS_KEY)?,
5291 listed(TaskRef::DELIVERED_BY_KEY)?,
5292 )
5293 } else {
5294 (Vec::new(), Vec::new())
5295 };
5296 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5297 let priority = self.held_priority(nodes)?;
5298 // Present when the item was reached through its own issue, whose board entry
5299 // names the board; a read of the board's own items has the board already. An
5300 // empty id names nothing a field write could address, so it is read as absent and
5301 // the write goes back to reading the board.
5302 let board_id = item
5303 .pointer("/project/id")
5304 .and_then(Value::as_str)
5305 .filter(|id| !id.is_empty());
5306 let resolved = Resolved {
5307 item_id: required_str(item, "id")?.to_owned(),
5308 id,
5309 content_kind,
5310 kind,
5311 title,
5312 body: body.filter(|value| !value.is_empty()),
5313 raw_body,
5314 status: self
5315 .statuses
5316 .status(kind.status_kind(), option, closed, reason),
5317 option: option.map(str::to_owned),
5318 priority,
5319 closed,
5320 delivers,
5321 delivered_by,
5322 labels: labels(content)?,
5323 parent,
5324 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5325 number: match content_kind {
5326 ContentKind::Issue => Some(issue_number(content)?),
5327 // A draft is filed in no repository, so nothing ever numbered it:
5328 // `DraftIssue` declares no `number` at all, exactly as it declares no
5329 // `subIssuesSummary` the branch above reads.
5330 ContentKind::DraftIssue => None,
5331 },
5332 url: optional_str(content, "url")?.map(str::to_owned),
5333 created_at: optional_time(content, "createdAt")?,
5334 updated_at: optional_time(content, "updatedAt")?,
5335 own_repository,
5336 repositories,
5337 slot,
5338 board_id: board_id.map(str::to_owned),
5339 fields: field_definitions(nodes),
5340 board_fields: Self::carried_board_fields(content, board_id)?,
5341 blocked_by: carried_blocked_by(content)?,
5342 };
5343 self.resolved_cache()?
5344 .insert(resolved.id.clone(), resolved.clone());
5345 Ok(Some(resolved))
5346 }
5347
5348 /// The field definitions of the board `board_id` names — the project this issue's own
5349 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5350 /// `None` when the read carried none, carried no entry for that board, or the board item
5351 /// named no board, which a write then answers by reading the board's fields itself.
5352 ///
5353 /// Matched by the board's node id and never by its number alone: a project number is
5354 /// unique only within its owner, so another owner's board numbered alike can sit on the
5355 /// same page, and its field and option ids address nothing on this one.
5356 fn carried_board_fields(
5357 content: &Value,
5358 board_id: Option<&str>,
5359 ) -> Result<Option<Value>, SourceError> {
5360 let (Some(nodes), Some(board_id)) = (
5361 content.pointer("/boards/nodes").and_then(Value::as_array),
5362 board_id,
5363 ) else {
5364 return Ok(None);
5365 };
5366 let Some(board) = nodes.iter().find_map(|node| {
5367 let project = node.get("project")?;
5368 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5369 }) else {
5370 return Ok(None);
5371 };
5372 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5373 return Ok(None);
5374 };
5375 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5376 Ok(Some(fields.clone()))
5377 }
5378
5379 /// What one board item's `Priority` field says, through this instance's mapping.
5380 ///
5381 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5382 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5383 /// option the mapping does not name is kept as itself — never read as a level or as
5384 /// `none` — for a read of the task to report by name.
5385 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5386 let Some(mapping) = &self.priorities else {
5387 return Ok(HeldPriority::Read(Priority::None));
5388 };
5389 // A value of the field that names no option — a text field someone called `Priority` —
5390 // is malformed rather than `none`: reading it as no priority would let the next copy
5391 // clear one a person set.
5392 let Some(option) = field_values
5393 .iter()
5394 .find(|value| {
5395 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5396 })
5397 .map(|value| required_str(value, "name"))
5398 .transpose()?
5399 else {
5400 return Ok(HeldPriority::Read(Priority::None));
5401 };
5402 Ok(mapping.priority_of(option).map_or_else(
5403 || HeldPriority::Unmapped(option.to_owned()),
5404 HeldPriority::Read,
5405 ))
5406 }
5407
5408 /// What one board item's status is read from: its `Status` option, whether its issue
5409 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5410 /// three into the status it reports.
5411 fn status_parts<'a>(
5412 field_values: &'a [Value],
5413 content: &'a Value,
5414 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5415 let option = field_values
5416 .iter()
5417 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5418 .map(|value| required_str(value, "name"))
5419 .transpose()?;
5420 let closed = optional_str(content, "state")? == Some("CLOSED");
5421 Ok((option, closed, optional_str(content, "stateReason")?))
5422 }
5423
5424 /// The board Status option this write selects, or the refusal that says why not.
5425 ///
5426 /// The mapped option is required for both open and terminal targets. A terminal write
5427 /// validates it before changing either representation, so it can never fall back to
5428 /// closing an issue whose board cannot display the matching status.
5429 ///
5430 /// Answers the field's id, the option's id, and the option's name as the board spells
5431 /// it — which is the name a read of the item reports once it sits there.
5432 fn column_for(
5433 &self,
5434 fields: &Value,
5435 kind: ItemKind,
5436 category: StatusCategory,
5437 target: &StatusTarget,
5438 ) -> Result<Option<(String, String, String)>, SourceError> {
5439 let Some(wanted) = target.option() else {
5440 return Ok(None);
5441 };
5442 let missing = |detail: &str| SourceError::Refused {
5443 message: format!(
5444 "{} status {} of source {} needs the board Status option {wanted:?}, and \
5445 {detail}; next: add that option to the board, which `onetaskgraph sources \
5446 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5447 it has",
5448 kind.marker(),
5449 category_name(category),
5450 self.name,
5451 self.name,
5452 category_name(category),
5453 kind.marker()
5454 ),
5455 };
5456 let Some(field) = Board::field(fields, "Status")? else {
5457 return Err(missing("this board has no Status field"));
5458 };
5459 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5460 return Err(missing(
5461 "this board's Status field is not a single-select field",
5462 ));
5463 }
5464 let option = field
5465 .get("options")
5466 .and_then(Value::as_array)
5467 .and_then(|options| {
5468 options.iter().find(|option| {
5469 option
5470 .get("name")
5471 .and_then(Value::as_str)
5472 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5473 })
5474 });
5475 match option {
5476 None => Err(missing("this board does not have it")),
5477 Some(option) => Ok(Some((
5478 required_str(field, "id")?.to_owned(),
5479 required_str(option, "id")?.to_owned(),
5480 required_str(option, "name")?.to_owned(),
5481 ))),
5482 }
5483 }
5484
5485 /// The refusal a status that closes an issue is answered with over a board draft.
5486 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5487 SourceError::Refused {
5488 message: format!(
5489 "status {} of source {} closes the item's issue, and GitHub draft items have \
5490 no open or closed state",
5491 category_name(category),
5492 self.name
5493 ),
5494 }
5495 }
5496
5497 /// What a status write to one item needs of the board: the board's id and the
5498 /// definition of its `Status` field, read off the item when the item says both.
5499 ///
5500 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5501 /// and its `Status` value carries that field's definition, options and all. An item that
5502 /// does not say — no board id, or no `Status` value to read the field off — takes them
5503 /// from [`Self::board_fields`], which reads no item.
5504 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5505 if let Some(board) = item.carried_board() {
5506 return Ok(board);
5507 }
5508 if item.defines("Status")
5509 && let Some(board_id) = item.named_board()
5510 {
5511 return Ok(BoardFields {
5512 id: board_id,
5513 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5514 });
5515 }
5516 self.board_fields().await
5517 }
5518
5519 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5520 async fn set_status(
5521 &self,
5522 id: &NativeId,
5523 category: StatusCategory,
5524 ) -> Result<Option<Status>, SourceError> {
5525 // Refused before anything is read, in the words a write of the same status is.
5526 let target = self.resolved_target(ItemKind::Task, category)?;
5527 let Some(mut item) = self
5528 .bound_item(id)
5529 .await?
5530 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5531 else {
5532 return Ok(None);
5533 };
5534 let board = self.status_board(&item).await?;
5535 let (field, option, name) = self
5536 .column_for(&board.fields, ItemKind::Task, category, &target)?
5537 .ok_or_else(|| SourceError::Malformed {
5538 message: format!(
5539 "status {} of source {} names no board Status option",
5540 category_name(category),
5541 self.name
5542 ),
5543 })?;
5544 if item.status.category == category && item.option.as_deref() == Some(&name) {
5545 return Ok(Some(item.status));
5546 }
5547 match &target {
5548 StatusTarget::Terminal(_, reason) => {
5549 if item.content_kind == ContentKind::DraftIssue {
5550 return Err(self.closes_a_draft(category));
5551 }
5552 self.set_item_field(
5553 board.id.as_str(),
5554 &item.item_id,
5555 &field,
5556 json!({"singleSelectOptionId": option}),
5557 )
5558 .await?;
5559 self.update_content(
5560 ContentKind::Issue,
5561 &item.id,
5562 json!({"stateInput": state_input(Some(&target))}),
5563 )
5564 .await?;
5565 item.closed = true;
5566 item.status =
5567 self.statuses
5568 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5569 item.option = Some(name);
5570 }
5571 StatusTarget::Column(_) => {
5572 // An option is what an open item's status is, so a closed issue is reopened
5573 // first — sitting closed in the column, it would read back as closed. A draft has
5574 // no state to reopen.
5575 if item.content_kind == ContentKind::Issue && item.closed {
5576 self.update_content(
5577 ContentKind::Issue,
5578 &item.id,
5579 json!({"stateInput": state_input(Some(&target))}),
5580 )
5581 .await?;
5582 item.closed = false;
5583 }
5584 self.set_item_field(
5585 board.id.as_str(),
5586 &item.item_id,
5587 &field,
5588 json!({"singleSelectOptionId": option}),
5589 )
5590 .await?;
5591 item.status = self
5592 .statuses
5593 .status(ItemKind::Task, Some(&name), false, None);
5594 item.option = Some(name);
5595 }
5596 StatusTarget::Disabled(_) => {
5597 unreachable!("resolved_target refused a disabled status")
5598 }
5599 }
5600 let status = item.status.clone();
5601 self.remember_written(item, false)?;
5602 Ok(Some(status))
5603 }
5604
5605 /// Replace one task's `delivered_by` and nothing else; see
5606 /// [`TaskSource::set_delivered_by`].
5607 ///
5608 /// One update of the body, which differs from the body GitHub holds only inside the
5609 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5610 async fn replace_delivered_by(
5611 &self,
5612 id: &NativeId,
5613 delivered_by: &[TaskRef],
5614 ) -> Result<Option<()>, SourceError> {
5615 let entries = TaskRef::listed(
5616 TaskRef::DELIVERED_BY_KEY,
5617 id,
5618 Some(&self.name),
5619 delivered_by.to_vec(),
5620 )
5621 .map_err(|message| SourceError::Refused { message })?;
5622 let Some(mut item) = self
5623 .bound_item(id)
5624 .await?
5625 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5626 else {
5627 return Ok(None);
5628 };
5629 let mut slot = item.slot.clone();
5630 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5631 self.write_slot(&mut item, &slot).await?;
5632 item.delivered_by = entries;
5633 self.remember_written(item, false)?;
5634 Ok(Some(()))
5635 }
5636
5637 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5638 /// see [`TaskSource::set_task_metadata`].
5639 ///
5640 /// `None` when this board holds no item by that id, or holds one of another kind. The
5641 /// answer is the item as this source now reads it, so what a caller is told the key
5642 /// holds is what the slot holds.
5643 ///
5644 /// A key already holding the value is answered without a write, compared as JSON rather
5645 /// than as the body's bytes: a slot a person spelled with other whitespace would
5646 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5647 async fn set_slot_key(
5648 &self,
5649 id: &NativeId,
5650 kind: BoardKind,
5651 key: &MetadataKey,
5652 value: &Value,
5653 ) -> Result<Option<Resolved>, SourceError> {
5654 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5655 return Ok(None);
5656 };
5657 if item.slot.get(key.as_str()) == Some(value) {
5658 return Ok(Some(item));
5659 }
5660 let mut slot = item.slot.clone();
5661 slot.insert(key.as_str().to_owned(), value.clone());
5662 self.write_slot(&mut item, &slot).await?;
5663 self.remember_written(item.clone(), false)?;
5664 Ok(Some(item))
5665 }
5666
5667 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5668 /// `item` up to what that write left.
5669 ///
5670 /// The body sent differs from the body GitHub holds only inside the slot — see
5671 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5672 /// the mutation the item's content takes, so a board draft's body is written with
5673 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5674 async fn write_slot(
5675 &self,
5676 item: &mut Resolved,
5677 slot: &BTreeMap<String, Value>,
5678 ) -> Result<(), SourceError> {
5679 let held = item.raw_body.clone().unwrap_or_default();
5680 let body = with_slot(&held, slot)?;
5681 if body != held {
5682 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5683 .await?;
5684 }
5685 let (visible, slot) = metadata_body(Some(body.clone()))?;
5686 item.body = visible.filter(|value| !value.is_empty());
5687 item.raw_body = Some(body);
5688 item.slot = slot;
5689 Ok(())
5690 }
5691
5692 /// This instance's target for a category written to an item of `kind`, refusing one
5693 /// that kind has no option for — before anything is read or written.
5694 ///
5695 /// Nothing here mutates the board's option set to make room for a status. GitHub
5696 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5697 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5698 /// field and every item's status.
5699 fn resolved_target(
5700 &self,
5701 kind: ItemKind,
5702 category: StatusCategory,
5703 ) -> Result<StatusTarget, SourceError> {
5704 let target = self.statuses.target(kind, category).clone();
5705 let StatusTarget::Disabled(why) = target else {
5706 return Ok(target);
5707 };
5708 let refusal = why.refusal(&self.name, category, kind);
5709 // Why there is no shipped default, which is the question a person meeting this
5710 // refusal on a source that never mentioned the category asks.
5711 let shipped_none = match category {
5712 StatusCategory::Draft => Some(
5713 "draft has no shipped default because GitHub draft issues cannot have \
5714 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5715 ),
5716 StatusCategory::Unknown => Some(
5717 "unknown has no shipped default because this board keeps no open-ended status \
5718 word: every word classified unknown is written to the one board Status option \
5719 status_mapping.unknown names",
5720 ),
5721 _ => None,
5722 };
5723 Err(match (refusal, shipped_none, why) {
5724 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5725 SourceError::Refused {
5726 message: format!("{message}; {note}"),
5727 }
5728 }
5729 (refusal, _, _) => refusal,
5730 })
5731 }
5732
5733 /// What writing `priority` does to one item's `Priority` field on this board, or the
5734 /// refusal naming what the board lacks.
5735 ///
5736 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5737 /// none already, or of an item not created yet. Every other priority selects the option
5738 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5739 /// without that option, is refused rather than given one: reads and writes never create
5740 /// a field or an option.
5741 fn priority_write(
5742 &self,
5743 fields: &Value,
5744 existing: Option<&Resolved>,
5745 priority: Priority,
5746 ) -> Result<Option<PriorityWrite>, SourceError> {
5747 let Some(mapping) = &self.priorities else {
5748 return Err(self.holds_no_priority());
5749 };
5750 let Some(wanted) = mapping.option(priority) else {
5751 if !existing.is_some_and(Resolved::holds_priority) {
5752 return Ok(None);
5753 }
5754 let field =
5755 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5756 message: format!(
5757 "an item holding a {PRIORITY_FIELD} value was read without that field"
5758 ),
5759 })?;
5760 return Ok(Some(PriorityWrite::Clear {
5761 field: required_str(field, "id")?.to_owned(),
5762 }));
5763 };
5764 let missing = |detail: &str| SourceError::Refused {
5765 message: format!(
5766 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5767 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5768 it, or point priority_mapping.{priority} of this source at an option the board \
5769 has",
5770 self.name, self.name
5771 ),
5772 };
5773 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5774 return Err(missing(&format!(
5775 "this board has no {PRIORITY_FIELD} field"
5776 )));
5777 };
5778 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5779 return Err(missing(&format!(
5780 "this board's {PRIORITY_FIELD} field is not a single-select field"
5781 )));
5782 }
5783 // An options list that is absent or not a list is an answer this source cannot read,
5784 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5785 let option = field
5786 .get("options")
5787 .and_then(Value::as_array)
5788 .ok_or_else(|| SourceError::Malformed {
5789 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5790 })?
5791 .iter()
5792 .find(|option| {
5793 option
5794 .get("name")
5795 .and_then(Value::as_str)
5796 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5797 })
5798 .ok_or_else(|| missing("this board does not have it"))?;
5799 Ok(Some(PriorityWrite::Select {
5800 field: required_str(field, "id")?.to_owned(),
5801 option: required_str(option, "id")?.to_owned(),
5802 }))
5803 }
5804
5805 /// Apply one priority write to one board item.
5806 async fn write_priority(
5807 &self,
5808 board_id: &str,
5809 item_id: &str,
5810 write: &PriorityWrite,
5811 ) -> Result<(), SourceError> {
5812 match write {
5813 PriorityWrite::Select { field, option } => {
5814 self.set_item_field(
5815 board_id,
5816 item_id,
5817 field,
5818 json!({"singleSelectOptionId": option}),
5819 )
5820 .await
5821 }
5822 PriorityWrite::Clear { field } => {
5823 let data = self
5824 .graphql(
5825 graphql::CLEAR_FIELD,
5826 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5827 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5828 )
5829 .await?;
5830 let returned = data
5831 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5832 .ok_or_else(|| SourceError::Malformed {
5833 message: "GitHub field clear returned no project item".into(),
5834 })?;
5835 if required_str(returned, "id")? != item_id {
5836 return Err(SourceError::Malformed {
5837 message: "GitHub field clear returned the wrong project item".into(),
5838 });
5839 }
5840 Ok(())
5841 }
5842 }
5843 }
5844
5845 /// The refusal a priority is answered with by an instance configured with no
5846 /// `priority_mapping`, which holds none.
5847 fn holds_no_priority(&self) -> SourceError {
5848 SourceError::Refused {
5849 message: format!(
5850 "source {} holds no task priority: its configuration sets no priority_mapping; \
5851 next: set priority_mapping on this source, then run `onetaskgraph sources \
5852 fields {} --apply` to set its board up",
5853 self.name, self.name
5854 ),
5855 }
5856 }
5857
5858 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5859 ///
5860 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5861 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5862 async fn set_priority(
5863 &self,
5864 id: &NativeId,
5865 priority: Priority,
5866 ) -> Result<Option<Priority>, SourceError> {
5867 if self.priorities.is_none() {
5868 return Err(self.holds_no_priority());
5869 }
5870 let Some(mut item) = self
5871 .bound_item(id)
5872 .await?
5873 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5874 else {
5875 return Ok(None);
5876 };
5877 if priority == Priority::None && !item.holds_priority() {
5878 return Ok(Some(priority));
5879 }
5880 // The item's own read carries the field's definition whenever it holds a value of
5881 // it, which a clear always does; a select onto an item holding none reads the board.
5882 let board = match (item.carried_board(), item.named_board()) {
5883 (Some(board), _) => board,
5884 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5885 id,
5886 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5887 },
5888 _ => self.board_fields().await?,
5889 };
5890 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5891 return Ok(Some(priority));
5892 };
5893 let (document, root, input) = match write {
5894 PriorityWrite::Select { field, option } => (
5895 graphql::UPDATE_FIELD,
5896 "updateProjectV2ItemFieldValue",
5897 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5898 ),
5899 PriorityWrite::Clear { field } => (
5900 graphql::CLEAR_FIELD,
5901 "clearProjectV2ItemFieldValue",
5902 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5903 ),
5904 };
5905 let data = self
5906 .graphql(
5907 document,
5908 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5909 )
5910 .await?;
5911 let returned = data
5912 .get(root)
5913 .and_then(|value| value.get("projectV2Item"))
5914 .ok_or_else(|| SourceError::Malformed {
5915 message: "GitHub priority write returned no project item".into(),
5916 })?;
5917 if required_str(returned, "id")? != item.item_id {
5918 return Err(SourceError::Malformed {
5919 message: "GitHub priority write returned the wrong project item".into(),
5920 });
5921 }
5922 let value = returned
5923 .get("fieldValueByName")
5924 .ok_or_else(|| SourceError::Malformed {
5925 message: "GitHub priority write returned no priority read-back".into(),
5926 })?;
5927 if !value.is_null()
5928 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5929 {
5930 return Err(SourceError::Malformed {
5931 message: "GitHub priority read-back is not a Priority field value".into(),
5932 });
5933 }
5934 let values = if value.is_null() {
5935 Vec::new()
5936 } else {
5937 vec![value.clone()]
5938 };
5939 item.priority = self.held_priority(&values)?;
5940 let answer = item.task()?.priority;
5941 self.remember_written(item, false)?;
5942 Ok(Some(answer))
5943 }
5944
5945 /// Replace one task's visible body and nothing else; see
5946 /// [`TaskSource::set_task_content`].
5947 ///
5948 /// One update of the body, which differs from the body GitHub holds only outside the
5949 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5950 /// this source keeps there reads back as it was. A body that would not change is not
5951 /// sent at all.
5952 async fn replace_content(
5953 &self,
5954 id: &NativeId,
5955 content: &str,
5956 ) -> Result<Option<()>, SourceError> {
5957 let Some(mut item) = self
5958 .bound_item(id)
5959 .await?
5960 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5961 else {
5962 return Ok(None);
5963 };
5964 let held = item.raw_body.clone().unwrap_or_default();
5965 let body = with_content(&held, content)?;
5966 // Checked before anything is sent: content ending in what this source reads as its own
5967 // metadata slot would read back as metadata rather than as the content it was.
5968 let (visible, slot) = metadata_body(Some(body.clone()))?;
5969 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5970 return Err(SourceError::Refused {
5971 message: format!(
5972 "this content ends in what source {} reads as its own metadata slot \
5973 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5974 as content; next: remove that trailing block from the content",
5975 self.name
5976 ),
5977 });
5978 }
5979 if body != held {
5980 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5981 .await?;
5982 }
5983 item.body = visible.filter(|value| !value.is_empty());
5984 item.raw_body = Some(body);
5985 item.slot = slot;
5986 self.remember_written(item, false)?;
5987 Ok(Some(()))
5988 }
5989
5990 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5991 ///
5992 /// One read of the item — which carries the board's field definitions and the issue's
5993 /// `blockedBy`, so neither is read again — and then only what differs from it: the
5994 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5995 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5996 /// the title, the body — visible content and metadata slot together — and a state change.
5997 /// So an update naming any of title, body, metadata, status and priority is one read and
5998 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5999 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6000 /// as a whole write does; an open one selects its option and then reopens. The origin
6001 /// field is never written: an update is of an item that already exists, whose origin is
6002 /// what it is.
6003 ///
6004 /// The task answered is the item as those writes left it, built from the read and what was
6005 /// sent rather than read again — the same record a later read in this run answers from.
6006 async fn targeted_update(
6007 &self,
6008 id: &NativeId,
6009 update: &TaskUpdate,
6010 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6011 // Everything this source can refuse without reading the item is refused first, in the
6012 // words a whole write of the same fields is refused with.
6013 update.consistent()?;
6014 if update
6015 .title
6016 .as_deref()
6017 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6018 {
6019 return Err(SourceError::Refused {
6020 message: format!(
6021 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6022 spells a document, so it would read back as one rather than as a task; \
6023 retitle it",
6024 self.name
6025 ),
6026 });
6027 }
6028 if let Some(delivers) = &update.delivers {
6029 TaskRef::listed(
6030 TaskRef::DELIVERS_KEY,
6031 id,
6032 Some(&self.name),
6033 delivers.clone(),
6034 )
6035 .map_err(|message| SourceError::Refused { message })?;
6036 }
6037 if self.priorities.is_none()
6038 && update
6039 .priority
6040 .is_some_and(|priority| priority != Priority::None)
6041 {
6042 return Err(self.holds_no_priority());
6043 }
6044 let target = update
6045 .status
6046 .as_ref()
6047 .map(|status| self.resolved_target(ItemKind::Task, status.category))
6048 .transpose()?;
6049 let Some(mut item) = self
6050 .bound_item(id)
6051 .await?
6052 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6053 else {
6054 return Ok(None);
6055 };
6056 let before = item.task()?;
6057
6058 let mut status_move = None;
6059 if let (Some(status), Some(target)) = (&update.status, target) {
6060 let board = self.status_board(&item).await?;
6061 let (field, option, name) = self
6062 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6063 .ok_or_else(|| SourceError::Malformed {
6064 message: format!(
6065 "status {} of source {} names no board Status option",
6066 category_name(status.category),
6067 self.name
6068 ),
6069 })?;
6070 let terminal = matches!(target, StatusTarget::Terminal(_, _));
6071 if terminal && item.content_kind == ContentKind::DraftIssue {
6072 return Err(self.closes_a_draft(status.category));
6073 }
6074 let landed = match &target {
6075 StatusTarget::Terminal(_, reason) => {
6076 self.statuses
6077 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6078 }
6079 _ => self
6080 .statuses
6081 .status(ItemKind::Task, Some(&name), false, None),
6082 };
6083 let option_moves = item
6084 .option
6085 .as_deref()
6086 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6087 let state_moves = item.content_kind == ContentKind::Issue
6088 && (item.closed != terminal || (terminal && item.status != landed));
6089 if let Some(moves) = Moves::of(option_moves, state_moves) {
6090 status_move = Some(StatusMove {
6091 board: board.id,
6092 field,
6093 option,
6094 name,
6095 target,
6096 landed,
6097 moves,
6098 });
6099 }
6100 }
6101
6102 let mut priority_move = None;
6103 if let Some(priority) = update.priority
6104 && self.priorities.is_some()
6105 && item.priority != HeldPriority::Read(priority)
6106 {
6107 let board = match (item.carried_board(), item.named_board()) {
6108 (Some(board), _) => board,
6109 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6110 id: board,
6111 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6112 },
6113 _ => self.board_fields().await?,
6114 };
6115 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6116 priority_move = Some((board.id, write, priority));
6117 }
6118 }
6119
6120 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6121 // recorded in the slot, and the slot travels in the one body update below.
6122 let edges = match &update.depends_on {
6123 Some(edges) => Some(
6124 self.partition_edges(
6125 BoardKind::Work(ItemKind::Task),
6126 item.content_kind,
6127 item.blocked_by.as_deref(),
6128 edges,
6129 )
6130 .await?,
6131 ),
6132 None => None,
6133 };
6134
6135 let mut slot = item.slot.clone();
6136 for (key, value) in &update.metadata_set {
6137 slot.insert(key.as_str().to_owned(), value.clone());
6138 }
6139 for key in &update.metadata_remove {
6140 slot.remove(key.as_str());
6141 }
6142 if let Some(delivers) = &update.delivers {
6143 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6144 }
6145 if let Some((_, recorded)) = &edges {
6146 record_edges(&mut slot, recorded);
6147 }
6148 let held = item.raw_body.clone().unwrap_or_default();
6149 let content = match &update.content {
6150 Some(content) => with_content(&held, content)?,
6151 None => held.clone(),
6152 };
6153 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6154 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6155 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6156 let body = if slot == item.slot {
6157 content
6158 } else {
6159 with_slot(&content, &slot)?
6160 };
6161 // Checked before anything is sent, as a content write checks it: content ending in
6162 // what this source reads as its own slot would read back as metadata.
6163 let (visible, read) = metadata_body(Some(body.clone()))?;
6164 let wanted = update.content.as_deref().or(item.body.as_deref());
6165 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6166 return Err(SourceError::Refused {
6167 message: format!(
6168 "this content ends in what source {} reads as its own metadata slot \
6169 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6170 as content; next: remove that trailing block from the content",
6171 self.name
6172 ),
6173 });
6174 }
6175 let recorded_moves =
6176 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6177
6178 // One `updateIssue` carries all three, because every mutation spends the secondary
6179 // limiter and the title, body and state are one mutation's inputs.
6180 let mut fields = serde_json::Map::new();
6181 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6182 fields.insert("title".to_owned(), json!(title));
6183 }
6184 if body != held {
6185 fields.insert("body".to_owned(), json!(body));
6186 }
6187 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6188 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6189 }
6190 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6191 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6192 // without undoing an earlier field when a later one fails — so a body written before a
6193 // board field the board then refused would be left changed. Written after every other
6194 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6195 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6196 // first, together in one request — a terminal option selected before the issue
6197 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6198 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6199 let mut clear: Option<(&BoardId, &str)> = None;
6200 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6201 board_writes.push((
6202 &moving.board,
6203 (
6204 moving.field.clone(),
6205 json!({"singleSelectOptionId": moving.option}),
6206 ),
6207 ));
6208 }
6209 match &priority_move {
6210 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6211 board,
6212 (field.clone(), json!({"singleSelectOptionId": option})),
6213 )),
6214 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6215 None => {}
6216 }
6217 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6218 boards.extend(clear.map(|(board, _)| board));
6219 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6220 for board in boards {
6221 let writes = board_writes
6222 .iter()
6223 .filter(|(on, _)| on.as_str() == board.as_str())
6224 .map(|(_, write)| write.clone())
6225 .collect::<Vec<_>>();
6226 let cleared = clear
6227 .filter(|(on, _)| on.as_str() == board.as_str())
6228 .map(|(_, field)| field);
6229 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6230 .await?;
6231 }
6232 let mut blocked_by_moved = false;
6233 if let Some((native, _)) = &edges
6234 && item.content_kind == ContentKind::Issue
6235 {
6236 blocked_by_moved = self
6237 .reconcile_blocked_by(
6238 &item.id,
6239 native,
6240 Issue::Existing(item.blocked_by.as_deref()),
6241 )
6242 .await?;
6243 }
6244 if !fields.is_empty() {
6245 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6246 .await?;
6247 }
6248
6249 if let Some(title) = &update.title {
6250 item.title.clone_from(title);
6251 }
6252 item.body = visible.filter(|value| !value.is_empty());
6253 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6254 item.slot = slot;
6255 if let Some(delivers) = &update.delivers {
6256 item.delivers.clone_from(delivers);
6257 }
6258 if let Some(moving) = status_move {
6259 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6260 && item.content_kind == ContentKind::Issue;
6261 item.status = moving.landed;
6262 item.option = Some(moving.name);
6263 }
6264 if let Some((_, _, priority)) = priority_move {
6265 item.priority = HeldPriority::Read(priority);
6266 }
6267 let task = item.task()?;
6268 let mut written = update.changed(&before, &task);
6269 if blocked_by_moved || recorded_moves {
6270 written.insert(UpdatedField::DependsOn);
6271 }
6272 self.remember_written(item, false)?;
6273 Ok(Some(TaskUpdateOutcome {
6274 task,
6275 written,
6276 delivers_before: before.delivers,
6277 }))
6278 }
6279
6280 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6281 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6282 ///
6283 /// One update of the body: the content outside the slot, and inside it that one entry,
6284 /// every other entry kept as it was. This source keeps no template answers — an issue has
6285 /// no room beside itself that is not its body, and answers written there would duplicate
6286 /// what the content already says and count against GitHub's body limit — so `answers`
6287 /// reaches nothing here. A body that would not change is not sent at all.
6288 async fn replace_rendering(
6289 &self,
6290 id: &NativeId,
6291 kind: BoardKind,
6292 content: &str,
6293 provenance: &Value,
6294 ) -> Result<Option<()>, SourceError> {
6295 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6296 return Ok(None);
6297 };
6298 let held = item.raw_body.clone().unwrap_or_default();
6299 let mut slot = item.slot.clone();
6300 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6301 let body = with_slot(&with_content(&held, content)?, &slot)?;
6302 // Checked before anything is sent, as a content write checks it.
6303 let (visible, read) = metadata_body(Some(body.clone()))?;
6304 if visible.as_deref().unwrap_or_default() != content || read != slot {
6305 return Err(SourceError::Refused {
6306 message: format!(
6307 "this content ends in what source {} reads as its own metadata slot \
6308 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6309 as content; next: remove that trailing block from the template",
6310 self.name
6311 ),
6312 });
6313 }
6314 if body != held {
6315 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6316 .await?;
6317 }
6318 item.body = visible.filter(|value| !value.is_empty());
6319 item.raw_body = Some(body);
6320 item.slot = read;
6321 self.remember_written(item, false)?;
6322 Ok(Some(()))
6323 }
6324
6325 async fn set_item_field(
6326 &self,
6327 board_id: &str,
6328 item_id: &str,
6329 field_id: &str,
6330 value: Value,
6331 ) -> Result<(), SourceError> {
6332 let data = self
6333 .graphql(
6334 graphql::UPDATE_FIELD,
6335 json!({"input":{
6336 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6337 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6338 )
6339 .await?;
6340 let returned = data
6341 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6342 .ok_or_else(|| SourceError::Malformed {
6343 message: "GitHub field update returned no project item".into(),
6344 })?;
6345 if required_str(returned, "id")? != item_id {
6346 return Err(SourceError::Malformed {
6347 message: "GitHub field update returned the wrong project item".into(),
6348 });
6349 }
6350 Ok(())
6351 }
6352
6353 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6354 /// one request. Every returned item id is checked, including optional aliases.
6355 async fn set_item_fields(
6356 &self,
6357 board: &str,
6358 item: &str,
6359 fields: &[(String, Value)],
6360 clear: Option<&str>,
6361 ) -> Result<(), SourceError> {
6362 if fields.len() <= 1 && clear.is_none() {
6363 if let Some((field, value)) = fields.first() {
6364 self.set_item_field(board, item, field, value.clone())
6365 .await?;
6366 }
6367 return Ok(());
6368 }
6369 if fields.is_empty() {
6370 if let Some(field) = clear {
6371 self.write_priority(
6372 board,
6373 item,
6374 &PriorityWrite::Clear {
6375 field: field.to_owned(),
6376 },
6377 )
6378 .await?;
6379 }
6380 return Ok(());
6381 }
6382 let input = |index: usize| {
6383 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6384 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6385 };
6386 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6387 "input":input(0),"second":input(1),"third":input(2),
6388 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6389 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6390 })).await?;
6391 for alias in [
6392 Some("updateProjectV2ItemFieldValue"),
6393 (fields.len() > 1).then_some("second"),
6394 (fields.len() > 2).then_some("third"),
6395 clear.map(|_| "cleared"),
6396 ]
6397 .into_iter()
6398 .flatten()
6399 {
6400 let returned = data
6401 .get(alias)
6402 .and_then(|value| value.get("projectV2Item"))
6403 .ok_or_else(|| SourceError::Malformed {
6404 message: format!("GitHub field update {alias} returned no project item"),
6405 })?;
6406 if required_str(returned, "id")? != item {
6407 return Err(SourceError::Malformed {
6408 message: format!("GitHub field update {alias} returned the wrong project item"),
6409 });
6410 }
6411 }
6412 Ok(())
6413 }
6414
6415 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6416 let mut after: Option<String> = None;
6417 let mut ids = Vec::new();
6418 loop {
6419 let data = self
6420 .graphql(
6421 graphql::ISSUE_DEPENDENCIES,
6422 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6423 )
6424 .await?;
6425 let connection =
6426 data.pointer("/node/blockedBy")
6427 .ok_or_else(|| SourceError::Malformed {
6428 message: "GitHub dependency response has no blockedBy connection".into(),
6429 })?;
6430 ids.extend(
6431 connection
6432 .get("nodes")
6433 .and_then(Value::as_array)
6434 .ok_or_else(|| SourceError::Malformed {
6435 message: "GitHub dependency response nodes is not an array".into(),
6436 })?
6437 .iter()
6438 .map(|value| required_str(value, "id").map(str::to_owned))
6439 .collect::<Result<Vec<_>, _>>()?,
6440 );
6441 let next = next_cursor(connection)?;
6442 if let Some(next) = &next {
6443 validate_cursor_progress(after.as_deref(), &next.0)?;
6444 }
6445 after = next.map(|cursor| cursor.0);
6446 if after.is_none() {
6447 return Ok(ids);
6448 }
6449 }
6450 }
6451
6452 async fn dependencies(
6453 &self,
6454 id: &NativeId,
6455 near_kind: ItemKind,
6456 direction: Direction,
6457 page: &PageRequest,
6458 ) -> Result<Page<DependencyEdge>, SourceError> {
6459 validate_page(page)?;
6460 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6461 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6462 let recorded = recorded_offset(cursor, direction)?;
6463 // What this issue is blocked by, when a read of it by its own id in this command
6464 // already carried the whole connection — a copy reads the item it writes before it
6465 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6466 // after it. Answered from that read, in the shape the dependency read answers in;
6467 // anything else is asked of GitHub.
6468 let carried = match direction {
6469 Direction::DependsOn => self
6470 .resolved_cache()?
6471 .get(id)
6472 .filter(|item| item.content_kind == ContentKind::Issue)
6473 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6474 Direction::DependedOnBy => None,
6475 }
6476 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6477 // Asked for even in the recorded phase, whose page reads nothing from the
6478 // connection: `__typename` is what says whether this item has a native
6479 // relationship at all, and that is what decides which far ends the reserved key is
6480 // allowed to hold.
6481 let data = match carried {
6482 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6483 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6484 None => {
6485 self.graphql(
6486 graphql::ISSUE_DEPENDENCIES,
6487 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6488 "after":if recorded.is_some() {None} else {cursor}}),
6489 )
6490 .await?
6491 }
6492 };
6493 let node =
6494 data.get("node")
6495 .filter(|v| !v.is_null())
6496 .ok_or_else(|| SourceError::Refused {
6497 message: format!(
6498 "GitHub item {} was not found or does not support dependencies",
6499 id.0
6500 ),
6501 })?;
6502 let connection_name = match direction {
6503 Direction::DependsOn => "blockedBy",
6504 Direction::DependedOnBy => "blocking",
6505 };
6506 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6507 // named natively and the reserved key may hold any far end. An issue's connections
6508 // hold issues, and this source reads them at the near item's own level.
6509 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6510 if let Some(offset) = recorded {
6511 return Ok(recorded_page(
6512 self.recorded_edges(id, near_kind, direction, natively_names, node)
6513 .await?,
6514 offset,
6515 limit,
6516 ));
6517 }
6518 if natively_names.is_none() {
6519 return Ok(recorded_page(
6520 self.recorded_edges(id, near_kind, direction, natively_names, node)
6521 .await?,
6522 0,
6523 limit,
6524 ));
6525 }
6526 let connection = node
6527 .get(connection_name)
6528 .ok_or_else(|| SourceError::Malformed {
6529 message: "GitHub dependency response is missing its connection".into(),
6530 })?;
6531 let nodes = connection
6532 .get("nodes")
6533 .and_then(Value::as_array)
6534 .ok_or_else(|| SourceError::Malformed {
6535 message: "GitHub dependency response nodes is not an array".into(),
6536 })?;
6537 // `from` depends on `to`, always. GitHub spells the same relationship from either
6538 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6539 // it — so the near item is `from` in one direction and `to` in the other.
6540 let items = nodes
6541 .iter()
6542 .map(|value| {
6543 let related = NativeId(required_str(value, "id")?.into());
6544 let related_kind = related_kind(value)?;
6545 let (from, to) = match direction {
6546 Direction::DependsOn => (
6547 DependencyEndpoint::from_native(id.clone(), near_kind),
6548 DependencyEndpoint::from_native(related, related_kind),
6549 ),
6550 Direction::DependedOnBy => (
6551 DependencyEndpoint::from_native(related, related_kind),
6552 DependencyEndpoint::from_native(id.clone(), near_kind),
6553 ),
6554 };
6555 Ok(DependencyEdge {
6556 from,
6557 to,
6558 kind: DependencyKind::Blocks,
6559 })
6560 })
6561 .collect::<Result<Vec<_>, SourceError>>()?;
6562 let mut next = next_cursor(connection)?;
6563 if let Some(next) = &next {
6564 validate_cursor_progress(cursor, &next.0)?;
6565 }
6566 if next.is_none()
6567 && !self
6568 .recorded_edges(id, near_kind, direction, natively_names, node)
6569 .await?
6570 .is_empty()
6571 {
6572 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6573 }
6574 Ok(Page { items, next })
6575 }
6576
6577 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6578 /// a far end in another source has to live: no GitHub issue relationship can name one.
6579 ///
6580 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6581 /// source never writes one down.
6582 ///
6583 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6584 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6585 /// request beyond the read already made, and reading the board for them would be a
6586 /// walk of every item for one field of one. A draft has no body in that answer, because
6587 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6588 /// listing of the board, which can be behind on the very item asked about.
6589 async fn recorded_edges(
6590 &self,
6591 id: &NativeId,
6592 near_kind: ItemKind,
6593 direction: Direction,
6594 natively_names: Option<ItemKind>,
6595 node: &Value,
6596 ) -> Result<Vec<DependencyEdge>, SourceError> {
6597 if direction != Direction::DependsOn {
6598 return Ok(Vec::new());
6599 }
6600 let slot = match node.get("body") {
6601 Some(body) if natively_names.is_some() => {
6602 metadata_body(body.as_str().map(str::to_owned))?.1
6603 }
6604 _ => {
6605 let Some(item) = self.bound_item(id).await? else {
6606 return Ok(Vec::new());
6607 };
6608 item.slot
6609 }
6610 };
6611 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6612 .map_err(|message| SourceError::Malformed { message })
6613 }
6614
6615 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6616 self.repository
6617 .as_ref()
6618 .ok_or_else(|| SourceError::Refused {
6619 message: format!(
6620 "source {} has no repository configured, and a GitHub Projects board has no \
6621 repository of its own to create an issue in; set repository: owner/name on \
6622 this source",
6623 self.name
6624 ),
6625 })
6626 }
6627
6628 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6629 /// states.
6630 ///
6631 /// The fallback is demanded first, whichever arm answers: a write without a configured
6632 /// repository is refused naming the field exactly as it was before the rule existed,
6633 /// so a source that could not write before cannot write now, rather than writing for
6634 /// the one item whose own field happens to decide it.
6635 ///
6636 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6637 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6638 /// entry owned by someone other than the owner of the parent issue's repository —
6639 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6640 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6641 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6642 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6643 /// and is visible to the token is checked where its node id is resolved, still before
6644 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6645 /// looked up in a listing of the board, which can be minutes behind an issue its own
6646 /// `projectItems` already places on it — and that read answers first from this process's
6647 /// own record, so a project created moments ago in this command answers though GitHub
6648 /// has not caught up.
6649 async fn creation_target(
6650 &self,
6651 incoming: &Incoming<'_>,
6652 ) -> Result<RepositoryTarget, SourceError> {
6653 let fallback = self.configured_repository()?;
6654 let what = |incoming: &Incoming<'_>| {
6655 format!(
6656 "{} {:?}",
6657 incoming.written.kind().describes(),
6658 incoming.title
6659 )
6660 };
6661 let parent = match incoming.parent {
6662 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6663 SourceError::Refused {
6664 message: format!(
6665 "GitHub project issue {} was not found on the board of source {}, so {} \
6666 cannot be filed under it",
6667 parent.0,
6668 self.name,
6669 what(incoming)
6670 ),
6671 }
6672 })?),
6673 None => None,
6674 };
6675 let parents_repository = parent
6676 .as_ref()
6677 .map(|parent| {
6678 // A draft is on the board and so is found, but it has no repository to
6679 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6680 // would refuse the task only once `createIssue` had made it.
6681 if parent.content_kind == ContentKind::DraftIssue {
6682 return Err(SourceError::Refused {
6683 message: format!(
6684 "GitHub project item {} on the board of source {} is a draft, \
6685 which cannot have sub-issues, so {} cannot be filed under it",
6686 parent.id.0,
6687 self.name,
6688 what(incoming)
6689 ),
6690 });
6691 }
6692 // An issue's repository is where a sub-issue is placed and whose owner it
6693 // is compared against, so a parent whose repository this source cannot
6694 // spell as `owner/name` — GitHub's login grammar is wider than this
6695 // source's floor — is one nothing can be filed under.
6696 parent
6697 .own_repository
6698 .as_ref()
6699 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6700 .ok_or_else(|| SourceError::Malformed {
6701 message: format!(
6702 "GitHub project issue {} on the board of source {} is in {}, which \
6703 is not a {}/owner/name repository this source can place {} in",
6704 parent.id.0,
6705 self.name,
6706 parent
6707 .own_repository
6708 .as_ref()
6709 .map_or("no repository", Repository::as_str),
6710 RepositoryTarget::HOST,
6711 what(incoming)
6712 ),
6713 })
6714 })
6715 .transpose()?;
6716 match incoming.repositories {
6717 [named] => {
6718 let target =
6719 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6720 message: format!(
6721 "{} names repository {}, which is not a {}/owner/name repository \
6722 source {} can create an issue in; name one that is, or name none",
6723 what(incoming),
6724 named.as_str(),
6725 RepositoryTarget::HOST,
6726 self.name
6727 ),
6728 })?;
6729 if let Some(parents) = &parents_repository
6730 && parents.owner != target.owner
6731 {
6732 return Err(SourceError::Refused {
6733 message: format!(
6734 "{} names repository {}, owned by {}, but its project's issue is in \
6735 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6736 of the same owner as its parent issue; name a repository of {}, or \
6737 name none",
6738 what(incoming),
6739 target.slug(),
6740 target.owner,
6741 parents.slug(),
6742 parents.owner,
6743 parents.owner
6744 ),
6745 });
6746 }
6747 Ok(target)
6748 }
6749 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6750 }
6751 }
6752
6753 /// The node id of the repository `incoming` is being created in, or the refusal naming
6754 /// the item and the repository the token cannot see.
6755 ///
6756 /// Resolved once per command per repository; see [`Self::repository_cache`].
6757 async fn repository_id(
6758 &self,
6759 repository: &RepositoryTarget,
6760 incoming: &Incoming<'_>,
6761 ) -> Result<String, SourceError> {
6762 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6763 return Ok(id);
6764 }
6765 let data = self
6766 .graphql(
6767 graphql::REPOSITORY,
6768 json!({"owner":repository.owner,"name":repository.name}),
6769 )
6770 .await?;
6771 self.repository_read(&data, repository, incoming)
6772 }
6773
6774 /// The repository's node id out of an answer carrying the `repository` root, held for
6775 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6776 fn repository_read(
6777 &self,
6778 data: &Value,
6779 repository: &RepositoryTarget,
6780 incoming: &Incoming<'_>,
6781 ) -> Result<String, SourceError> {
6782 let node = data
6783 .get("repository")
6784 .filter(|value| !value.is_null())
6785 .ok_or_else(|| SourceError::Refused {
6786 message: format!(
6787 "GitHub repository {} was not found or is not visible to the token, so {} \
6788 {:?} cannot be created in it",
6789 repository.slug(),
6790 incoming.written.kind().describes(),
6791 incoming.title
6792 ),
6793 })?;
6794 let id = required_str(node, "id")?.to_owned();
6795 self.repository_cache()?
6796 .insert(repository.clone(), id.clone());
6797 Ok(id)
6798 }
6799
6800 fn repository_cache(
6801 &self,
6802 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6803 self.repository_cache
6804 .lock()
6805 .map_err(|_| SourceError::Unavailable {
6806 message: "this source's record of the destination repository was left \
6807 inconsistent by an earlier failure; next: run the command again"
6808 .into(),
6809 })
6810 }
6811
6812 /// Create or update one board item, whichever kind it is.
6813 async fn write_item(
6814 &self,
6815 incoming: &Incoming<'_>,
6816 target: Option<&NativeId>,
6817 depends_on: &[DependencyEdge],
6818 ) -> Result<NativeId, SourceError> {
6819 // Refused before anything is read or written: a task or a project titled the way
6820 // this board spells a document would land as an issue this same source reads back
6821 // as a document, so the field this destination cannot carry is named rather than
6822 // written and silently reclassified.
6823 if let Written::Work(kind, _) = incoming.written
6824 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6825 {
6826 return Err(SourceError::Refused {
6827 message: format!(
6828 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6829 spells a document, so it would read back as one rather than as a {}; \
6830 retitle it, or copy it as a document",
6831 kind.marker(),
6832 self.name,
6833 kind.marker()
6834 ),
6835 });
6836 }
6837 // The destination is read by its own id, and whether this board holds it is decided
6838 // by that read — its own `projectItems` — rather than by whether a listing of the
6839 // board happens to include it yet. See the module documentation.
6840 let existing = match target {
6841 Some(target) => {
6842 Some(
6843 self.bound_item(target)
6844 .await?
6845 .ok_or_else(|| SourceError::Refused {
6846 message: format!("GitHub destination item {} was not found", target.0),
6847 })?,
6848 )
6849 }
6850 None => None,
6851 };
6852 let existing = existing.as_ref();
6853 // An existing issue is never moved; a new one is created where the rule says — and
6854 // knowing where is what lets the board's fields and that repository's id be read
6855 // together, before anything below needs either.
6856 let creation_target = match existing {
6857 Some(_) => None,
6858 None => {
6859 let target = self.creation_target(incoming).await?;
6860 self.creation_context(&target, incoming).await?;
6861 Some(target)
6862 }
6863 };
6864 let board = self
6865 .fields_for(
6866 existing,
6867 incoming.written.status().is_some(),
6868 incoming
6869 .priority
6870 .is_some_and(|priority| priority != Priority::None),
6871 )
6872 .await?;
6873 let status_target = incoming
6874 .written
6875 .work_status()
6876 .map(|(kind, status)| self.resolved_target(kind, status.category))
6877 .transpose()?;
6878 let column = match (incoming.written.work_status(), status_target.as_ref()) {
6879 (Some((kind, status)), Some(target)) => {
6880 self.column_for(&board.fields, kind, status.category, target)?
6881 }
6882 _ => None,
6883 };
6884 // Resolved before anything is created, for the reason the column above is: a
6885 // priority this board has no option for is refused while nothing has been written.
6886 let priority_write = match incoming.priority {
6887 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6888 None => None,
6889 };
6890 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6891 if content_kind == ContentKind::DraftIssue {
6892 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6893 (status_target.as_ref(), incoming.written.status())
6894 {
6895 return Err(self.closes_a_draft(status.category));
6896 }
6897 if incoming.parent.is_some() {
6898 return Err(SourceError::Refused {
6899 message: "GitHub draft items cannot be a project's sub-issue".into(),
6900 });
6901 }
6902 }
6903 match existing {
6904 Some(item) if content_kind == ContentKind::Issue => {
6905 if item.labels != incoming.labels {
6906 return Err(SourceError::Refused {
6907 message: "GitHub issue labels differ from the labels being written".into(),
6908 });
6909 }
6910 }
6911 _ => {
6912 if !incoming.labels.is_empty() {
6913 return Err(SourceError::Refused {
6914 message: "GitHub items created by this destination carry no labels".into(),
6915 });
6916 }
6917 }
6918 }
6919
6920 // The repository the issue really lives in is what the slot below is written against,
6921 // so a single entry that is where the issue is created travels as no key at all, and
6922 // the read side derives it back from the issue.
6923 let own_repository = match (existing, &creation_target) {
6924 (Some(item), _) => item.own_repository.clone(),
6925 (None, Some(target)) => Some(
6926 Repository::try_from(target.origin())
6927 .map_err(|message| SourceError::Config { message })?,
6928 ),
6929 (None, None) => None,
6930 };
6931 let (native, fallback) = self
6932 .partition_edges(
6933 incoming.written.kind(),
6934 content_kind,
6935 existing.and_then(|item| item.blocked_by.as_deref()),
6936 depends_on,
6937 )
6938 .await?;
6939 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6940 let body = compose_body(incoming.content, &slot)?;
6941 // Read before anything is created, for the reason the field below is: a value
6942 // this destination cannot store has to refuse, and refusing after `createIssue`
6943 // would leave an issue behind that nothing asked for. The engine writes a
6944 // qualified id here; a caller handing this key anything else is told so rather
6945 // than having it silently stored as no origin at all.
6946 // 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.
6947 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6948 None => "",
6949 Some(Value::String(origin)) => origin.as_str(),
6950 Some(other) => {
6951 return Err(SourceError::Refused {
6952 message: format!(
6953 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6954 is {other}"
6955 ),
6956 });
6957 }
6958 };
6959 // Resolved before anything is created: a board that cannot carry the copy origin
6960 // has to refuse the write, and refusing it after `createIssue` would leave an
6961 // issue behind that nothing asked for.
6962 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6963 Some(field) => {
6964 if required_str(field, "__typename")? != "ProjectV2Field" {
6965 return Err(SourceError::Refused {
6966 message: format!(
6967 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6968 ),
6969 });
6970 }
6971 Some(required_str(field, "id")?.to_owned())
6972 }
6973 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6974 return Err(SourceError::Refused {
6975 message: format!(
6976 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6977 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6978 the board"
6979 ),
6980 });
6981 }
6982 None => None,
6983 };
6984
6985 let Landed {
6986 content_id,
6987 item_id,
6988 url,
6989 number,
6990 } = match existing {
6991 // Its content is written last, below, once everything else has landed.
6992 Some(item) => Landed {
6993 content_id: item.id.clone(),
6994 item_id: item.item_id.clone(),
6995 url: item.url.clone(),
6996 number: item.number,
6997 },
6998 None => {
6999 let target = creation_target
7000 .as_ref()
7001 .ok_or_else(|| SourceError::Malformed {
7002 message: "a new item was decided without a repository to create it in"
7003 .into(),
7004 })?;
7005 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7006 .await?
7007 }
7008 };
7009
7010 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7011 let column = column
7012 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7013 .map(|(field, option, _)| (field, option));
7014 // Creating an item here is several calls — `createIssue`, which files it on the
7015 // board, then its board fields, the parent and the dependencies — and GitHub can fail
7016 // at any of them. Everything this source can refuse *before* the first of those is
7017 // already checked above, so what is left is GitHub itself failing part way. When it
7018 // does over an item this call created, the issue is taken back: a write that
7019 // refused must not leave an item behind that nobody asked for, and one that does
7020 // makes the retry create a second.
7021 // Whether the board-field write carrying a moved origin was answered as landing whole.
7022 // When it was refused, GitHub does not say which of its fields ran before the one that
7023 // failed, so the origin may or may not have moved.
7024 let mut origin_landed = false;
7025 let landed = self
7026 .finish_write(
7027 board.id.as_str(),
7028 incoming,
7029 &content_id,
7030 &item_id,
7031 content_kind,
7032 existing,
7033 origin_field.as_deref(),
7034 origin,
7035 column,
7036 status_target.as_ref(),
7037 priority_write.as_ref(),
7038 &native,
7039 &mut origin_landed,
7040 )
7041 .await;
7042 // An existing item's title, body and state go last, in one `updateIssue`, once its board
7043 // fields and its relationships have landed: a refusal of any of those then leaves its
7044 // body — and the metadata slot inside it — exactly as it stood.
7045 let landed = match (landed, existing) {
7046 (Ok(()), Some(item)) => {
7047 self.update_existing(item, incoming, &body, status_target.as_ref())
7048 .await
7049 }
7050 (landed, _) => landed,
7051 };
7052 if let Err(error) = landed {
7053 match existing {
7054 // Best effort, and the write's own failure is what the caller is told: a
7055 // refusal naming the tidy-up would hide why the write failed at all.
7056 None => {
7057 let _ = self.delete_issue(&content_id).await;
7058 }
7059 // The origin field is the one piece of an existing item's metadata written
7060 // before its body, so a write refused after it puts it back as it was. When
7061 // that is refused too, the write's own failure is still what the caller is
7062 // told — with what it left behind added, because the item's metadata is then
7063 // not as it stood and a caller retrying has to know which key moved.
7064 Some(item) => {
7065 let before = item.origin.as_deref().unwrap_or("");
7066 if let Some(field) = origin_field.as_deref()
7067 && before != origin
7068 && let Err(restore) = self
7069 .set_item_field(
7070 board.id.as_str(),
7071 &item.item_id,
7072 field,
7073 json!({"text": before}),
7074 )
7075 .await
7076 {
7077 let left = if origin_landed {
7078 format!(
7079 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7080 not be put back to {before:?} ({restore}), so item {} still \
7081 holds {origin:?} there",
7082 item.id.0
7083 )
7084 } else {
7085 format!(
7086 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7087 {origin:?}, GitHub does not say whether that part of it ran, \
7088 and putting it back to {before:?} was refused ({restore}), so \
7089 item {} holds {origin:?} or {before:?} there",
7090 item.id.0
7091 )
7092 };
7093 return Err(noting(
7094 error,
7095 &format!(
7096 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7097 run the write again"
7098 ),
7099 ));
7100 }
7101 }
7102 }
7103 return Err(error);
7104 }
7105
7106 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7107 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7108 self.statuses
7109 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7110 }
7111 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7112 self.statuses
7113 .status(kind, written_option.as_deref(), false, None)
7114 }
7115 (Some((_, status)), _) => status.clone(),
7116 (None, _) => Status {
7117 category: StatusCategory::Unknown,
7118 name: "Open".to_owned(),
7119 },
7120 };
7121
7122 // So the rest of this command reads what it just did rather than what the board
7123 // said before it. See `remember_written` for which half takes it.
7124 let remembered = Resolved {
7125 item_id,
7126 id: content_id.clone(),
7127 content_kind,
7128 kind: incoming.written.kind(),
7129 title: incoming.title.to_owned(),
7130 // The visible half of the body this write composed, split back off it the
7131 // way a read splits it — so what this record reports is what a read of the
7132 // same issue reports, rather than the person's text with the metadata slot
7133 // still on the end of it.
7134 body: metadata_body(body.clone())?.0,
7135 raw_body: body.clone(),
7136 // A document has no status of its own; what it reads back as is whatever
7137 // the issue's own state says, which is what a re-read reports.
7138 status: written_status,
7139 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7140 priority: match incoming.priority {
7141 Some(priority) => HeldPriority::Read(priority),
7142 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7143 item.priority.clone()
7144 }),
7145 },
7146 // What `state_input` asked for: closed for a terminal target, open for any other
7147 // status, and the issue's own state left as it was by a document write.
7148 closed: content_kind == ContentKind::Issue
7149 && match status_target.as_ref() {
7150 Some(StatusTarget::Terminal(_, _)) => true,
7151 Some(_) => false,
7152 None => existing.is_some_and(|item| item.closed),
7153 },
7154 delivers: incoming.delivers.to_vec(),
7155 delivered_by: incoming.delivered_by.to_vec(),
7156 labels: incoming.labels.to_vec(),
7157 parent: incoming.parent.cloned(),
7158 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7159 number,
7160 // In the update path this is the item's own url, read off `existing` where the
7161 // record above was bound, so one expression serves both halves.
7162 url,
7163 created_at: existing.and_then(|item| item.created_at),
7164 updated_at: existing.and_then(|item| item.updated_at),
7165 own_repository,
7166 repositories: incoming.repositories.to_vec(),
7167 slot,
7168 board_id: Some(board.id.as_str().to_owned()),
7169 fields: board
7170 .fields
7171 .get("nodes")
7172 .and_then(Value::as_array)
7173 .cloned()
7174 .unwrap_or_default(),
7175 board_fields: Some(board.fields.clone()),
7176 // What this write left the relationship holding is known by id alone, and a
7177 // later read of its edges needs each far end's kind, so it reads them again.
7178 blocked_by: None,
7179 };
7180 self.remember_written(remembered, existing.is_none())?;
7181 Ok(content_id)
7182 }
7183
7184 /// Everything a write does after the item exists: its board fields, its parent, and
7185 /// its dependencies.
7186 ///
7187 /// Split out of `write_item` so there is one place a failure past the point of no
7188 /// return is caught, rather than a tidy-up repeated at each `?` above.
7189 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7190 // so there is one place a failure past the point of no return is caught, and its
7191 // arguments are exactly the values that tail already had in scope. Bundling them into a
7192 // struct would describe no concept — it would be "the arguments of this function" — and
7193 // would put the whole of `write_item`'s locals behind one more indirection.
7194 #[allow(clippy::too_many_arguments)]
7195 async fn finish_write(
7196 &self,
7197 board_id: &str,
7198 incoming: &Incoming<'_>,
7199 content_id: &NativeId,
7200 item_id: &str,
7201 content_kind: ContentKind,
7202 existing: Option<&Resolved>,
7203 origin_field: Option<&str>,
7204 origin: &str,
7205 column: Option<(String, String)>,
7206 status_target: Option<&StatusTarget>,
7207 priority: Option<&PriorityWrite>,
7208 native: &[String],
7209 origin_landed: &mut bool,
7210 ) -> Result<(), SourceError> {
7211 let mut fields = Vec::new();
7212 if let Some(field_id) = origin_field
7213 && existing.map_or(!origin.is_empty(), |item| {
7214 item.origin.as_deref().unwrap_or("") != origin
7215 })
7216 {
7217 fields.push((field_id.to_owned(), json!({"text":origin})));
7218 }
7219 if let Some((field_id, option_id)) = column {
7220 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7221 }
7222 let clear = match priority {
7223 Some(PriorityWrite::Select { field, option }) => {
7224 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7225 None
7226 }
7227 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7228 None => None,
7229 };
7230 self.set_item_fields(board_id, item_id, &fields, clear)
7231 .await?;
7232 *origin_landed = true;
7233
7234 // An existing issue closes in the `updateIssue` its write ends with; one created just
7235 // now closes here, once its option is selected.
7236 if existing.is_none()
7237 && content_kind == ContentKind::Issue
7238 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7239 {
7240 self.update_content(
7241 ContentKind::Issue,
7242 content_id,
7243 json!({"stateInput":state_input(status_target)}),
7244 )
7245 .await?;
7246 }
7247
7248 if content_kind == ContentKind::Issue {
7249 self.reparent(
7250 existing.and_then(|item| item.parent.clone()),
7251 content_id,
7252 incoming.parent,
7253 )
7254 .await?;
7255 // A document takes part in no dependency graph, so writing one neither reads
7256 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7257 // against the empty list a document write carries would *delete* whatever
7258 // relationships a person had made on that issue, which is a write nobody
7259 // asked for.
7260 if incoming.written.kind() != BoardKind::Document {
7261 let issue = match existing {
7262 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7263 None => Issue::Created,
7264 };
7265 self.reconcile_blocked_by(content_id, native, issue).await?;
7266 }
7267 }
7268 Ok(())
7269 }
7270
7271 /// Delete one issue, which takes its board item with it.
7272 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7273 let data = self
7274 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7275 .await?;
7276 data.pointer("/deleteIssue/repository")
7277 .filter(|value| !value.is_null())
7278 .ok_or_else(|| SourceError::Malformed {
7279 message: "GitHub issue deletion returned no repository".into(),
7280 })?;
7281 self.forget(id)?;
7282 Ok(())
7283 }
7284
7285 /// Remove one item this copy created, so a copy that could not finish leaves the board
7286 /// as it found it.
7287 ///
7288 /// Deleting the issue takes its board item with it, so there is no second mutation to
7289 /// keep in step. An id the board does not hold is not an error: the item is already
7290 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7291 /// by its own id — a listing of the board can still be missing an item it holds, and
7292 /// reading that as *already gone* would leave behind the very item this was asked to
7293 /// take back.
7294 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7295 let Some(item) = self.bound_item(id).await? else {
7296 return Ok(());
7297 };
7298 if item.content_kind == ContentKind::DraftIssue {
7299 return Err(SourceError::Refused {
7300 message: format!(
7301 "GitHub item {} is a draft, and this source removes an item by deleting \
7302 its issue; next: remove it from the board by hand",
7303 id.0
7304 ),
7305 });
7306 }
7307 let data = self
7308 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7309 .await?;
7310 data.pointer("/deleteIssue/repository")
7311 .filter(|value| !value.is_null())
7312 .ok_or_else(|| SourceError::Malformed {
7313 message: "GitHub issue deletion returned no repository".into(),
7314 })?;
7315 self.forget(id)?;
7316 Ok(())
7317 }
7318
7319 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7320 /// task.
7321 ///
7322 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7323 /// read of the task cannot disagree about which ids name one: a project or a document of
7324 /// this board is not a task here either.
7325 ///
7326 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7327 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7328 /// which would read as a task nobody has commented on yet.
7329 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7330 let cached = self.resolved_cache()?.get(task).cloned();
7331 let Some(item) = (match cached {
7332 Some(item) => Some(item),
7333 None => self.item_by_id(task).await?,
7334 })
7335 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7336 return Ok(None);
7337 };
7338 if item.content_kind == ContentKind::DraftIssue {
7339 return Err(self.draft_has_no_comments(task));
7340 }
7341 Ok(Some(item.id))
7342 }
7343
7344 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7345 /// issues, and a draft is not one.
7346 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7347 SourceError::Refused {
7348 message: format!(
7349 "task {} of source {} is a draft item on the board, and GitHub keeps \
7350 comments on issues alone, so a draft has none to read or write; next: \
7351 convert the draft to an issue on the board, then comment on the issue it \
7352 becomes",
7353 task.0, self.name
7354 ),
7355 }
7356 }
7357
7358 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7359 /// request — or `None` when this board holds no task by that id.
7360 ///
7361 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7362 /// is answered with the draft and the refusal, at the price of the draft's own read.
7363 async fn issue_detail(
7364 &self,
7365 id: &NativeId,
7366 page: &PageRequest,
7367 ) -> Result<Option<TaskDetailRead>, SourceError> {
7368 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7369 let asked = self
7370 .graphql(
7371 graphql::ISSUE_DETAIL,
7372 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7373 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7374 "duplicates":true}),
7375 )
7376 .await;
7377 let data = match asked {
7378 Ok(data) => data,
7379 Err(error) if unresolvable_node(&error) => return Ok(None),
7380 Err(error) => return Err(error),
7381 };
7382 // `node` is null for an id that names nothing, and absent only from an answer this
7383 // source cannot read — never the same thing.
7384 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7385 message: format!("GitHub answered the read of {} with no node", id.0),
7386 })?;
7387 self.detail_of(id, node, true, after).await
7388 }
7389
7390 /// Several tasks, each with the first page of its comments when `comments` is set, read
7391 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7392 /// order.
7393 ///
7394 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7395 /// one item at a time, so that id is answered as missing and the others as themselves; any
7396 /// other refusal is every id of that batch's answer.
7397 async fn issue_details(
7398 &self,
7399 ids: &[NativeId],
7400 comments: Option<&PageRequest>,
7401 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7402 let mut read = Vec::with_capacity(ids.len());
7403 for batch in ids.chunks(DETAIL_BATCH) {
7404 match self
7405 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7406 .await
7407 {
7408 Ok(data) => {
7409 for (slot, id) in batch.iter().enumerate() {
7410 // Every alias asked for is answered, null for an id naming nothing;
7411 // one missing is an answer this source cannot read.
7412 let read_one = match data.get(format!("i{slot}")) {
7413 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7414 None => Err(SourceError::Malformed {
7415 message: format!(
7416 "GitHub answered a batch read with no item for {}",
7417 id.0
7418 ),
7419 }),
7420 };
7421 read.push(read_one);
7422 }
7423 }
7424 Err(error) if unresolvable_node(&error) => {
7425 for id in batch {
7426 read.push(match comments {
7427 Some(page) => self.issue_detail(id, page).await,
7428 None => self.task_read(id).await,
7429 });
7430 }
7431 }
7432 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7433 }
7434 }
7435 read
7436 }
7437
7438 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7439 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7440 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7441 task,
7442 comments: None,
7443 }))
7444 }
7445
7446 /// What one node a detail read reached says: the task this board holds by `id`, with the
7447 /// page of comments the node carries when `commented` — or `None` for a node that is no
7448 /// task of this board.
7449 ///
7450 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7451 /// and an item this process created answers from this process's own record, which a node
7452 /// read taken moments after the write can still be behind.
7453 async fn detail_of(
7454 &self,
7455 id: &NativeId,
7456 node: &Value,
7457 commented: bool,
7458 after: Option<&str>,
7459 ) -> Result<Option<TaskDetailRead>, SourceError> {
7460 if node.is_null() {
7461 return Ok(None);
7462 }
7463 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7464 // An issue answered under one id is that id's, or the answer is not one this source
7465 // can report: reporting another issue's task and comments under the qualified id asked
7466 // for would be the one wrong answer here. A draft's own read checks the same.
7467 if !draft
7468 && optional_str(node, "__typename")? == Some("Issue")
7469 && required_str(node, "id")? != id.0
7470 {
7471 return Err(SourceError::Malformed {
7472 message: format!(
7473 "GitHub answered the read of {} with issue {}",
7474 id.0,
7475 required_str(node, "id")?
7476 ),
7477 });
7478 }
7479 let item = if draft {
7480 self.draft_by_id(id).await?
7481 } else {
7482 self.resolve_issue(node).await?
7483 };
7484 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7485 return Ok(None);
7486 };
7487 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7488 let task = own.unwrap_or(item).task()?;
7489 let comments = match (commented, draft) {
7490 (false, _) => None,
7491 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7492 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7493 };
7494 Ok(Some(TaskDetailRead { task, comments }))
7495 }
7496
7497 /// Whether the comment `comment` is one of `issue`'s own.
7498 ///
7499 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7500 /// comment's id and nothing else: a comment id given against the wrong task would
7501 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7502 /// names something that is not an issue comment, is a comment this task does not have —
7503 /// which is what GitHub refusing to resolve it means too.
7504 async fn comment_is_on(
7505 &self,
7506 issue: &NativeId,
7507 comment: &NativeId,
7508 ) -> Result<bool, SourceError> {
7509 let asked = self
7510 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7511 .await;
7512 let data = match asked {
7513 Ok(data) => data,
7514 Err(error) if unresolvable_node(&error) => return Ok(false),
7515 Err(error) => return Err(error),
7516 };
7517 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7518 return Ok(false);
7519 };
7520 if optional_str(node, "__typename")? != Some("IssueComment") {
7521 return Ok(false);
7522 }
7523 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7524 message: format!("GitHub issue comment {} names no issue", comment.0),
7525 })?;
7526 Ok(required_str(on, "id")? == issue.0)
7527 }
7528
7529 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7530 async fn partition_edges(
7531 &self,
7532 near_kind: BoardKind,
7533 near_content: ContentKind,
7534 carried: Option<&[Value]>,
7535 depends_on: &[DependencyEdge],
7536 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7537 let mut native = Vec::new();
7538 let mut fallback = Vec::new();
7539 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7540 .iter()
7541 .map(|edge| {
7542 let same_source = edge
7543 .to
7544 .source()
7545 .is_none_or(|source| source == self.name.as_str());
7546 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7547 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7548 // colons of its own, so the far end is everything after that one separator.
7549 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7550 let far_id = if edge.to.is_qualified() {
7551 edge.to
7552 .id()
7553 .split_once(':')
7554 .map_or(edge.to.id(), |(_, native)| native)
7555 } else {
7556 edge.to.id()
7557 };
7558 // One that already blocks the near issue was answered by that issue's own
7559 // read, which carried each of its blockers' kinds — an issue every one — so it
7560 // is not read again.
7561 let blocking = carried.and_then(|nodes| {
7562 nodes
7563 .iter()
7564 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7565 });
7566 (edge, far_id, same_source, blocking)
7567 })
7568 .collect();
7569 // Every other same-source far end is read by its own id, exactly as the item it is a
7570 // far end of is: whether this board holds it is that read's answer, never a listing's.
7571 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7572 let mut unread: Vec<NativeId> = Vec::new();
7573 for (_, far_id, same_source, blocking) in &far_ends {
7574 let id = NativeId((*far_id).to_owned());
7575 if *same_source && blocking.is_none() && !unread.contains(&id) {
7576 unread.push(id);
7577 }
7578 }
7579 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7580 .iter()
7581 .cloned()
7582 .zip(self.items_by_ids(&unread).await?)
7583 .collect();
7584 for (edge, far_id, same_source, blocking) in far_ends {
7585 let far = match (same_source, blocking) {
7586 (false, _) => None,
7587 (true, Some(node)) => Some(FarEnd {
7588 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7589 BoardKind::Document
7590 } else {
7591 BoardKind::Work(related_kind(node)?)
7592 },
7593 content_kind: ContentKind::Issue,
7594 }),
7595 (true, None) => {
7596 let read = read
7597 .get(&NativeId(far_id.to_owned()))
7598 .cloned()
7599 .flatten()
7600 .ok_or_else(|| SourceError::Refused {
7601 message: format!("GitHub dependency item {far_id} was not found"),
7602 })?;
7603 Some(FarEnd {
7604 kind: read.kind,
7605 content_kind: read.content_kind,
7606 })
7607 }
7608 };
7609 let far = far.as_ref();
7610 // The caller says which kind the far end is, and this board holds the far end
7611 // itself, so a disagreement is settled here rather than stored: recorded, the
7612 // wrong kind would read back as a cross-level edge that never existed; written
7613 // natively, it would name a relationship of a different level than the caller
7614 // asked for.
7615 //
7616 // A far end this board holds as a *document* fails the same comparison and is
7617 // refused by the same sentence: `ItemKind` has no document variant because
7618 // nothing may point at one, so no caller can name it correctly and the refusal
7619 // is the only honest answer.
7620 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7621 return Err(SourceError::Refused {
7622 message: format!(
7623 "GitHub dependency item {far_id} is a {} of this board, and this item \
7624 names it as a {}; record the kind it is",
7625 disagreeing.kind.describes(),
7626 edge.to.kind.marker()
7627 ),
7628 });
7629 }
7630 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7631 // however the far end is spelled — and one classified native here would be
7632 // written nowhere at all, because a draft's native reconciliation never runs.
7633 let native_here = near_content == ContentKind::Issue
7634 && far.is_some_and(|far| {
7635 far.content_kind == ContentKind::Issue
7636 && BoardKind::Work(edge.to.kind) == near_kind
7637 });
7638 if native_here {
7639 native.push(far_id.to_owned());
7640 } else {
7641 fallback.push(edge.clone());
7642 }
7643 }
7644 Ok((native, fallback))
7645 }
7646
7647 async fn update_existing(
7648 &self,
7649 item: &Resolved,
7650 incoming: &Incoming<'_>,
7651 body: &Option<String>,
7652 status_target: Option<&StatusTarget>,
7653 ) -> Result<(), SourceError> {
7654 let title = incoming.written_title();
7655 // A terminal status closes the issue here, in the same mutation as its body: its board
7656 // option was selected before this, so a close never lands on an item whose board cannot
7657 // show it.
7658 let fields = match item.content_kind {
7659 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7660 ContentKind::Issue => json!({"title":title,"body":body,
7661 "stateInput":state_input(status_target)}),
7662 };
7663 self.update_content(item.content_kind, &item.id, fields)
7664 .await
7665 }
7666
7667 /// Update one board item's content with exactly `fields` beside its id, through the
7668 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7669 /// a draft.
7670 ///
7671 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7672 /// is what lets a narrow write carry the one thing it changes and nothing else.
7673 async fn update_content(
7674 &self,
7675 kind: ContentKind,
7676 id: &NativeId,
7677 fields: Value,
7678 ) -> Result<(), SourceError> {
7679 let (operation, id_key, pointer) = match kind {
7680 ContentKind::DraftIssue => (
7681 graphql::UPDATE_DRAFT,
7682 "draftIssueId",
7683 "/updateProjectV2DraftIssue/draftIssue",
7684 ),
7685 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7686 };
7687 let mut input = fields;
7688 input[id_key] = json!(id.0);
7689 let data = self.graphql(operation, json!({"input":input})).await?;
7690 let returned = data
7691 .pointer(pointer)
7692 .ok_or_else(|| SourceError::Malformed {
7693 message: "GitHub item update returned no item".into(),
7694 })?;
7695 if required_str(returned, "id")? != id.0 {
7696 return Err(SourceError::Malformed {
7697 message: "GitHub item update returned the wrong item".into(),
7698 });
7699 }
7700 Ok(())
7701 }
7702
7703 /// Creates one issue, files it on the board, and reports what a read of it would say:
7704 /// its content id, its board item id, and the web address GitHub gave it.
7705 ///
7706 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7707 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7708 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7709 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7710 /// "Content already exists in this project". A terminal status is not written here:
7711 /// `finish_write` selects its option first and closes the issue after, so a close never
7712 /// lands on an item whose board cannot show it.
7713 ///
7714 /// The address and the number come back here because this is the only place either is
7715 /// known before GitHub's own board read catches up — an item this run created answers
7716 /// the reads that follow it out of the record below, and one remembered without them
7717 /// would report no location and no key for the rest of the run.
7718 async fn create_and_file_issue(
7719 &self,
7720 board_id: &str,
7721 repository: &RepositoryTarget,
7722 incoming: &Incoming<'_>,
7723 body: &Option<String>,
7724 ) -> Result<Landed, SourceError> {
7725 let repository_id = self.repository_id(repository, incoming).await?;
7726 let data = self
7727 .graphql(
7728 graphql::CREATE_ISSUE,
7729 json!({"input":{
7730 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7731 }}),
7732 )
7733 .await?;
7734 let created = data
7735 .pointer("/createIssue/issue")
7736 .filter(|value| !value.is_null())
7737 .ok_or_else(|| SourceError::Malformed {
7738 message: "GitHub issue creation returned no issue".into(),
7739 })?;
7740 let content_id = NativeId(required_str(created, "id")?.to_owned());
7741 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7742 // a response without it is not worth failing a landed write over — the item simply
7743 // reports no location until the board read catches up, which is what it did before.
7744 let url = optional_str(created, "url")?.map(str::to_owned);
7745 // The issue exists from here on, so an unreadable number and a refused board
7746 // filing below each try, best effort, to take it back: an issue in the repository
7747 // that is on no board is an item nobody asked for and nothing here would find again.
7748 //
7749 // Its number is optional on the same terms its address is — a landed write is not
7750 // worth failing over a member that came back missing, and such an item reports no
7751 // handle until a board read catches up. A number that is *present* and is not an
7752 // unsigned integer is still a response this source cannot read.
7753 let number = match created_issue_number(created) {
7754 Ok(number) => number,
7755 Err(error) => {
7756 let _ = self.delete_issue(&content_id).await;
7757 return Err(error);
7758 }
7759 };
7760 let added = match self
7761 .graphql(
7762 graphql::ADD_TO_BOARD,
7763 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7764 )
7765 .await
7766 {
7767 Ok(added) => added,
7768 Err(error) => {
7769 let _ = self.delete_issue(&content_id).await;
7770 return Err(error);
7771 }
7772 };
7773 let item = added
7774 .pointer("/addProjectV2ItemById/item")
7775 .filter(|value| !value.is_null())
7776 .ok_or_else(|| SourceError::Malformed {
7777 message: "GitHub board addition returned no project item".into(),
7778 })?;
7779 Ok(Landed {
7780 content_id,
7781 item_id: required_str(item, "id")?.to_owned(),
7782 url,
7783 number,
7784 })
7785 }
7786
7787 /// Move one issue under the project it now belongs to, or out of the one it left.
7788 async fn reparent(
7789 &self,
7790 held: Option<NativeId>,
7791 child: &NativeId,
7792 wanted: Option<&NativeId>,
7793 ) -> Result<(), SourceError> {
7794 if held.as_ref() == wanted {
7795 return Ok(());
7796 }
7797 if let Some(held) = &held {
7798 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7799 .await?;
7800 }
7801 if let Some(wanted) = wanted {
7802 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7803 .await?;
7804 }
7805 Ok(())
7806 }
7807
7808 async fn sub_issue(
7809 &self,
7810 operation: &str,
7811 parent: &NativeId,
7812 child: &NativeId,
7813 root: &str,
7814 ) -> Result<(), SourceError> {
7815 let data = self
7816 .graphql(
7817 operation,
7818 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7819 )
7820 .await?;
7821 let issue =
7822 data.pointer(&format!("/{root}/issue"))
7823 .ok_or_else(|| SourceError::Malformed {
7824 message: "GitHub sub-issue update returned no issue".into(),
7825 })?;
7826 let sub =
7827 data.pointer(&format!("/{root}/subIssue"))
7828 .ok_or_else(|| SourceError::Malformed {
7829 message: "GitHub sub-issue update returned no sub-issue".into(),
7830 })?;
7831 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7832 return Err(SourceError::Malformed {
7833 message: "GitHub sub-issue update returned the wrong issues".into(),
7834 });
7835 }
7836 Ok(())
7837 }
7838
7839 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7840 /// whether there was one.
7841 ///
7842 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7843 /// relationships are not read: there is nothing a read of them could find.
7844 async fn reconcile_blocked_by(
7845 &self,
7846 content_id: &NativeId,
7847 native: &[String],
7848 issue: Issue<'_>,
7849 ) -> Result<bool, SourceError> {
7850 let current = match issue {
7851 Issue::Created => Vec::new(),
7852 Issue::Existing(Some(held)) => held
7853 .iter()
7854 .map(|far| required_str(far, "id").map(str::to_owned))
7855 .collect::<Result<Vec<_>, _>>()?,
7856 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7857 };
7858 let mut changed = false;
7859 for (operation, far_id) in current
7860 .iter()
7861 .filter(|id| !native.contains(id))
7862 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7863 .chain(
7864 native
7865 .iter()
7866 .filter(|id| !current.contains(id))
7867 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7868 )
7869 {
7870 let data = self
7871 .graphql(
7872 operation,
7873 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7874 )
7875 .await?;
7876 let root = if operation == graphql::ADD_BLOCKED_BY {
7877 "addBlockedBy"
7878 } else {
7879 "removeBlockedBy"
7880 };
7881 let issue =
7882 data.pointer(&format!("/{root}/issue"))
7883 .ok_or_else(|| SourceError::Malformed {
7884 message: "GitHub dependency update returned no issue".into(),
7885 })?;
7886 let blocker = data
7887 .pointer(&format!("/{root}/blockingIssue"))
7888 .ok_or_else(|| SourceError::Malformed {
7889 message: "GitHub dependency update returned no blocking issue".into(),
7890 })?;
7891 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7892 {
7893 return Err(SourceError::Malformed {
7894 message: "GitHub dependency update returned the wrong issues".into(),
7895 });
7896 }
7897 changed = true;
7898 }
7899 Ok(changed)
7900 }
7901}
7902
7903/// What a write needs to know of one far end it names: which kind of item it is, and whether
7904/// it is an issue a native relationship can name.
7905struct FarEnd {
7906 kind: BoardKind,
7907 content_kind: ContentKind,
7908}
7909
7910/// Whether the issue one write reconciles was created by that write or was already there.
7911#[derive(Clone, Copy, PartialEq, Eq)]
7912enum Issue<'a> {
7913 /// Created by this write, so it holds no relationships yet.
7914 Created,
7915 /// On the board before this write, holding whatever relationships it holds — the far
7916 /// ends of its whole `blockedBy`, when the read that reached it carried them.
7917 Existing(Option<&'a [Value]>),
7918}
7919
7920/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7921enum Reached {
7922 /// An issue this board holds, resolved into everything this source reports about it.
7923 Held(Box<Resolved>),
7924 /// Nothing this board holds: no such node, or a node on some other board.
7925 Nothing,
7926 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7927 /// again by [`GitHubProjectsSource::draft_by_id`].
7928 Draft,
7929}
7930
7931/// What GitHub says when a string is not a node id it can resolve.
7932///
7933/// Matched because it is the ordinary answer to a project selector naming a project by its
7934/// *name*, and reporting that as a failure would make naming one impossible. It is read
7935/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7936/// does not define the syntax of a GitHub node id and would be wrong about it.
7937const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7938
7939/// `error` with `note` added to the end of what it says, its kind and every other member
7940/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7941/// what that failure left behind.
7942fn noting(error: SourceError, note: &str) -> SourceError {
7943 match error {
7944 SourceError::Config { message } => SourceError::Config {
7945 message: message + note,
7946 },
7947 SourceError::Auth { message } => SourceError::Auth {
7948 message: message + note,
7949 },
7950 SourceError::Refused { message } => SourceError::Refused {
7951 message: message + note,
7952 },
7953 SourceError::RateLimited {
7954 retry_after_seconds,
7955 message,
7956 } => SourceError::RateLimited {
7957 retry_after_seconds,
7958 message: Some(message.unwrap_or_default() + note),
7959 },
7960 SourceError::Unavailable { message } => SourceError::Unavailable {
7961 message: message + note,
7962 },
7963 SourceError::Malformed { message } => SourceError::Malformed {
7964 message: message + note,
7965 },
7966 }
7967}
7968
7969/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7970/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7971/// for them.
7972///
7973/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7974/// is read again at no added price.
7975fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7976 let mut variables = serde_json::Map::new();
7977 for slot in 0..DETAIL_BATCH {
7978 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7979 variables.insert(format!("id{slot}"), json!(id));
7980 }
7981 variables.insert(
7982 "first".to_owned(),
7983 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7984 );
7985 variables.insert("comments".to_owned(), json!(comments.is_some()));
7986 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7987 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7988 variables.insert("duplicates".to_owned(), json!(true));
7989 Value::Object(variables)
7990}
7991
7992/// Whether this refusal is GitHub saying the id names no node at all.
7993fn unresolvable_node(error: &SourceError) -> bool {
7994 matches!(error, SourceError::Refused { message }
7995 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7996}
7997
7998/// One project name, as a search qualifier which filters on it at the server.
7999///
8000/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8001/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8002/// the way it documents. A title matched here is still compared for equality afterwards:
8003/// the qualifier narrows what the server sends, and this source decides what it names.
8004fn title_qualifier(name: &str) -> String {
8005 format!("in:title {}", quoted(name))
8006}
8007
8008/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8009/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8010/// it documents — so a value holding a qualifier's spelling is searched for rather than
8011/// obeyed.
8012fn quoted(value: &str) -> String {
8013 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8014 format!("\"{escaped}\"")
8015}
8016
8017/// The search qualifier for the issues updated at or after `since`.
8018///
8019/// Written to the second, rounded down, which can only widen what the search returns.
8020fn updated_qualifier(since: DateTime<Utc>) -> String {
8021 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8022}
8023
8024/// The search terms that narrow a board-scoped issue search to a task query's text and
8025/// metadata predicates, or `None` when it carries neither.
8026///
8027/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8028/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8029/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8030/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8031/// a metadata value searches both fields for both — wider than asked, never narrower, and
8032/// every candidate is confirmed in process afterwards.
8033///
8034/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8035/// matches whole tokens where a substring rule would match inside a word, so an item holding
8036/// the text only inside a longer word is not returned. A text of nothing but whitespace
8037/// matches every item, so it narrows nothing and is not sent.
8038fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8039 let text = query
8040 .text
8041 .as_ref()
8042 .filter(|text| !text.terms.trim().is_empty());
8043 if text.is_none() && query.metadata.is_empty() {
8044 return None;
8045 }
8046 let (title, body) = match text.map(|text| text.fields) {
8047 None => (false, true),
8048 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8049 Some(TextFields::Content) => (false, true),
8050 Some(TextFields::TitleOrContent) => (true, true),
8051 };
8052 let fields = match (title, body) {
8053 (true, true) => "in:title,body",
8054 (true, false) => "in:title",
8055 _ => "in:body",
8056 };
8057 let phrases = text
8058 .map(|text| text.terms.clone())
8059 .into_iter()
8060 .chain(
8061 query
8062 .metadata
8063 .iter()
8064 .map(|wanted| as_stored(wanted.value())),
8065 )
8066 .map(|phrase| quoted(&phrase))
8067 .collect::<Vec<_>>();
8068 Some(format!("{fields} {}", phrases.join(" ")))
8069}
8070
8071/// The search terms that narrow a board-scoped issue search to a project or document query's
8072/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8073/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8074fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8075 narrowing_qualifiers(&TaskQuery {
8076 text: text.cloned(),
8077 ..TaskQuery::default()
8078 })
8079}
8080
8081/// Refuses a project or document query's text GitHub's issue search cannot find, before
8082/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8083/// query's.
8084fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8085 refuse_unsearchable(&TaskQuery {
8086 text: text.cloned(),
8087 ..TaskQuery::default()
8088 })
8089}
8090
8091/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8092/// before anything is asked of GitHub.
8093///
8094/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8095/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8096/// left out, the search is every issue of the board. So this source says it cannot answer
8097/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8098/// nothing GitHub could search for, and keeps the board read it always had.
8099fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8100 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8101 letter or digit with a bounded query";
8102 if let Some(text) = &query.text
8103 && !text.terms.trim().is_empty()
8104 && !has_words(&text.terms)
8105 {
8106 return Err(SourceError::Refused {
8107 message: format!(
8108 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8109 text.terms
8110 ),
8111 });
8112 }
8113 if let Some(wanted) = query
8114 .metadata
8115 .iter()
8116 .find(|wanted| !has_words(wanted.value()))
8117 {
8118 return Err(SourceError::Refused {
8119 message: format!(
8120 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8121 wanted.value(),
8122 std::iter::once(wanted.key())
8123 .chain(wanted.path().iter().map(String::as_str))
8124 .collect::<Vec<_>>()
8125 .join("/"),
8126 ),
8127 });
8128 }
8129 Ok(())
8130}
8131
8132/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8133fn has_words(phrase: &str) -> bool {
8134 phrase.chars().any(char::is_alphanumeric)
8135}
8136
8137/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8138///
8139/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8140/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8141/// which GitHub's word match would read as different words.
8142fn as_stored(value: &str) -> String {
8143 let encoded = Value::String(value.to_owned()).to_string();
8144 encoded[1..encoded.len() - 1].to_owned()
8145}
8146
8147/// The one narrower question a task query carrying a text, metadata or origin predicate is
8148/// sent as.
8149enum Narrowing {
8150 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8151 Origin(String),
8152 /// The board-scoped issue search narrowed by these qualifiers.
8153 Search(String),
8154}
8155
8156impl Narrowing {
8157 /// What this question is remembered under for the length of one command.
8158 fn key(&self) -> String {
8159 match self {
8160 Self::Origin(origin) => format!("origin {origin}"),
8161 Self::Search(also) => format!("search {also}"),
8162 }
8163 }
8164}
8165
8166/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8167enum Resumed {
8168 /// It reported another page, which starts after this cursor.
8169 More(String),
8170 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8171 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8172 /// can go on walking the other connection.
8173 Ended(Option<String>),
8174}
8175
8176impl Resumed {
8177 /// Whether the connection has another page.
8178 const fn has_more(&self) -> bool {
8179 matches!(self, Self::More(_))
8180 }
8181
8182 /// The cursor to send this connection next.
8183 fn cursor(self) -> Option<String> {
8184 match self {
8185 Self::More(next) => Some(next),
8186 Self::Ended(last) => last,
8187 }
8188 }
8189}
8190
8191/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8192/// with no cursor to it, or from a cursor that does not advance.
8193fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8194 let info = connection
8195 .get("pageInfo")
8196 .ok_or_else(|| SourceError::Malformed {
8197 message: "GitHub connection has no pageInfo".into(),
8198 })?;
8199 let end = optional_str(info, "endCursor")?;
8200 if required_bool(info, "hasNextPage")? {
8201 let next = end.ok_or_else(|| SourceError::Malformed {
8202 message: "GitHub connection reports another page and no endCursor".into(),
8203 })?;
8204 validate_cursor_progress(after, next)?;
8205 return Ok(Resumed::More(next.to_owned()));
8206 }
8207 Ok(Resumed::Ended(
8208 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8209 ))
8210}
8211
8212/// The board, and every item on it this source reports.
8213#[derive(Clone)]
8214struct Board {
8215 id: String,
8216 fields: Value,
8217 items: Vec<Resolved>,
8218}
8219
8220/// What a write needs of the board and nothing more: its node id and its field
8221/// definitions, in the shape a read of the board's own `fields` gives them.
8222///
8223/// Deliberately no items. A write decides which item it writes, which parent it files
8224/// under and which far ends it names by reading each of them by its own id; this is the
8225/// half of the board those reads cannot carry, and holding no item is what keeps it from
8226/// ever being asked whether an item is there.
8227#[derive(Clone)]
8228struct BoardFields {
8229 id: BoardId,
8230 fields: Value,
8231}
8232
8233/// A board's node id: what a field write and `addProjectV2ItemById` address.
8234///
8235/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8236/// refused where it is read, and one an item names blank is read as not named at all.
8237#[derive(Clone)]
8238struct BoardId(String);
8239
8240/// Where one write left its item, for the record the rest of the command reads it out of.
8241///
8242/// A named record rather than a tuple because the update arm and the create arm each fill
8243/// all four, and two `Option`s of different meaning side by side in a tuple are two
8244/// positions a reader has to count.
8245struct Landed {
8246 /// The issue's own node id, which is the [`NativeId`] this source reports.
8247 content_id: NativeId,
8248 /// The board item's id, which is what a field write addresses.
8249 // 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.
8250 item_id: String,
8251 /// The web address GitHub gave the issue, when it gave one.
8252 // 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.
8253 url: Option<String>,
8254 /// The issue's number on its repository, when GitHub reported one.
8255 number: Option<u64>,
8256}
8257
8258impl BoardId {
8259 fn parse(id: &str) -> Result<Self, SourceError> {
8260 if id.trim().is_empty() {
8261 return Err(SourceError::Malformed {
8262 message: "GitHub named a board with a blank node id".into(),
8263 });
8264 }
8265 Ok(Self(id.to_owned()))
8266 }
8267
8268 fn as_str(&self) -> &str {
8269 &self.0
8270 }
8271}
8272
8273impl Board {
8274 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8275 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8276 let nodes = fields
8277 .get("nodes")
8278 .and_then(Value::as_array)
8279 .ok_or_else(|| SourceError::Malformed {
8280 message: "GitHub project fields.nodes is not an array".into(),
8281 })?;
8282 Ok(nodes
8283 .iter()
8284 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8285 }
8286}
8287
8288/// One board item, resolved into everything this source reports about it.
8289#[derive(Clone)]
8290struct Resolved {
8291 item_id: String,
8292 id: NativeId,
8293 content_kind: ContentKind,
8294 kind: BoardKind,
8295 title: String,
8296 body: Option<String>,
8297 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8298 /// that changes the slot alone has to keep byte for byte outside it.
8299 raw_body: Option<String>,
8300 status: Status,
8301 /// The name of the board `Status` option this item sits in, as the board spells it.
8302 option: Option<String>,
8303 /// What its `Priority` field says, read through this instance's mapping.
8304 priority: HeldPriority,
8305 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8306 closed: bool,
8307 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8308 delivers: Vec<TaskRef>,
8309 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8310 /// task.
8311 delivered_by: Vec<TaskRef>,
8312 labels: Vec<Label>,
8313 parent: Option<NativeId>,
8314 // 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.
8315 origin: Option<String>,
8316 /// The issue's own number on its repository, as GitHub reports it.
8317 ///
8318 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8319 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8320 /// an issue this run created whose creating mutation answered without one, which is a
8321 /// response GitHub's own schema says cannot happen and which a landed write is not
8322 /// worth failing over. An `Issue` read off the board always has one.
8323 number: Option<u64>,
8324 url: Option<String>,
8325 created_at: Option<DateTime<Utc>>,
8326 updated_at: Option<DateTime<Utc>>,
8327 own_repository: Option<Repository>,
8328 repositories: Vec<Repository>,
8329 slot: BTreeMap<String, Value>,
8330 /// The node id of the board this item sits on, when the read that reached it said.
8331 board_id: Option<String>,
8332 /// The definition of every board field this item holds a value of, in the shape a read
8333 /// of the board's own `fields` gives one.
8334 ///
8335 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8336 /// which says nothing about whether the board has it.
8337 fields: Vec<Value>,
8338 /// Every field the board this item sits on defines, as its own read of the board's
8339 /// `fields` gives them — when the read that reached the item carried them, which a read
8340 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8341 /// of the board.
8342 board_fields: Option<Value>,
8343 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8344 /// selects one — when the read that reached it carried the connection to its end, which a
8345 /// read of it by its own id does for any issue blocked by no more than a page. What a
8346 /// write reconciles that relationship against, and what a read of its forward edges in
8347 /// the same command answers with.
8348 blocked_by: Option<Vec<Value>>,
8349}
8350
8351impl Resolved {
8352 /// The board this item's own read names it on, when that read named one this source can
8353 /// address.
8354 fn named_board(&self) -> Option<BoardId> {
8355 self.board_id
8356 .as_deref()
8357 .and_then(|id| BoardId::parse(id).ok())
8358 }
8359
8360 /// The board's id and every field it defines, when the read that reached this item
8361 /// carried both — which a read of it by its own id does.
8362 fn carried_board(&self) -> Option<BoardFields> {
8363 Some(BoardFields {
8364 id: self.named_board()?,
8365 fields: self.board_fields.clone()?,
8366 })
8367 }
8368
8369 /// Whether this item holds a value of the board field called `name`, and so carries
8370 /// that field's definition. `false` says nothing about whether the board has the field.
8371 fn defines(&self, name: &str) -> bool {
8372 self.fields
8373 .iter()
8374 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8375 }
8376
8377 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8378 /// in a field of its own, and none of the five keys that are only an encoding.
8379 ///
8380 /// The two delivery keys are left out for every kind, not only for a task: they are
8381 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8382 /// document carrying one holds nothing a caller's own metadata could mean by it.
8383 fn metadata(&self) -> BTreeMap<String, Value> {
8384 let mut metadata = self.slot.clone();
8385 metadata.remove(Repository::METADATA_KEY);
8386 metadata.remove(DependencyEdge::RECORDED_KEY);
8387 metadata.remove(ItemKind::METADATA_KEY);
8388 metadata.remove(TaskRef::DELIVERS_KEY);
8389 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8390 // The board field is the origin, and the body's copy of it is only a mirror for the
8391 // issue search to find: an item whose field holds none has none, whatever its body
8392 // says, so no reader ever sees two answers.
8393 metadata.remove(ORIGIN_KEY);
8394 if let Some(origin) = &self.origin {
8395 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8396 }
8397 metadata
8398 }
8399
8400 /// Where this item is, as a link a reader can open.
8401 ///
8402 /// A board is a hosted place and every issue on it has a web address, so that address
8403 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8404 /// of place it is, so a reader knows to open it rather than to read a file out. It
8405 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8406 /// reported before, and this says what that address *is*.
8407 ///
8408 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8409 /// rather than a third variant, which is the contract's "the source did not say". An
8410 /// issue this run created is not one of those: its address comes back from the
8411 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8412 /// rather than from whenever the board read catches up.
8413 fn location(&self) -> Option<Location> {
8414 self.url.clone().map(Location::Url)
8415 }
8416
8417 /// The short handle this board's backend shows people for a task: the issue's number
8418 /// alone, as a decimal string.
8419 ///
8420 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8421 /// value for this backend. A draft has no number and so no handle, which is the
8422 /// contract's *absent* rather than a handle of some other shape — and the native
8423 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8424 /// derives from.
8425 fn key(&self) -> Option<String> {
8426 self.number.map(|number| number.to_string())
8427 }
8428
8429 /// Whether its `Priority` field holds a value at all, mapped or not.
8430 fn holds_priority(&self) -> bool {
8431 self.priority != HeldPriority::Read(Priority::None)
8432 }
8433
8434 /// The task this item is.
8435 ///
8436 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8437 /// reading that as a level would be a guess, and reading it as `none` would let the next
8438 /// copy clear a priority a person set.
8439 fn task(&self) -> Result<Task, SourceError> {
8440 let priority = match &self.priority {
8441 HeldPriority::Read(priority) => *priority,
8442 HeldPriority::Unmapped(option) => {
8443 return Err(SourceError::Malformed {
8444 message: format!(
8445 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8446 this source's priority_mapping does not name, so its priority cannot be \
8447 read; next: name {option:?} under priority_mapping, or move the item to \
8448 a mapped option",
8449 self.id,
8450 self.number
8451 .map(|number| format!(" (#{number})"))
8452 .unwrap_or_default()
8453 ),
8454 });
8455 }
8456 };
8457 Ok(Task {
8458 id: self.id.clone(),
8459 key: self.key(),
8460 title: self.title.clone(),
8461 content: self.body.clone(),
8462 status: self.status.clone(),
8463 priority,
8464 labels: self.labels.clone(),
8465 project: self.parent.clone(),
8466 url: self.url.clone(),
8467 location: self.location(),
8468 created_at: self.created_at,
8469 updated_at: self.updated_at,
8470 metadata: self.metadata(),
8471 repositories: self.repositories.clone(),
8472 delivers: self.delivers.clone(),
8473 delivered_by: self.delivered_by.clone(),
8474 })
8475 }
8476
8477 fn project(&self) -> Project {
8478 Project {
8479 id: self.id.clone(),
8480 title: self.title.clone(),
8481 content: self.body.clone(),
8482 status: self.status.clone(),
8483 labels: self.labels.clone(),
8484 url: self.url.clone(),
8485 location: self.location(),
8486 created_at: self.created_at,
8487 updated_at: self.updated_at,
8488 metadata: self.metadata(),
8489 repositories: self.repositories.clone(),
8490 }
8491 }
8492
8493 /// The same issue as a document: the project it is filed under, and no status and no
8494 /// dependencies, because a document is not work.
8495 fn document(&self) -> Document {
8496 Document {
8497 id: self.id.clone(),
8498 title: self.title.clone(),
8499 content: self.body.clone(),
8500 project: self.parent.clone(),
8501 labels: self.labels.clone(),
8502 url: self.url.clone(),
8503 location: self.location(),
8504 created_at: self.created_at,
8505 updated_at: self.updated_at,
8506 metadata: self.metadata(),
8507 repositories: self.repositories.clone(),
8508 }
8509 }
8510}
8511
8512/// Where one targeted update moves an item's status, and which of its two halves move.
8513struct StatusMove {
8514 /// The board the item's `Status` field is on.
8515 board: BoardId,
8516 /// The `Status` field's id.
8517 field: String,
8518 /// The option's id.
8519 option: String,
8520 /// The option's name, as the board spells it.
8521 name: String,
8522 /// What the status asks of the issue's state.
8523 target: StatusTarget,
8524 /// The status the item reads as once it is there.
8525 landed: Status,
8526 /// Which of the status's two halves differ from what the item holds.
8527 moves: Moves,
8528}
8529
8530/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8531/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8532/// and is not a value of this type.
8533#[derive(Clone, Copy, PartialEq, Eq)]
8534enum Moves {
8535 /// The option alone.
8536 Option,
8537 /// The issue's state alone: open, closed, or closed with another reason.
8538 State,
8539 /// Both.
8540 Both,
8541}
8542
8543impl Moves {
8544 /// What differs, or `None` when nothing does.
8545 const fn of(option: bool, state: bool) -> Option<Self> {
8546 match (option, state) {
8547 (true, true) => Some(Self::Both),
8548 (true, false) => Some(Self::Option),
8549 (false, true) => Some(Self::State),
8550 (false, false) => None,
8551 }
8552 }
8553
8554 /// Whether the option moves.
8555 const fn option(self) -> bool {
8556 matches!(self, Self::Option | Self::Both)
8557 }
8558
8559 /// Whether the issue's state moves.
8560 const fn state(self) -> bool {
8561 matches!(self, Self::State | Self::Both)
8562 }
8563}
8564
8565/// What one write is, and the status that comes with being it.
8566///
8567/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8568/// status and a task or a project always has one, so "a document carrying a status" and
8569/// "a task carrying none" are states a write cannot be in rather than states every use
8570/// site below has to defend against.
8571enum Written<'a> {
8572 /// A document, which is not work and so has no status at all.
8573 Document,
8574 /// A task or a project, and the status it is being written with.
8575 Work(ItemKind, &'a Status),
8576}
8577
8578impl Written<'_> {
8579 /// Which of the board's three kinds this write is.
8580 const fn kind(&self) -> BoardKind {
8581 match self {
8582 Self::Document => BoardKind::Document,
8583 Self::Work(kind, _) => BoardKind::Work(*kind),
8584 }
8585 }
8586
8587 /// The status this write carries. A document carries none, so a write of one says
8588 /// nothing about the issue's open or closed state and selects no board `Status`
8589 /// option.
8590 const fn status(&self) -> Option<&Status> {
8591 match self {
8592 Self::Document => None,
8593 Self::Work(_, status) => Some(status),
8594 }
8595 }
8596
8597 /// The status this write carries with the kind whose half of `status_mapping` it is
8598 /// written through.
8599 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8600 match self {
8601 Self::Document => None,
8602 Self::Work(kind, status) => Some((*kind, status)),
8603 }
8604 }
8605}
8606
8607/// The item being written, in the one shape all three write methods reach.
8608struct Incoming<'a> {
8609 written: Written<'a>,
8610 /// The title a person wrote. A document's goes onto the issue with
8611 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8612 title: &'a str,
8613 content: Option<&'a str>,
8614 labels: &'a [Label],
8615 metadata: &'a BTreeMap<String, Value>,
8616 repositories: &'a [Repository],
8617 parent: Option<&'a NativeId>,
8618 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8619 /// what keeps either key out of their slot.
8620 delivers: &'a [TaskRef],
8621 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8622 delivered_by: &'a [TaskRef],
8623 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8624 /// project, a document, and every write to an instance with no `priority_mapping` —
8625 /// which is what keeps such a write's requests exactly what they were before.
8626 priority: Option<Priority>,
8627}
8628
8629/// What one write does to an item's `Priority` field.
8630enum PriorityWrite {
8631 /// Select this option of this field.
8632 Select {
8633 /// The `Priority` field's id.
8634 field: String,
8635 /// The mapped option's id.
8636 option: String,
8637 },
8638 /// Clear the field's value, which is what `none` is.
8639 Clear {
8640 /// The `Priority` field's id.
8641 field: String,
8642 },
8643}
8644
8645impl Incoming<'_> {
8646 /// The title this write puts on the issue.
8647 fn written_title(&self) -> String {
8648 match self.written {
8649 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8650 Written::Work(..) => self.title.to_owned(),
8651 }
8652 }
8653}
8654
8655#[derive(Clone, Copy, PartialEq, Eq)]
8656enum ContentKind {
8657 DraftIssue,
8658 Issue,
8659}
8660
8661/// What one board issue is: a document, or the work an [`ItemKind`] names.
8662///
8663/// A type of this source's own rather than an `ItemKind` with a third variant, because
8664/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8665/// document — the contract keeps a document out of that enum deliberately. Holding the
8666/// board's three answers in one value is what makes every place that asks "which is this?"
8667/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8668/// two thirds of the board.
8669#[derive(Clone, Copy, PartialEq, Eq)]
8670enum BoardKind {
8671 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8672 Document,
8673 /// Every other issue, and every draft.
8674 Work(ItemKind),
8675}
8676
8677impl BoardKind {
8678 /// Whose half of `status_mapping` an item of this kind reads its status through. A
8679 /// document has no status of its own, so the task half stands in for whatever the issue
8680 /// holds; nothing reports it.
8681 const fn status_kind(self) -> ItemKind {
8682 match self {
8683 Self::Document => ItemKind::Task,
8684 Self::Work(kind) => kind,
8685 }
8686 }
8687
8688 /// How a refusal names this kind to the person reading it.
8689 const fn describes(self) -> &'static str {
8690 match self {
8691 Self::Document => "document",
8692 Self::Work(kind) => kind.marker(),
8693 }
8694 }
8695}
8696
8697/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8698///
8699/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8700/// the shared cross-source journeys assert one answer to one question, so two sources
8701/// that disagree about what "carries the label bug" means fail them.
8702fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8703 let holds = |name: &String| {
8704 labels
8705 .iter()
8706 .any(|label| label.name.eq_ignore_ascii_case(name))
8707 };
8708 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8709 && filter.all_of.iter().all(holds)
8710 && !filter.none_of.iter().any(holds)
8711}
8712
8713/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8714/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8715fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8716 statuses.is_empty() || statuses.contains(&category)
8717}
8718
8719/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8720///
8721/// `content` is the item's own prose — the body with this source's trailing metadata
8722/// comment already taken off — so a search never matches an encoding the author of the
8723/// issue never wrote.
8724fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8725 let terms = query.terms.to_lowercase();
8726 let in_title = title.to_lowercase().contains(&terms);
8727 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8728 match query.fields {
8729 TextFields::Title => in_title,
8730 TextFields::Content => in_content,
8731 TextFields::TitleOrContent => in_title || in_content,
8732 }
8733}
8734
8735/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8736///
8737/// The project predicate is passed separately because a read narrowed to one project has
8738/// already answered it by asking *that project* for its own items — and re-applying it
8739/// there would compare the caller's selector, which may be a project's **name**, against
8740/// the id of the project that name resolved to, and keep nothing. Every other read passes
8741/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8742/// source really does apply.
8743fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8744 labels_match(&task.labels, &query.labels)
8745 && status_matches(task.status.category, &query.statuses)
8746 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8747 && match project {
8748 ProjectFilter::Any => true,
8749 ProjectFilter::Orphans => task.project.is_none(),
8750 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8751 }
8752 && query
8753 .text
8754 .as_ref()
8755 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8756 // Against the parsed metadata slot, and against the origin field, which is where
8757 // `Resolved::metadata` reads each of them from.
8758 && query.metadata_matches(&task.metadata)
8759 && query.origin_matches(&task.metadata)
8760}
8761
8762fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8763 labels_match(&project.labels, &query.labels)
8764 && status_matches(project.status.category, &query.statuses)
8765 && query
8766 .text
8767 .as_ref()
8768 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8769}
8770
8771/// The same three predicates a task query carries, minus the status filter.
8772///
8773/// A document is not work, so it has no status for one to compare against and the query
8774/// type carries none. The project predicate is the same one — a design issue filed under a
8775/// project issue is in that project, and one filed under nothing is in none — so it is
8776/// spelled the same way here rather than answered differently.
8777fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8778 labels_match(&document.labels, &query.labels)
8779 && match project {
8780 ProjectFilter::Any => true,
8781 ProjectFilter::Orphans => document.project.is_none(),
8782 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8783 }
8784 && query
8785 .text
8786 .as_ref()
8787 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8788}
8789
8790#[async_trait::async_trait]
8791impl TaskSource for GitHubProjectsSource {
8792 fn kind(&self) -> &'static str {
8793 KIND
8794 }
8795 fn capabilities(&self) -> Capabilities {
8796 Capabilities {
8797 projects: Support::Native,
8798 documents: Support::Native,
8799 comments: Support::Native,
8800 priority: if self.priorities.is_some() {
8801 Support::Native
8802 } else {
8803 Support::Unsupported
8804 },
8805 filter_by_priority: Support::Native,
8806 filter_by_comment_activity: Support::Native,
8807 filter_by_metadata: Support::Native,
8808 filter_by_origin: Support::Native,
8809 orphan_tasks: Support::Native,
8810 filter_by_label: Support::Native,
8811 filter_by_status: Support::Native,
8812 search_title: Support::Native,
8813 search_content: Support::Native,
8814 task_dependencies: DependencySupport::BothDirections,
8815 project_dependencies: DependencySupport::BothDirections,
8816 max_page_size: MAX_PAGE_SIZE,
8817 }
8818 }
8819 async fn health(&self) -> Result<Health, SourceError> {
8820 let board = self.board_page(None, 1).await?;
8821 Ok(Health {
8822 reachable: true,
8823 detail: Some(format!(
8824 "reading GitHub project {}/{} ({})",
8825 self.owner,
8826 self.project_number,
8827 required_str(&board, "title")?
8828 )),
8829 })
8830 }
8831 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8832 self.item_by_id(id)
8833 .await?
8834 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8835 .map(|item| item.task())
8836 .transpose()
8837 }
8838 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8839 Ok(self
8840 .item_by_id(id)
8841 .await?
8842 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8843 .map(|item| item.project()))
8844 }
8845 async fn query_tasks(
8846 &self,
8847 query: &TaskQuery,
8848 page: &PageRequest,
8849 ) -> Result<Page<Task>, SourceError> {
8850 validate_page(page)?;
8851 refuse_unsearchable(query)?;
8852 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8853 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8854 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8855 (Some(also), None) => Some(also),
8856 (None, Some(since)) => Some(updated_qualifier(since)),
8857 (None, None) => None,
8858 };
8859 if let Some(also) = qualifiers {
8860 return self.search_tasks(query, page, &also).await;
8861 }
8862 }
8863
8864 // A read narrowed to one project asks that project for its own tasks, so nothing
8865 // about it costs what the rest of the board holds. A read carrying a text, metadata
8866 // or origin predicate asks GitHub the narrower question those predicates are, and a
8867 // read narrowed to comment activity alone asks the board's own issue search for the
8868 // issues updated since, which is every issue a comment could have been written or
8869 // edited on since. Every other task read is a question about the whole board and is
8870 // answered by reading it.
8871 let (held, membership) = match (&query.project, query.commented_since) {
8872 (ProjectFilter::Is(project), _) => (
8873 self.project_children(project).await?,
8874 // Answered by where these items came from; see `task_matches`.
8875 &ProjectFilter::Any,
8876 ),
8877 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8878 match (self.narrowed(query).await?, since) {
8879 (Some(narrowed), _) => (narrowed, &query.project),
8880 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8881 (None, None) => (self.board().await?.items, &query.project),
8882 }
8883 }
8884 };
8885 // Filtered before paged: a page of a filtered result is a page of the survivors,
8886 // never the survivors of a page.
8887 let mut tasks = Vec::new();
8888 for item in held
8889 .iter()
8890 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8891 {
8892 let task = item.task()?;
8893 if task_matches(&task, query, membership)
8894 && self.commented_since(item, query.commented_since).await?
8895 {
8896 tasks.push(task);
8897 }
8898 }
8899 Ok(offset_page(
8900 tasks,
8901 numeric_cursor(page.cursor.as_ref())?,
8902 page.limit.min(MAX_PAGE_SIZE) as usize,
8903 ))
8904 }
8905 async fn query_projects(
8906 &self,
8907 query: &ProjectQuery,
8908 page: &PageRequest,
8909 ) -> Result<Page<Project>, SourceError> {
8910 validate_page(page)?;
8911 refuse_unsearchable_text(query.text.as_ref())?;
8912 // The projects a board holds are found by an issue search scoped to that board,
8913 // never by walking the board's own item connection: what tells a project from a
8914 // task is the `parent` each issue carries, which costs nothing to read. A query
8915 // carrying a text asks that search for the text too, so it reads the issues that
8916 // hold it rather than every issue of the board.
8917 let held = match self.text_searched(query.text.as_ref()).await? {
8918 Some(searched) => searched,
8919 None => self.board_issues().await?,
8920 };
8921 let projects = held
8922 .iter()
8923 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8924 .map(Resolved::project)
8925 .filter(|project| project_matches(project, query))
8926 .collect();
8927 Ok(offset_page(
8928 projects,
8929 numeric_cursor(page.cursor.as_ref())?,
8930 page.limit.min(MAX_PAGE_SIZE) as usize,
8931 ))
8932 }
8933 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8934 Ok(self
8935 .item_by_id(id)
8936 .await?
8937 .filter(|item| item.kind == BoardKind::Document)
8938 .map(|item| item.document()))
8939 }
8940 async fn query_documents(
8941 &self,
8942 query: &DocumentQuery,
8943 page: &PageRequest,
8944 ) -> Result<Page<Document>, SourceError> {
8945 validate_page(page)?;
8946 // Narrowed to one project, this is the same sub-issue read a task list scoped to
8947 // that project makes — a document filed under a project is a sub-issue of it too,
8948 // and which of them come back is the kind this caller asked for. Unscoped, a query
8949 // carrying a text asks the board-scoped issue search for it, as a task query does,
8950 // and only one carrying none reads the board.
8951 let (held, membership) = match &query.project {
8952 ProjectFilter::Is(project) => (
8953 self.project_children(project).await?,
8954 // Answered by where these items came from; see `task_matches`.
8955 &ProjectFilter::Any,
8956 ),
8957 ProjectFilter::Any | ProjectFilter::Orphans => {
8958 refuse_unsearchable_text(query.text.as_ref())?;
8959 match self.text_searched(query.text.as_ref()).await? {
8960 Some(searched) => (searched, &query.project),
8961 None => (self.board().await?.items, &query.project),
8962 }
8963 }
8964 };
8965 // Filtered before paged, exactly as a task read is: a page of a filtered result is
8966 // a page of the survivors, never the survivors of a page.
8967 let documents = held
8968 .iter()
8969 .filter(|item| item.kind == BoardKind::Document)
8970 .map(Resolved::document)
8971 .filter(|document| document_matches(document, query, membership))
8972 .collect();
8973 Ok(offset_page(
8974 documents,
8975 numeric_cursor(page.cursor.as_ref())?,
8976 page.limit.min(MAX_PAGE_SIZE) as usize,
8977 ))
8978 }
8979 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8980 validate_page(page)?;
8981 let offset = numeric_cursor(page.cursor.as_ref())?;
8982 let mut labels = self
8983 .board()
8984 .await?
8985 .items
8986 .into_iter()
8987 .flat_map(|item| item.labels)
8988 .fold(Vec::new(), |mut all, label| {
8989 if !all.iter().any(|x: &Label| x.id == label.id) {
8990 all.push(label);
8991 }
8992 all
8993 });
8994 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8995 Ok(offset_page(
8996 labels,
8997 offset,
8998 page.limit.min(MAX_PAGE_SIZE) as usize,
8999 ))
9000 }
9001 async fn task_dependencies(
9002 &self,
9003 id: &NativeId,
9004 direction: Direction,
9005 page: &PageRequest,
9006 ) -> Result<Page<DependencyEdge>, SourceError> {
9007 self.dependencies(id, ItemKind::Task, direction, page).await
9008 }
9009 async fn project_dependencies(
9010 &self,
9011 id: &NativeId,
9012 direction: Direction,
9013 page: &PageRequest,
9014 ) -> Result<Page<DependencyEdge>, SourceError> {
9015 self.dependencies(id, ItemKind::Project, direction, page)
9016 .await
9017 }
9018
9019 fn writes(&self) -> WriteSupport {
9020 WriteSupport::Supported
9021 }
9022
9023 /// Create or update one task.
9024 ///
9025 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9026 /// neither may name the task itself or name one task twice — and land in the body's
9027 /// metadata slot under their reserved keys, in place of any caller metadata of those
9028 /// names.
9029 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9030 let near = write.target.as_ref().unwrap_or(&write.item.id);
9031 for (key, entries) in [
9032 (TaskRef::DELIVERS_KEY, &write.item.delivers),
9033 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9034 ] {
9035 TaskRef::listed(key, near, Some(&self.name), entries.clone())
9036 .map_err(|message| SourceError::Refused { message })?;
9037 }
9038 if self.priorities.is_none() && write.item.priority != Priority::None {
9039 return Err(self.holds_no_priority());
9040 }
9041 self.write_item(
9042 &Incoming {
9043 written: Written::Work(ItemKind::Task, &write.item.status),
9044 title: &write.item.title,
9045 content: write.item.content.as_deref(),
9046 labels: &write.item.labels,
9047 metadata: &write.item.metadata,
9048 repositories: &write.item.repositories,
9049 parent: write.item.project.as_ref(),
9050 delivers: &write.item.delivers,
9051 delivered_by: &write.item.delivered_by,
9052 priority: self.priorities.as_ref().map(|_| write.item.priority),
9053 },
9054 write.target.as_ref(),
9055 &write.depends_on,
9056 )
9057 .await
9058 }
9059
9060 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9061 self.write_item(
9062 &Incoming {
9063 written: Written::Work(ItemKind::Project, &write.item.status),
9064 title: &write.item.title,
9065 content: write.item.content.as_deref(),
9066 labels: &write.item.labels,
9067 metadata: &write.item.metadata,
9068 repositories: &write.item.repositories,
9069 parent: None,
9070 delivers: &[],
9071 delivered_by: &[],
9072 priority: None,
9073 },
9074 write.target.as_ref(),
9075 &write.depends_on,
9076 )
9077 .await
9078 }
9079
9080 /// Create or update one document, which is one issue titled the way this board spells
9081 /// a document.
9082 ///
9083 /// Everything else is exactly a task write: caller metadata goes to the same canonical
9084 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9085 /// or a field this board cannot carry is refused by name rather than dropped, a target
9086 /// naming an issue this board does not hold is refused rather than created, and an
9087 /// issue this call created is taken back when the rest of the write fails.
9088 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9089 // A document takes part in no dependency graph, so there is no far end to write
9090 // natively and none to record: a caller naming one is told so rather than having it
9091 // stored under the reserved key, where a later read would report an edge the
9092 // contract says cannot exist.
9093 if !write.depends_on.is_empty() {
9094 return Err(SourceError::Refused {
9095 message: format!(
9096 "this write names {} dependencies for a document, and a document takes \
9097 part in no dependency graph; next: put the dependency on the task or \
9098 project the document is about",
9099 write.depends_on.len()
9100 ),
9101 });
9102 }
9103 self.write_item(
9104 &Incoming {
9105 written: Written::Document,
9106 title: &write.item.title,
9107 content: write.item.content.as_deref(),
9108 labels: &write.item.labels,
9109 metadata: &write.item.metadata,
9110 repositories: &write.item.repositories,
9111 parent: write.item.project.as_ref(),
9112 delivers: &[],
9113 delivered_by: &[],
9114 priority: None,
9115 },
9116 write.target.as_ref(),
9117 &[],
9118 )
9119 .await
9120 }
9121
9122 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9123 /// which reads nothing; then the board's `Status` option. Over an existing item that is
9124 /// read off the item, as the write reads it, and the item is held among this command's
9125 /// resolved records so the write that follows reuses that read rather than repeating it;
9126 /// an item that does not carry the field takes the board's fields, which are held once
9127 /// read. A create is checked against the board's fields only when this command already
9128 /// holds them, because a create reads them together with its repository, in one request,
9129 /// and refuses a missing option before it writes anything.
9130 async fn check_status_write(
9131 &self,
9132 kind: ItemKind,
9133 category: StatusCategory,
9134 target: Option<&NativeId>,
9135 ) -> Result<(), SourceError> {
9136 let status = self.resolved_target(kind, category)?;
9137 if status.option().is_none() {
9138 return Ok(());
9139 }
9140 let fields = match target {
9141 Some(target) => {
9142 // A target this board does not hold is the write's own refusal to make.
9143 let Some(item) = self.bound_item(target).await? else {
9144 return Ok(());
9145 };
9146 self.resolved_cache()?.insert(target.clone(), item.clone());
9147 self.fields_for(Some(&item), true, false).await?.fields
9148 }
9149 None => {
9150 let held = self
9151 .board_cache()?
9152 .as_ref()
9153 .map(|board| board.fields.clone());
9154 match held.or_else(|| {
9155 self.fields_cache()
9156 .ok()
9157 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9158 }) {
9159 Some(fields) => fields,
9160 None => return Ok(()),
9161 }
9162 }
9163 };
9164 self.column_for(&fields, kind, category, &status)
9165 .map(|_| ())
9166 }
9167
9168 /// Set one task's status alone.
9169 ///
9170 /// An open target reopens a closed issue with an `updateIssue` carrying only its
9171 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9172 /// terminal target selects its mapped option, then closes with its fixed reason. No
9173 /// request carries a title, a body or a label. The status
9174 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9175 /// what a re-read reports.
9176 async fn set_task_status(
9177 &self,
9178 id: &NativeId,
9179 category: StatusCategory,
9180 ) -> Result<Option<Status>, SourceError> {
9181 self.set_status(id, category).await
9182 }
9183
9184 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9185 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9186 /// for `none`. Refused by an instance with no `priority_mapping`.
9187 async fn set_task_priority(
9188 &self,
9189 id: &NativeId,
9190 priority: Priority,
9191 ) -> Result<Option<Priority>, SourceError> {
9192 self.set_priority(id, priority).await
9193 }
9194
9195 /// Replace one task's content with a single body update that keeps the metadata slot
9196 /// byte for byte.
9197 async fn set_task_content(
9198 &self,
9199 id: &NativeId,
9200 content: &str,
9201 ) -> Result<Option<()>, SourceError> {
9202 self.replace_content(id, content).await
9203 }
9204
9205 /// Replace one task issue's content and its provenance slot entry with a single body
9206 /// update. The answers are not kept: see `replace_rendering`.
9207 async fn set_task_rendering(
9208 &self,
9209 id: &NativeId,
9210 content: &str,
9211 provenance: &Value,
9212 _answers: &BTreeMap<String, Value>,
9213 ) -> Result<Option<()>, SourceError> {
9214 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9215 .await
9216 }
9217
9218 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9219 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9220 async fn set_document_rendering(
9221 &self,
9222 id: &NativeId,
9223 content: &str,
9224 provenance: &Value,
9225 _answers: &BTreeMap<String, Value>,
9226 ) -> Result<Option<()>, SourceError> {
9227 self.replace_rendering(id, BoardKind::Document, content, provenance)
9228 .await
9229 }
9230
9231 /// Apply a targeted update with one read of the item and a write only for what differs:
9232 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9233 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9234 async fn update_task(
9235 &self,
9236 id: &NativeId,
9237 update: &TaskUpdate,
9238 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9239 self.targeted_update(id, update).await
9240 }
9241
9242 /// Replace one task's `delivered_by` with a single body update that changes the
9243 /// metadata slot and nothing outside it.
9244 async fn set_delivered_by(
9245 &self,
9246 id: &NativeId,
9247 delivered_by: &[TaskRef],
9248 ) -> Result<Option<()>, SourceError> {
9249 self.replace_delivered_by(id, delivered_by).await
9250 }
9251
9252 /// Set one key of one task issue's metadata with a single body update that changes the
9253 /// metadata slot and nothing outside it — no title, label, state or board field request —
9254 /// and sends nothing when the task already holds that value under the key.
9255 async fn set_task_metadata(
9256 &self,
9257 id: &NativeId,
9258 key: &MetadataKey,
9259 value: &Value,
9260 ) -> Result<Option<Task>, SourceError> {
9261 Ok(self
9262 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9263 .await?
9264 .map(|item| item.task())
9265 .transpose()?)
9266 }
9267
9268 /// Set one key of one project issue's metadata, on exactly the terms of
9269 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9270 async fn set_project_metadata(
9271 &self,
9272 id: &NativeId,
9273 key: &MetadataKey,
9274 value: &Value,
9275 ) -> Result<Option<Project>, SourceError> {
9276 Ok(self
9277 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9278 .await?
9279 .map(|item| item.project()))
9280 }
9281
9282 /// Set one key of one design-document issue's metadata, on exactly the terms of
9283 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9284 async fn set_document_metadata(
9285 &self,
9286 id: &NativeId,
9287 key: &MetadataKey,
9288 value: &Value,
9289 ) -> Result<Option<Document>, SourceError> {
9290 Ok(self
9291 .set_slot_key(id, BoardKind::Document, key, value)
9292 .await?
9293 .map(|item| item.document()))
9294 }
9295
9296 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9297 self.delete_item(id).await
9298 }
9299
9300 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9301 self.delete_item(id).await
9302 }
9303
9304 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9305 self.delete_item(id).await
9306 }
9307
9308 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9309 ///
9310 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9311 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9312 ///
9313 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9314 /// board is the read of its comments. A draft this process already resolved is refused
9315 /// without one.
9316 async fn task_comments(
9317 &self,
9318 task: &NativeId,
9319 page: &PageRequest,
9320 ) -> Result<Option<Page<Comment>>, SourceError> {
9321 validate_page(page)?;
9322 let cached = self.resolved_cache()?.get(task).cloned();
9323 if let Some(item) = cached {
9324 if item.kind != BoardKind::Work(ItemKind::Task) {
9325 return Ok(None);
9326 }
9327 if item.content_kind == ContentKind::DraftIssue {
9328 return Err(self.draft_has_no_comments(task));
9329 }
9330 }
9331 match self.issue_detail(task, page).await? {
9332 Some(TaskDetailRead {
9333 comments: Some(comments),
9334 ..
9335 }) => comments,
9336 _ => Ok(None),
9337 }
9338 }
9339
9340 /// Every id's task, with the first page of its comments when `comments` names it:
9341 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9342 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9343 async fn get_task_details(
9344 &self,
9345 ids: &[NativeId],
9346 comments: Option<&PageRequest>,
9347 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9348 if let Some(page) = comments
9349 && let Err(error) = validate_page(page)
9350 {
9351 return ids.iter().map(|_| Err(error.clone())).collect();
9352 }
9353 match (ids, comments) {
9354 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9355 ([id], None) => vec![self.task_read(id).await],
9356 _ => self.issue_details(ids, comments).await,
9357 }
9358 }
9359
9360 /// Add one comment to the task's issue, as the account the token belongs to.
9361 ///
9362 /// The author is refused before anything is sent — not even the task is read — because
9363 /// no answer GitHub could give would make posting under another name than the one asked
9364 /// for the right outcome.
9365 async fn add_comment(
9366 &self,
9367 task: &NativeId,
9368 comment: &NewComment,
9369 ) -> Result<Option<Comment>, SourceError> {
9370 if let Some(author) = &comment.author {
9371 return Err(SourceError::Refused {
9372 message: format!(
9373 "source {} cannot post a comment as {author:?}: GitHub records the account \
9374 the token signs in as the author of every comment; next: leave --author \
9375 out, and the comment is posted as that account",
9376 self.name
9377 ),
9378 });
9379 }
9380 let Some(issue) = self.commented_issue(task).await? else {
9381 return Ok(None);
9382 };
9383 let data = self
9384 .graphql(
9385 graphql::ADD_COMMENT,
9386 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9387 )
9388 .await?;
9389 let subject = data
9390 .pointer("/addComment/subject")
9391 .filter(|value| !value.is_null())
9392 .ok_or_else(|| SourceError::Malformed {
9393 message: "GitHub comment addition returned no subject".into(),
9394 })?;
9395 if required_str(subject, "id")? != issue.0 {
9396 return Err(SourceError::Malformed {
9397 message: "GitHub comment addition answered about another issue".into(),
9398 });
9399 }
9400 let added = data
9401 .pointer("/addComment/commentEdge/node")
9402 .filter(|value| !value.is_null())
9403 .ok_or_else(|| SourceError::Malformed {
9404 message: "GitHub comment addition returned no comment".into(),
9405 })?;
9406 comment_from(added).map(Some)
9407 }
9408
9409 async fn edit_comment(
9410 &self,
9411 task: &NativeId,
9412 comment: &NativeId,
9413 body: &CommentBody,
9414 ) -> Result<Option<Comment>, SourceError> {
9415 let Some(issue) = self.commented_issue(task).await? else {
9416 return Ok(None);
9417 };
9418 if !self.comment_is_on(&issue, comment).await? {
9419 return Ok(None);
9420 }
9421 let data = self
9422 .graphql(
9423 graphql::UPDATE_COMMENT,
9424 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9425 )
9426 .await?;
9427 let edited = data
9428 .pointer("/updateIssueComment/issueComment")
9429 .filter(|value| !value.is_null())
9430 .ok_or_else(|| SourceError::Malformed {
9431 message: "GitHub comment update returned no comment".into(),
9432 })?;
9433 let edited = comment_from(edited)?;
9434 if edited.id != *comment {
9435 return Err(SourceError::Malformed {
9436 message: "GitHub comment update returned the wrong comment".into(),
9437 });
9438 }
9439 Ok(Some(edited))
9440 }
9441
9442 async fn delete_comment(
9443 &self,
9444 task: &NativeId,
9445 comment: &NativeId,
9446 ) -> Result<Option<NativeId>, SourceError> {
9447 let Some(issue) = self.commented_issue(task).await? else {
9448 return Ok(None);
9449 };
9450 if !self.comment_is_on(&issue, comment).await? {
9451 return Ok(None);
9452 }
9453 let data = self
9454 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9455 .await?;
9456 // The payload says nothing about the comment it removed, so what is checked is that
9457 // GitHub answered the mutation at all rather than leaving it unanswered.
9458 data.get("deleteIssueComment")
9459 .filter(|value| !value.is_null())
9460 .ok_or_else(|| SourceError::Malformed {
9461 message: "GitHub comment deletion returned no payload".into(),
9462 })?;
9463 Ok(Some(comment.clone()))
9464 }
9465
9466 /// Every request this source has recorded, and what each of GitHub's two budgets was
9467 /// attributed — read off the same accounting the session report is rendered from, so
9468 /// the two cannot count one request two ways.
9469 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9470 Ok(Some(self.ledger.snapshot().metering()))
9471 }
9472
9473 /// Drop every item, search answer and board read this source holds, so the next command
9474 /// reads the board as a person has since left it.
9475 ///
9476 /// Every one of those is held on the assumption that nothing but this source writes the
9477 /// board while a command runs, which stops being true the moment the command is over: a
9478 /// body a person edited would be overwritten from the record held here, and a card they
9479 /// moved would be read as still where this source left it. The board's own field
9480 /// definitions go too, because a person can add or delete a `Status` option and a write
9481 /// resolved against the held list would not re-read on a miss. What stays is what stays
9482 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9483 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9484 /// accounting [`metering`](TaskSource::metering) answers from.
9485 ///
9486 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9487 /// refused, because clearing it is what puts it right.
9488 async fn end_command(&self) -> Result<(), SourceError> {
9489 fn clear<T: Default>(held: &Mutex<T>) {
9490 *held
9491 .lock()
9492 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9493 held.clear_poison();
9494 }
9495 clear(&self.created);
9496 clear(&self.updated);
9497 clear(&self.board_cache);
9498 clear(&self.search_cache);
9499 clear(&self.narrowed_cache);
9500 clear(&self.search_next);
9501 clear(&self.resolved_cache);
9502 clear(&self.fields_cache);
9503 Ok(())
9504 }
9505}
9506
9507/// One issue comment as the contract carries it.
9508///
9509/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9510/// and when it answers an actor with no login, because either way the source did not say who
9511/// wrote it — which is what an absent author means, rather than an author called nothing.
9512fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9513 Ok(Comment {
9514 id: NativeId(required_str(value, "id")?.to_owned()),
9515 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9516 .map(str::to_owned),
9517 created_at: optional_time(value, "createdAt")?,
9518 updated_at: optional_time(value, "updatedAt")?,
9519 body: required_str(value, "body")?.to_owned(),
9520 url: optional_str(value, "url")?.map(str::to_owned),
9521 })
9522}
9523
9524/// The page of comments one issue node carries, resumed from `after`.
9525fn comment_page(
9526 node: &Value,
9527 issue: &str,
9528 after: Option<&str>,
9529) -> Result<Page<Comment>, SourceError> {
9530 let connection = node
9531 .get("comments")
9532 .filter(|value| !value.is_null())
9533 .ok_or_else(|| SourceError::Malformed {
9534 message: format!("GitHub issue {issue} answered with no comments connection"),
9535 })?;
9536 let items = optional_nodes(Some(connection), "issue comments")?
9537 .into_iter()
9538 .flatten()
9539 .map(comment_from)
9540 .collect::<Result<Vec<_>, _>>()?;
9541 let next = next_cursor(connection)?;
9542 if let Some(next) = &next {
9543 validate_cursor_progress(after, &next.0)?;
9544 }
9545 Ok(Page { items, next })
9546}
9547
9548/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9549/// end — `None` when it carried none, or a page with more past it.
9550fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9551 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9552 return Ok(None);
9553 };
9554 if next_cursor(connection)?.is_some() {
9555 return Ok(None);
9556 }
9557 Ok(Some(
9558 optional_nodes(Some(connection), "blocked-by issues")?
9559 .into_iter()
9560 .flatten()
9561 .cloned()
9562 .collect(),
9563 ))
9564}
9565
9566/// Where the recorded tail of a dependency walk resumes; see
9567/// [`GitHubProjectsSource::recorded_edges`].
9568const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9569
9570/// The board text field this source keeps a copy's origin in.
9571///
9572/// Named after the key it holds, and held to that name by the guard below rather than by
9573/// a reader noticing.
9574const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9575
9576/// The metadata key that field holds.
9577///
9578/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9579/// constructs or interprets the qualified id it carries. This source names it only to
9580/// route it — a short, typed value belongs in a typed field rather than in the body slot
9581/// a caller's own prose shares.
9582///
9583/// Restated rather than imported, because no plugin crate may depend on the engine. What
9584/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9585/// target in `check`: it reads the engine's own literal and fails naming the file and the
9586/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9587/// that creates a second item every run instead of finding the one it wrote — and that is
9588/// too late to learn it.
9589const ORIGIN_KEY: &str = "onetaskgraph.origin";
9590
9591/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9592///
9593/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9594/// is derived from the far end, never written down on the near item — so only a forward
9595/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9596/// it did not come from, and it is told so rather than answered with an empty page that
9597/// reads as a walk which ended.
9598fn recorded_offset(
9599 cursor: Option<&str>,
9600 direction: Direction,
9601) -> Result<Option<usize>, SourceError> {
9602 cursor
9603 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9604 .map(|offset| {
9605 if direction != Direction::DependsOn {
9606 return Err(SourceError::Config {
9607 message: format!(
9608 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9609 reverse dependency read never issues; resume it in the direction \
9610 that reported it"
9611 ),
9612 });
9613 }
9614 offset.parse().map_err(|_| SourceError::Config {
9615 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9616 })
9617 })
9618 .transpose()
9619}
9620
9621fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9622 let mut page = offset_page(edges, offset, limit.max(1));
9623 page.next = page
9624 .next
9625 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9626 page
9627}
9628
9629/// The kind of one issue reached through a dependency connection.
9630///
9631/// The same questions the board scan asks, over the fields the dependency document
9632/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9633/// then anything with sub-issues or the marker is a project.
9634///
9635/// # Errors
9636///
9637/// A far end this board holds as a document is refused rather than reported. The two
9638/// answers that are not refusals would both be wrong: reporting it as a task names an id
9639/// no task read of this source can find, and reporting it as a project names one no
9640/// project read can. There is no third value to return — `ItemKind` has no document
9641/// variant, because nothing may point at a document — so the relationship itself is what
9642/// the person is told about.
9643fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9644 let id = required_str(value, "id")?;
9645 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9646 return Err(SourceError::Refused {
9647 message: format!(
9648 "GitHub issue {id} is a document of this board — its title begins \
9649 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9650 on by one; next: remove that issue's blocking relationship on this board"
9651 ),
9652 });
9653 }
9654 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9655 if parent.is_some() {
9656 return Ok(ItemKind::Task);
9657 }
9658 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9659 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9660 message: format!("GitHub issue {id}: {message}"),
9661 })?;
9662 let sub_issues = sub_issue_total(value)?;
9663 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9664 ItemKind::Project
9665 } else {
9666 ItemKind::Task
9667 })
9668}
9669
9670/// The `IssueStateUpdateInput` one status target asks for.
9671///
9672/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9673/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9674/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9675/// would report a change forever. A document has no status at all, and asks for neither.
9676fn state_input(target: Option<&StatusTarget>) -> Value {
9677 match target {
9678 Some(StatusTarget::Terminal(_, reason)) => {
9679 json!({"value":"CLOSED","stateReason":reason.reason()})
9680 }
9681 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9682 // A document has no status, so a write of one says nothing about the issue's open
9683 // or closed state rather than forcing it open: `stateInput` is what carries that
9684 // instruction, and an explicit null asks for no change to it.
9685 None => Value::Null,
9686 }
9687}
9688
9689/// The metadata one write stores in the item's body slot.
9690///
9691/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9692/// rather than carried: the kind marker so an empty project stays readable, the
9693/// repository list only when it is not exactly the issue's own repository, and the far
9694/// ends no relationship here can name.
9695///
9696/// The copy origin is the one typed field that is also mirrored here, and only as a
9697/// mirror: it lands in the board's origin field as well, which stays the one every reader
9698/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9699/// and catches up with a write in seconds rather than minutes — can find the item by it.
9700/// A reader of the release before this one drops the slot's copy and reads the field, so an
9701/// item written here still reads with exactly one origin there.
9702fn slot_metadata(
9703 incoming: &Incoming<'_>,
9704 own_repository: Option<&Repository>,
9705 fallback: &[DependencyEdge],
9706) -> BTreeMap<String, Value> {
9707 let mut metadata = incoming.metadata.clone();
9708 match metadata.remove(ORIGIN_KEY) {
9709 Some(Value::String(origin)) if !origin.is_empty() => {
9710 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9711 }
9712 _ => {}
9713 }
9714 match incoming.written.kind() {
9715 BoardKind::Work(kind) => metadata.insert(
9716 ItemKind::METADATA_KEY.to_owned(),
9717 Value::String(kind.marker().to_owned()),
9718 ),
9719 // A document is told by its title, so it carries no kind marker: that key names
9720 // what a dependency endpoint points at, and nothing may point at a document.
9721 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9722 };
9723 let derivable = own_repository
9724 .map(|own| incoming.repositories == [own.clone()])
9725 .unwrap_or(incoming.repositories.is_empty());
9726 if derivable {
9727 metadata.remove(Repository::METADATA_KEY);
9728 } else {
9729 metadata.insert(
9730 Repository::METADATA_KEY.to_owned(),
9731 Value::Array(
9732 incoming
9733 .repositories
9734 .iter()
9735 .map(|repository| Value::String(repository.as_str().to_owned()))
9736 .collect(),
9737 ),
9738 );
9739 }
9740 // The typed lists are what land, whatever the caller's own metadata held under their
9741 // keys: a key of either name travelling beside the field would otherwise be a second
9742 // answer to the same question, and the field is the one the contract names.
9743 for (key, entries) in [
9744 (TaskRef::DELIVERS_KEY, incoming.delivers),
9745 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9746 ] {
9747 set_task_list(&mut metadata, key, entries);
9748 }
9749 record_edges(&mut metadata, fallback);
9750 metadata
9751}
9752
9753/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9754/// one slot's metadata, or no such key when there are none.
9755fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9756 if fallback.is_empty() {
9757 metadata.remove(DependencyEdge::RECORDED_KEY);
9758 } else {
9759 metadata.insert(
9760 DependencyEdge::RECORDED_KEY.to_owned(),
9761 Value::Array(
9762 fallback
9763 .iter()
9764 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9765 .collect(),
9766 ),
9767 );
9768 }
9769}
9770
9771/// Every label one item carries, from its content's own connection and nowhere else.
9772///
9773/// There is no second place to read one from: no document this source sends selects the
9774/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9775/// cannot carry one at all. The module documentation records the three schema facts that
9776/// settle it.
9777fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9778 optional_nodes(content.get("labels"), "content labels")?
9779 .into_iter()
9780 .flatten()
9781 .map(|v| {
9782 Ok(Label {
9783 id: NativeId(required_str(v, "id")?.to_owned()),
9784 name: required_str(v, "name")?.to_owned(),
9785 color: optional_str(v, "color")?.map(str::to_owned),
9786 })
9787 })
9788 .collect()
9789}
9790
9791/// The definition of each board field one item's values are values of, in the shape a read
9792/// of the board's own `fields` gives one.
9793///
9794/// A value names its field through a fragment on that field's own type, so the type is
9795/// known from which kind of value it is: a single-select value's field is a
9796/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9797/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9798fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9799 field_values
9800 .iter()
9801 .filter_map(|value| {
9802 let field = value.get("field")?.as_object()?;
9803 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9804 let typename = if value.get("text").is_some() {
9805 "ProjectV2Field"
9806 } else if value.get("name").is_some() {
9807 "ProjectV2SingleSelectField"
9808 } else {
9809 return None;
9810 };
9811 let mut defined = field.clone();
9812 defined.insert("__typename".to_owned(), json!(typename));
9813 Some(Value::Object(defined))
9814 })
9815 .collect()
9816}
9817
9818fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9819 let Some(node) = field_values
9820 .iter()
9821 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9822 else {
9823 return Ok(None);
9824 };
9825 Ok(optional_str(node, "text")?.map(str::to_owned))
9826}
9827
9828fn valid_github_owner(owner: &str) -> bool {
9829 !owner.is_empty()
9830 && owner.len() <= 39
9831 && !owner.starts_with('-')
9832 && !owner.ends_with('-')
9833 && !owner.contains("--")
9834 && owner
9835 .bytes()
9836 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9837}
9838
9839/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9840/// neither of the two names a path segment already means.
9841fn valid_github_repository_name(name: &str) -> bool {
9842 !name.is_empty()
9843 && name.len() <= 100
9844 && name != "."
9845 && name != ".."
9846 && name
9847 .bytes()
9848 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9849}
9850
9851fn valid_environment_name(name: &str) -> bool {
9852 let mut bytes = name.bytes();
9853 bytes
9854 .next()
9855 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9856 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9857}
9858
9859/// How many sub-issues one issue has.
9860///
9861/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9862/// absent or non-integer one is a response this source cannot read — and reading it as
9863/// zero would classify a project as a task, which is exactly the mistake the marker
9864/// exists to keep from happening quietly.
9865fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9866 let summary = issue
9867 .get("subIssuesSummary")
9868 .ok_or_else(|| SourceError::Malformed {
9869 message: "GitHub issue is missing subIssuesSummary".into(),
9870 })?;
9871 summary
9872 .get("total")
9873 .and_then(Value::as_u64)
9874 .ok_or_else(|| SourceError::Malformed {
9875 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9876 })
9877}
9878
9879/// One issue's own `number`.
9880///
9881/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9882/// an issue in this module asks for it. So a read of one that comes back without it, or
9883/// with something that is not an unsigned integer, is a response this source cannot read —
9884/// absence here is **not** "this issue has no number". A draft is the content that has
9885/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9886/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9887fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9888 issue
9889 .get("number")
9890 .and_then(Value::as_u64)
9891 .ok_or_else(|| SourceError::Malformed {
9892 message: "GitHub issue number is missing or is not an unsigned integer".into(),
9893 })
9894}
9895
9896/// The `number` a creating mutation answered with, and `None` when it answered without one;
9897/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9898fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9899 match created.get("number") {
9900 None | Some(Value::Null) => Ok(None),
9901 Some(value) => value
9902 .as_u64()
9903 .map(Some)
9904 .ok_or_else(|| SourceError::Malformed {
9905 message: "GitHub created issue number is not an unsigned integer".into(),
9906 }),
9907 }
9908}
9909
9910fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9911 value
9912 .get(field)
9913 .and_then(Value::as_str)
9914 .ok_or_else(|| SourceError::Malformed {
9915 message: format!("GitHub response is missing string field {field}"),
9916 })
9917}
9918
9919fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9920 let found = required_str(value, field)?;
9921 if found.trim().is_empty() {
9922 return Err(SourceError::Malformed {
9923 message: format!("GitHub response has blank string field {field}"),
9924 });
9925 }
9926 Ok(found)
9927}
9928
9929/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9930/// needs one — Linear spells them too, in its own description field.
9931///
9932/// Restated rather than shared, because a plugin crate depends on the contract crate and
9933/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9934/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9935/// source round-trips its own writes perfectly well under its own spelling.
9936const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9937const METADATA_CLOSE: &str = "\n-->";
9938
9939/// What the composer puts between a non-empty visible body and the slot, and the one thing
9940/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9941/// other trailing byte of the body comes back as it was written.
9942// 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.
9943const METADATA_SEPARATOR: &str = "\n\n";
9944
9945/// The visible body and the metadata slot at the end of it.
9946///
9947/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9948/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9949/// own content and is left alone. The visible body is everything before the slot less the
9950/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9951fn metadata_body(
9952 body: Option<String>,
9953) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9954 let Some(body) = body else {
9955 return Ok((None, BTreeMap::new()));
9956 };
9957 let Some(slot) = slot_span(&body)? else {
9958 return Ok((Some(body), BTreeMap::new()));
9959 };
9960 let metadata =
9961 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9962 SourceError::Malformed {
9963 message: format!(
9964 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9965 ),
9966 }
9967 })?;
9968 let before = &body[..slot.start];
9969 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9970 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9971}
9972
9973/// Where the metadata slot sits in one body, as byte offsets into it.
9974struct SlotSpan {
9975 /// Where [`METADATA_OPEN`] begins.
9976 start: usize,
9977 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9978 encoded_start: usize,
9979 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9980 encoded_end: usize,
9981 /// Just past [`METADATA_CLOSE`].
9982 end: usize,
9983}
9984
9985/// The slot at the very end of `body`, or `None` when it has none.
9986///
9987/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9988/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9989/// slot.
9990fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9991 let Some(start) = body.rfind(METADATA_OPEN) else {
9992 return Ok(None);
9993 };
9994 let encoded_start = start + METADATA_OPEN.len();
9995 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9996 return Err(SourceError::Malformed {
9997 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9998 });
9999 };
10000 let encoded_end = encoded_start + relative_end;
10001 let end = encoded_end + METADATA_CLOSE.len();
10002 if !body[end..].trim().is_empty() {
10003 return Ok(None);
10004 }
10005 Ok(Some(SlotSpan {
10006 start,
10007 encoded_start,
10008 encoded_end,
10009 end,
10010 }))
10011}
10012
10013/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10014/// slot as it was.
10015///
10016/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10017/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10018/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10019/// or alone in an empty body — and a body with no slot that is given no metadata is
10020/// returned as it is.
10021fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10022 let encoded = if metadata.is_empty() {
10023 None
10024 } else {
10025 Some(
10026 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10027 message: error.to_string(),
10028 })?,
10029 )
10030 };
10031 Ok(match (slot_span(body)?, encoded) {
10032 (Some(slot), Some(encoded)) => format!(
10033 "{}{encoded}{}",
10034 &body[..slot.encoded_start],
10035 &body[slot.encoded_end..]
10036 ),
10037 (Some(slot), None) => {
10038 let before = &body[..slot.start];
10039 format!(
10040 "{}{}",
10041 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10042 &body[slot.end..]
10043 )
10044 }
10045 (None, None) => body.to_owned(),
10046 (None, Some(encoded)) if body.is_empty() => {
10047 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10048 }
10049 (None, Some(encoded)) => {
10050 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10051 }
10052 })
10053}
10054
10055/// `body` with everything before its metadata slot replaced by `content`, and the slot
10056/// itself kept byte for byte.
10057///
10058/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10059/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10060/// `content` is empty — so a read of the result reports `content` as the visible body and
10061/// the slot's metadata exactly as it was.
10062fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10063 let Some(slot) = slot_span(body)? else {
10064 return Ok(content.to_owned());
10065 };
10066 let kept = &body[slot.start..];
10067 Ok(if content.is_empty() {
10068 kept.to_owned()
10069 } else {
10070 format!("{content}{METADATA_SEPARATOR}{kept}")
10071 })
10072}
10073
10074/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10075fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10076 if entries.is_empty() {
10077 metadata.remove(key);
10078 } else {
10079 metadata.insert(
10080 key.to_owned(),
10081 Value::Array(
10082 entries
10083 .iter()
10084 .map(|entry| Value::String(entry.as_str().to_owned()))
10085 .collect(),
10086 ),
10087 );
10088 }
10089}
10090
10091fn compose_body(
10092 content: Option<&str>,
10093 metadata: &BTreeMap<String, Value>,
10094) -> Result<Option<String>, SourceError> {
10095 let visible = content.unwrap_or_default();
10096 if metadata.is_empty() {
10097 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10098 }
10099 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10100 message: error.to_string(),
10101 })?;
10102 Ok(Some(if visible.is_empty() {
10103 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10104 } else {
10105 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10106 }))
10107}
10108
10109fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10110 value
10111 .get(field)
10112 .and_then(Value::as_bool)
10113 .ok_or_else(|| SourceError::Malformed {
10114 message: format!("GitHub response is missing boolean field {field}"),
10115 })
10116}
10117fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10118 match value.get(field) {
10119 None | Some(Value::Null) => Ok(None),
10120 Some(value) => value
10121 .as_str()
10122 .map(Some)
10123 .ok_or_else(|| SourceError::Malformed {
10124 message: format!("GitHub response field {field} is not a string or null"),
10125 }),
10126 }
10127}
10128fn optional_nodes<'a>(
10129 connection: Option<&'a Value>,
10130 name: &str,
10131) -> Result<Option<&'a Vec<Value>>, SourceError> {
10132 match connection {
10133 None | Some(Value::Null) => Ok(None),
10134 Some(value) => value
10135 .get("nodes")
10136 .and_then(Value::as_array)
10137 .map(Some)
10138 .ok_or_else(|| SourceError::Malformed {
10139 message: format!("GitHub {name}.nodes is not an array"),
10140 }),
10141 }
10142}
10143fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10144 let page_info = connection
10145 .get("pageInfo")
10146 .ok_or_else(|| SourceError::Malformed {
10147 message: format!("GitHub {name} has no pageInfo"),
10148 })?;
10149 if required_bool(page_info, "hasNextPage")? {
10150 return Err(SourceError::Malformed {
10151 message: format!(
10152 "GitHub {name} exceeds the supported nested connection size of {size}"
10153 ),
10154 });
10155 }
10156 Ok(())
10157}
10158fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10159 optional_str(value, field)?
10160 .map(|timestamp| {
10161 timestamp.parse().map_err(|error| SourceError::Malformed {
10162 message: format!("GitHub response field {field} is not a timestamp: {error}"),
10163 })
10164 })
10165 .transpose()
10166}
10167fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10168 if page.limit == 0 {
10169 Err(SourceError::Config {
10170 message: "page limit must be at least 1".into(),
10171 })
10172 } else {
10173 Ok(())
10174 }
10175}
10176fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10177 let page = connection
10178 .get("pageInfo")
10179 .filter(|value| value.is_object())
10180 .ok_or_else(|| SourceError::Malformed {
10181 message: "GitHub connection is missing pageInfo".into(),
10182 })?;
10183 if required_bool(page, "hasNextPage")? {
10184 let cursor = required_str(page, "endCursor")?;
10185 validate_cursor_progress(None, cursor)?;
10186 Ok(Some(Cursor(cursor.into())))
10187 } else {
10188 Ok(None)
10189 }
10190}
10191fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10192 if next.is_empty() || previous == Some(next) {
10193 Err(SourceError::Malformed {
10194 message: "GitHub pagination cursor is empty or did not advance".into(),
10195 })
10196 } else {
10197 Ok(())
10198 }
10199}
10200/// The version of this plugin's opaque narrowing-search cursor.
10201pub const SEARCH_CURSOR_VERSION: u32 = 4;
10202
10203#[derive(Serialize, Deserialize)]
10204#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10205enum SearchConnection {
10206 Initial {},
10207 Continuing { after: Cursor },
10208 Exhausted {},
10209}
10210impl SearchConnection {
10211 fn after(&self) -> Option<&str> {
10212 match self {
10213 Self::Continuing { after } => Some(&after.0),
10214 _ => None,
10215 }
10216 }
10217 fn exhausted(&self) -> bool {
10218 matches!(self, Self::Exhausted { .. })
10219 }
10220 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10221 /// plugin could have handed out: a page is resumed only part of the way through it — an
10222 /// offset of a whole page or more would skip rows nobody was given — an initial page
10223 /// only once some of it was handed out, and an exhausted connection has no page to be
10224 /// part of the way through.
10225 fn valid_resume(&self, offset: usize) -> bool {
10226 let within = offset < SEARCH_PAGE_SIZE as usize;
10227 match self {
10228 Self::Initial { .. } => offset > 0 && within,
10229 Self::Continuing { after } => !after.0.is_empty() && within,
10230 Self::Exhausted { .. } => offset == 0,
10231 }
10232 }
10233}
10234
10235/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10236#[derive(Serialize, Deserialize)]
10237#[serde(deny_unknown_fields)]
10238struct SearchPosition {
10239 version: u32,
10240 connection: SearchConnection,
10241 /// How many rows of the page `connection` starts were already handed out.
10242 #[serde(default, skip_serializing_if = "is_zero")]
10243 offset: usize,
10244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10245 seen: Vec<NativeId>,
10246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10247 own: Vec<NativeId>,
10248}
10249impl Default for SearchPosition {
10250 fn default() -> Self {
10251 Self {
10252 version: SEARCH_CURSOR_VERSION,
10253 connection: SearchConnection::Initial {},
10254 offset: 0,
10255 seen: Vec::new(),
10256 own: Vec::new(),
10257 }
10258 }
10259}
10260
10261fn is_zero(offset: &usize) -> bool {
10262 *offset == 0
10263}
10264
10265fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10266 cursor.map_or(Ok(0), |c| {
10267 c.0.parse().map_err(|_| SourceError::Config {
10268 message: "page cursor is invalid".into(),
10269 })
10270 })
10271}
10272fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10273 if offset > items.len() {
10274 return Page::last(vec![]);
10275 }
10276 let tail = items.split_off(offset);
10277 let mut selected = tail;
10278 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10279 selected.truncate(limit);
10280 Page {
10281 items: selected,
10282 next,
10283 }
10284}
10285
10286/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10287/// itself, for the two things no journey can observe.
10288///
10289/// The journeys in `crates/onetaskgraph/tests/e2e/end_command.rs` prove through the engine,
10290/// with and without the call, that a settlement, a board listing and a metadata search each
10291/// read afresh after it — the resolved records, the written-item overlay, the board and its
10292/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10293/// because a status write naming an option a person deleted is refused the same whether or
10294/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10295/// while one of its locks is held. So these assert those directly, and every other holder
10296/// beside them so a holder added later without a clear in the call fails here.
10297#[cfg(test)]
10298mod end_command_tests {
10299 use super::*;
10300
10301 struct Token;
10302
10303 impl SecretResolver for Token {
10304 fn get(&self, var: &str) -> Option<SecretString> {
10305 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10306 }
10307 }
10308
10309 fn source() -> GitHubProjectsSource {
10310 let config = serde_json::from_value(json!({
10311 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10312 // Nothing here is sent: the source is only built and its state inspected.
10313 "endpoint": "http://127.0.0.1:9/graphql",
10314 }))
10315 .expect("a usable configuration");
10316 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10317 .expect("the source builds")
10318 }
10319
10320 /// One issue as a board read answers it.
10321 fn resolved(source: &GitHubProjectsSource) -> Resolved {
10322 source
10323 .resolve(&json!({
10324 "id": "ITEM-1",
10325 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10326 "body": "what a person may since have edited", "state": "OPEN",
10327 "stateReason": null, "url": null, "number": 1,
10328 "subIssuesSummary": {"total": 0},
10329 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10330 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10331 }))
10332 .expect("the item reads")
10333 .expect("an issue")
10334 }
10335
10336 /// Hold something in every holder the call clears, and the repository id it keeps.
10337 fn fill(source: &GitHubProjectsSource) {
10338 let item = resolved(source);
10339 source.created.lock().unwrap().push(item.clone());
10340 source.updated.lock().unwrap().push(item.clone());
10341 *source.board_cache.lock().unwrap() = Some(Board {
10342 id: "PVT-board".into(),
10343 fields: json!({"nodes": []}),
10344 items: vec![item.clone()],
10345 });
10346 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10347 source
10348 .narrowed_cache
10349 .lock()
10350 .unwrap()
10351 .insert("status:todo".into(), vec![item.clone()]);
10352 source
10353 .search_next
10354 .lock()
10355 .unwrap()
10356 .insert("status:todo".into(), Some("cursor".into()));
10357 source
10358 .resolved_cache
10359 .lock()
10360 .unwrap()
10361 .insert(item.id.clone(), item);
10362 *source.fields_cache.lock().unwrap() = Some(BoardFields {
10363 id: BoardId::parse("PVT-board").unwrap(),
10364 fields: json!({"nodes": []}),
10365 });
10366 source
10367 .repository_cache
10368 .lock()
10369 .unwrap()
10370 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10371 }
10372
10373 fn assert_dropped(source: &GitHubProjectsSource) {
10374 assert!(source.created().unwrap().is_empty(), "created");
10375 assert!(source.updated().unwrap().is_empty(), "updated");
10376 assert!(source.board_cache().unwrap().is_none(), "board");
10377 assert!(source.search_cache.lock().unwrap().is_none(), "search");
10378 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10379 assert!(
10380 source.search_next.lock().unwrap().is_empty(),
10381 "search paging"
10382 );
10383 assert!(
10384 source.resolved_cache().unwrap().is_empty(),
10385 "resolved records"
10386 );
10387 assert!(source.fields_cache().unwrap().is_none(), "board fields");
10388 assert_eq!(
10389 source.repository_cache().unwrap().len(),
10390 1,
10391 "a repository's node id stays valid and is kept"
10392 );
10393 }
10394
10395 fn end(source: &GitHubProjectsSource) {
10396 tokio::runtime::Builder::new_current_thread()
10397 .build()
10398 .unwrap()
10399 .block_on(source.end_command())
10400 .expect("the command ends");
10401 }
10402
10403 #[test]
10404 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10405 let source = source();
10406 fill(&source);
10407 end(&source);
10408 assert_dropped(&source);
10409 }
10410
10411 #[test]
10412 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10413 fn poison<T: Send>(held: &Mutex<T>) {
10414 std::thread::scope(|scope| {
10415 let _ = scope
10416 .spawn(|| {
10417 let _guard = held.lock().unwrap();
10418 panic!("a failure while the lock is held");
10419 })
10420 .join();
10421 });
10422 assert!(held.is_poisoned());
10423 }
10424 let source = source();
10425 fill(&source);
10426 poison(&source.created);
10427 poison(&source.updated);
10428 poison(&source.board_cache);
10429 poison(&source.search_cache);
10430 poison(&source.narrowed_cache);
10431 poison(&source.search_next);
10432 poison(&source.resolved_cache);
10433 poison(&source.fields_cache);
10434 assert!(
10435 source.resolved_cache().is_err(),
10436 "a poisoned lock is refused before the call"
10437 );
10438 end(&source);
10439 assert_dropped(&source);
10440 }
10441}