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//! **A caller's value can also be projected onto a board text field**, so the board can filter
84//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
85//! metadata key and a path inside its value. The slot stays the value's one home — every read
86//! reports metadata from it alone — and the field follows it on every write of an item, a
87//! create, an update and a copy alike: a string is written, and not sent again while the field
88//! holds it — an empty string included, which is written as itself; nothing there or `null`
89//! clears a field holding a value; any other
90//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
91//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
92//! own moves its field first and the body last, as every write of an existing item does. A
93//! board without the field, or with a non-text field of that name, is refused before any
94//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
95//! a text field and never changes a field that is there.
96//!
97// 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.
98//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
99//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
100//! option of the board's one `Status` field for a task and for a project: a bare name is
101//! the option for both kinds, `null` disables the category for both, and `{task, project}`
102//! names it per kind. A category the mapping does not mention keeps its shipped default for
103//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
104//! gets the shipped default for the kind it leaves out. Two categories one kind would read
105//! back from one option are refused as the configuration is read, ignoring case, while one
106//! option may stand for different categories of the two kinds. Writes go by the kind of the
107//! item written: a status that kind has no option for, or whose option the board lacks, is
108//! refused before any mutation, naming the source, the kind, the category and the key
109//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
110//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
111//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
112//! before selecting its option. Reads give a closed issue's reason precedence over its
113//! option, while an open issue's option decides its category through its own kind's
114//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
115//! The guarded [`GitHubProjectsSource::status_options`] and
116//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
117//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
118//! existing option id and verify the field and item assignments immediately afterwards.
119//! They ask for both kinds' options, counting a terminal category's mapped option as
120//! configured because a terminal write refuses without it. No ordinary source read or
121//! write calls that mutation, whose
122//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
123//! and a mistake destroys every item's status. A status this board cannot represent is a
124//! refusal naming the status and the instance instead.
125//!
126//! `unknown` has no shipped option because this source cannot preserve an open-ended
127//! status word: it writes an existing board option and never
128//! creates an option. An operator may map `unknown` to one existing option, in which case
129//! every unknown word lands on that option and reads back as `unknown` under the option's
130//! name. This differs from `local-md`, which writes and reads the original word itself.
131//!
132//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
133//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
134//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
135//! finished tasks were only moved to a "Done" column would read 0% complete forever.
136// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
137//!
138//! # What this source declares, field by field
139//!
140//! One verdict per field of [`Capabilities`], and what `Native` means when this source
141//! says it. *Proven* means a shared journey drives it against the real
142//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
143//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
144//! [`capabilities`](TaskSource::capabilities) from parting.
145//!
146//! | Field | Verdict |
147//! | --- | --- |
148//! | `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. |
149//! | `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. |
150//! | `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. |
151//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
152//! | `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. |
153//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
154//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
155//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
156//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
157//! | `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. |
158//! | `filter_by_metadata` | **Supported, and asked of GitHub** — through the body and never through a projected `metadata_fields` text field, which is the board's to filter on and not this source's to read. 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. |
159//! | `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. |
160//! | `reconcile_status` | **Supported,** and proven by `onetaskgraph-github-projects-e2e`'s `status_reconcile.rs`: the board's own `ProjectV2.items(query:)` filter, `is:closed`, read page by page and never the unfiltered items, each candidate confirmed from its own item node — a closed task whose card is not at the option the mapping gives its closure's category. |
161//! | `complete_by_issue` | **Supported,** and proven by `status_complete.rs` there and in the engine's suite: each issue a reference names read in one aliased request per fifty — its repository, its number, its first page of board memberships with each card's `Status` read by name — and nothing else of the board. |
162//! | `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. |
163//! | `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. |
164//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
165//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
166//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
167//!
168//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
169//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
170//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
171//! and the raw bytes as the request body. The numeric repository id is read at most once
172//! per repository per source instance. A loopback API endpoint also selects the loopback
173//! uploads host; production needs no assets repository, commits or extra configuration.
174//! Every returned JSON `url` is read with the same token before a body is written: only
175//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
176//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
177//! asset and HTTP status and says the token type may not be accepted by the upload
178//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
179//! fine-grained PATs or OAuth tokens.
180//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
181//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
182//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
183//! and verified afresh, in the repository the issue already lives in on an update.
184//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
185//! Uploads take the same mutation spacing as content writes. Both uploads and verification
186//! reads wait out classified rate limits within the configured per-call retry budget,
187//! on the supplied clock; verifying reads take no mutation slot.
188//!
189//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
190//! source has documents and that its tasks have comments, both of which hold — and the three
191//! facts behind the uniform `Native` on the
192//! predicates beside it are recorded below rather than re-derived, because a reader who
193//! takes `Native` to mean *the remote service filters* will read that uniformity as a
194//! lie.
195//!
196//! First, the plugin contract defines `Support::Native` as *the source applies this
197//! predicate itself*, and says nothing about where it applies it. What the declaration
198//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
199//! — so that the engine may push it down and apply nothing of its own.
200//!
201//! Second, this source can keep that promise for every predicate at no additional API
202//! cost, because whichever of the reads below answers a query has already read every
203//! candidate that query will return before it filters anything. Filtering those items is
204//! in-process work over data already in hand.
205//!
206//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
207//! applied in process over what that question returned. A project filter has a relationship — a
208//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
209//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
210//! a search for metadata values, is the board-scoped issue search carrying the text and each
211//! value as quoted phrases; an origin is the board's own field filter over its origin field
212//! beside the same search for the id. **The text search narrows, and that is this source's
213//! declared semantics:** GitHub matches whole words where the substring rule this source and
214//! the local Markdown source confirm with would match inside one, so an item holding the text
215//! only inside a longer word is never a candidate. Every item returned does contain the text.
216//! A project query's text, and a document query's scoped to no project, is that same search
217//! and narrows on the same terms, its candidates confirmed by their kind as well.
218//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
219//! so those three are applied in process over the candidates, and a query carrying none of
220//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
221//! engine compensate for work this source has already done, and declaring `projects` native
222//! while ignoring the filter (which this source once did) silently returns another project's
223//! tasks, because the engine trusts the declaration and applies nothing locally.
224//!
225//! # The three ways this source reaches an item, and what each costs
226//!
227//! A board read is charged for what its *nested* connections could return rather than for
228//! what was asked, so one whole-board read costs the same whether the question was about
229//! one project or about all of them. That is why a question about one project is never
230//! answered by reading the board:
231//!
232//! | The question | What is sent | What it costs |
233//! | --- | --- | --- |
234//! | 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 |
235//! | 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 |
236//! | 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 |
237//! | 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 |
238//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
239//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
240//! | 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 |
241//! | 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 |
242//! | 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 |
243//! | 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 |
244//! | 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 |
245//! | 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 |
246//!
247//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
248//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
249//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
250//! equal to declared points equal to the row. They include the origin lookup and the
251//! field/repository discovery a create needs. A bound re-copy changes status, priority,
252//! content and metadata; comment recount means a subsequent detail read. Each request here
253//! costs one declared point. A membership beyond the embedded page can additionally require
254//! the one-point membership recovery described above. A bound re-copy of a task filed under a
255//! project adds one read, the engine confirming that project's link by its own id once per
256//! command; and the same-source far ends a write newly names — those that do not already block
257//! the item, whose own read answered for them — are read together by their own ids,
258//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
259//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
260//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
261//!
262//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
263//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
264//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
265//! holds both halves.
266//!
267//! **An existing item is written body last.** A bound re-copy and a `task update` send its
268//! board fields first — the `Status` option and the `Priority` together, in one request — then
269//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
270//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
271//! undoing an earlier field when a later one fails, so that order is what makes a write
272//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
273//! the one piece of metadata written before the body, an origin a copy re-points, is put back
274//! when a later write is refused — and when putting it back is refused too, the write's own
275//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
276//! of those writes in turn, whole and as one aliased field failing after the one before it.
277//!
278//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
279//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
280//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
281//!
282//! - **A board is accepted at creation but its item is not answered, so a create still files
283//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
284//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
285//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
286//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
287//! against a real board on 2026-10-01 with a create sending the board there and reading the
288//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
289//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
290//! refused "Content already exists in this project" — GitHub had filed the issue after
291//! answering, and refuses a second filing rather than answering with the item it holds. A
292//! create therefore sends no `projectV2Ids` and files the issue with
293//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
294//! real is the read before it: the board's fields and the repository's id together, in
295//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
296//! - **A comment still reads its target first, so a comment is 2 requests.**
297//! `AddCommentInput.subjectId: ID!` is declared there with
298//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
299//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
300//! project's issue, a document's issue, an issue on no board of this source and a pull
301//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
302//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
303//! refuses those by name.
304//!
305//! | Verb | Requests / points | Documents |
306//! | --- | --- | --- |
307//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
308//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
309//! | 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 |
310//! | 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 |
311//! | 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 |
312//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
313//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
314//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
315//! | copy --comments, n changed tasks | ceil(n / COPY_DETAIL_BATCH) | ISSUE_COPY_DETAILS: each task with its first page of comments and its `blockedBy`, COPY_DETAIL_BATCH (21) at a time; a task whose comments run past that page adds one ISSUE_DETAIL per further page, and a task skipped from its listing costs nothing beyond it |
316//! | recount | 1 | ISSUE_DETAIL |
317//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
318//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
319//! | content | 2 | ISSUE, UPDATE_ISSUE |
320//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
321//! | 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 |
322//! | record only | 1 | ISSUE |
323//!
324//! <!-- github-search-paging:start -->
325//! Board-scoped text, metadata, project-name and comment-activity searches send every
326//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
327//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
328//! needs rows. A page is never resized to the rows still needed: GitHub orders one
329//! search differently at different page sizes, so one fixed size makes a paged walk
330//! send exactly the requests one whole read sends, and the answer's order is the order
331//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
332//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
333//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
334//! returned and fetched pages: a limit is sliced from the pages it needs, and local
335//! confirmation can require more candidates than matching rows. Walking all pages
336//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
337//! cursor and how far into that page the last answer stopped, and resumes in the same
338//! process or a new one, without duplicates or gaps. It carries no rows: one process
339//! sends each page's search once, and a new process re-reads only the page it resumes
340//! in, then sends a further page once, never as a re-read, only when its limit still
341//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
342//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
343//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
344//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
345//! not required to include the original process's writes still omitted by the index.
346//! <!-- github-search-paging:end -->
347//!
348//! The board half of an issue — its board item's id, its `Status` option and this
349//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
350//! an item reached any of those ways resolves through the same
351//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
352//! same status, the same labels and the same qualified id. That connection comes back a
353//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
354//! on the page in hand and — only if that page reports more of the connection — in the
355//! last row's read of that one issue's memberships, resumed from the page's own cursor and
356//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
357//! report, which is what keeps an id naming another repository's issue from being answered
358//! as an item of this board; and because the page is where the search starts rather than
359//! where it ends, that answer is one about a connection read to exhaustion and never about
360//! an unread page. Nothing costs the extra read but an issue on more boards than a page
361//! holds: an issue this board really does not hold reports no next page, so its
362//! memberships are already exhausted where they arrived.
363//!
364//! **No document here selects the board's own `Labels` field, and nothing is lost by
365//! that.** An item's labels are read from its content alone, wherever that content is
366//! reached: the three documents above select `Issue.labels` on the fragment, and
367//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
368//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
369//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
370//! create one, and `ProjectV2FieldValue` — the whole of what
371//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
372//! it from the content, for every content type it exists on, and there is nothing it can
373//! hold that the content does not already say: for an `Issue` it *is* that issue's own
374//! labels, so selecting it beside them unions a set with itself.
375//!
376//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
377//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
378//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
379//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
380//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
381//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
382//! label set, and that set is the fixture issue's own, by
383//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
384//! item whose content is a draft reports an empty set, by
385//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
386//! selection is held over [`graphql::DOCUMENTS`] by
387//! `no_document_selects_the_boards_own_labels_field`.
388//!
389//! The whole-board row is still the board's own item connection, and deliberately: a
390//! **draft** board item is not an issue, so no search can list one, and the reads that have
391//! to answer for the whole board are the ones whose cost is the board's size anyway.
392//!
393//! **A question about one item this source already names by id never lists the board.**
394//! Whether that item is on this board, and what its board fields are, is answered by reading
395//! that item — its own `Issue.projectItems`, walked to exhaustion by
396//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
397//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
398//! holds. That covers a write's destination, the project a new item is filed under, a
399//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
400//! and the delete that takes back an item a copy made. What such a write needs of the board
401//! and the item does not carry — the board's id, the `Status` and origin field definitions —
402//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
403//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
404//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
405//! minutes. Scanning this host's 842-item board has refused a document copy and an update
406//! even though the items' own reads named that board. A scan there gives the wrong answer
407//! as well as paying for every page. So a `board.items` lookup does not belong on any of
408//! those paths.
409//!
410//! **What a read may return is capped too, and that cap is on the document rather than on
411//! the board.** GitHub limits the number of nodes **one query may return** to
412//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
413//! an error naming the connection the count crossed at, not a slow or a partial result.
414//! Every board this source reads is refused the same way, so no board is too big for these
415//! documents and none is small enough to save one that is over.
416//!
417//! The count is arithmetic over the document's own text: each connection contributes the
418//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
419//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
420//! restate them — `github-graphql-node-count` implements them, and
421//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
422//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
423//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
424//! same text on every run and fails naming any that reaches the limit — so a connection
425//! added to a shared fragment is caught there rather than by GitHub.
426//!
427//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
428//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
429//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
430//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
431//! effectively squared there, which is why it is the one the limit is most sensitive to.
432//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
433//! page of memberships misses is recovered by one further read rather than refused, so it
434//! buys a bound every read pays for at the price of a request only a multi-board issue
435//! pays.
436//!
437//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
438//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
439//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
440//! `cost` is rate-limit points, metered per hour across everything one credential does; it
441//! is what the two limiters [`Limiter`] tells apart meter, and a document under
442//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
443//! that second number, and `tests/point_cost.rs` pins every document in
444//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
445//! one under, the pin itself is the check. The credentialed lane reconciles both figures
446//! against GitHub's own, off a probe it already sends.
447//!
448//! **What is pinned that way is a per-document price and never a session's.** The record in
449//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
450//! **requests** and **worst-case nodes** — and neither is points. What one whole session
451//! consumes of the hourly point allowance is observable only from a credentialed run's own
452//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
453//! and what `tests/live.rs` prints at the end of every run.
454//!
455//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
456//!
457//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
458//! enumerations of a board can supply one alone.** Resolving a node id is strongly
459//! consistent, so a read by id and a project's own sub-issues are already current. The
460//! other two are not, and they are behind by different amounts and in different directions:
461//!
462//! - GitHub's **issue search** is an index and answers a write made moments ago with the
463//! value from before it — usually for a second or two.
464//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
465//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
466//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
467//! for *minutes*, while `Issue.projectItems` names the same membership at once.
468//!
469//! That second one is a measurement rather than a caution. This repository's own
470//! credentialed journey writes a project and waits for the board to report it, then writes a
471//! task and waits for the same thing seconds later on the same board: the project wait is
472//! answered through the search and converged in two or three attempts in each of three runs,
473//! and the task wait is answered through `ProjectV2.items` and converged in none of them
474//! inside thirty. Separately, an item added to a second and larger board was read back by
475//! `Issue.projectItems` on that board's own id while every one of that connection's nine
476//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
477//! through the lagging one alone is what had a board read deny an issue that had certainly
478//! landed on it.
479//!
480//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
481//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
482//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
483//! search reports what the projection is behind on. What closes the last
484//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
485//! source answers is completed with what this process itself wrote, so an item created
486//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
487//! nothing is written down, and the record dies with the process. **A wait that has to
488//! observe GitHub's own data cannot be answered from that record** — which is why the
489//! credentialed journey asks through a source built afresh, and why the union above rather
490//! than a longer wait is what makes such a wait converge.
491//!
492//! **A narrowed read is the same bargain, stated for each of the three predicates it
493//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
494//! than walking the board, and every such answer is completed with what this process wrote —
495//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
496//! each filtered by the same predicates as the rest — so an item this command wrote a moment
497//! ago is returned by a query that matches it whether or not the index has caught up. An item
498//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
499//! consistent. What is left is stated rather than papered over:
500//!
501//! | Read | Finds | Behind by |
502//! | --- | --- | --- |
503//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
504//! | 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 |
505//! | 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 |
506//! | origin, third read | this process's own writes | nothing |
507//!
508//! So an origin carrier another process added within the last second or two, before either
509//! index has it, can be missing from an origin query, and one written by the release before
510//! this one — its origin in the field alone — can be missing for as long as the board's own
511//! item connection is behind on it. A copy that must not duplicate its own earlier write
512//! relies on the link it records, not on either index. **A board draft is not an issue**, so
513//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
514//! lists one, the origin lookup drops any the board's own field filter names, and one this
515//! process wrote is not added back either.
516//!
517//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
518//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
519//! body's metadata slot, so the issue search can find it in seconds. The field is
520//! authoritative: this source reads an item's origin from the field alone, so a slot that
521//! disagrees with it, or holds one where the field holds none, is never read as a second
522//! origin — and the release before this one reads the slot, drops that key's copy for the
523//! field's, and sees the same one origin.
524//!
525//! Filtering happens before paging, so a page of a filtered result is a page of the
526//! survivors rather than the survivors of a page. Label matching and the substring rule a
527//! text candidate is confirmed by answer the same question the same way the local Markdown
528//! source's do; which candidates a text search has to confirm is GitHub's word match, which
529//! is the one place the two sources can answer the same text differently.
530//!
531//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
532//! has one source, `capabilities`, and the note above is the reasoning behind it rather
533//! than a second copy of it: without the three facts recorded here a reader takes the
534//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
535//! crate's own capabilities test, which pins every field of it against a fully spelled-out
536//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
537//! fails to compile there rather than going unasserted. -->
538//! The fixture-server tests above run wherever this crate is selected; the credentialed
539//! lane runs in the same required check, beside them, and can fail it — it verifies the
540//! 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,
541//! one filed under neither, a label on one of the three and a closed status on another —
542//! because that shape is what tells an honoured predicate from an ignored one: a board
543//! holding a single project answers a project filter the same way whether or not this
544//! source applies it, which is exactly how the defect above went unseen.
545//!
546//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
547//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
548//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
549//! session or request, independently of the live demand. Fix that variable in the machine's
550//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
551//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
552//! policy crate; absent credentials or nominations otherwise skip.
553//!
554//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
555//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
556//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
557//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
558//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
559//! after its run reads back as `completed`, immediately before the listed-artifact batch.
560//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
561//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
562//! and the board's `onetaskgraph.origin` field untouched.
563//!
564//! # What a session of requests costs, and where the report is
565//!
566//! This source records **every** request it sends into [`accounting::Accounting`], at
567//! `send_once` — the one place a request leaves this crate, which is why a read path added
568//! later is counted without anybody remembering to count it. That is the whole of what this
569//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
570//! session's spend is arrived at, and what it deliberately does not know are set out.
571//!
572//! What one whole session of the live journey costs, counted that way against this crate's
573//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
574//! reduction it came out of, and with what it does and does not say about rate-limit points.
575//!
576//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
577//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
578//! code path — no environment variable, no feature, no build configuration — because an
579//! instrument nobody switches on measures nothing, and
580//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
581//! source's counts the whole session rather than this source's share. The credentialed lane
582//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
583//! passed or failed.
584//!
585//! **A live session refuses to start unless the account can afford it.** Before it does any
586//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
587//! GitHub documents as not counting against the REST rate limit and which answers both of
588//! its budgets at once — and starts only if, for each of them, what remains minus this
589//! session's estimated cost is still at least
590//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
591//! allowance. A session that cannot **declines**: it did not run, so it is
592//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
593//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
594//! waiting for the budget to come back. The estimate is derived offline from
595//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
596//! which is also where the published rule that model rests on is cited; the accounting
597//! above records the gate's own read like any other request, and
598//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
599//!
600//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
601//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
602//! text, which is what lets it run on every platform and on a pull request from a fork with
603//! no credential — and that is what actually stops a regression merging. But an offline
604//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
605//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
606//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
607//! number of nodes this query may return"* and whose `cost` is what that document would
608//! spend, both for a document **without executing it**, and the lane asks it for every query
609//! document this source sends, under the largest bindings this source sends, and fails when
610//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
611//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
612//! one; the offline pins still cover it. It records what those calls reported about the
613//! account's own allowance, because whether asking is free is a thing to observe rather than
614//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
615//! and `cost` is metered against an hourly allowance the accounting above reads off a
616//! credentialed run's own response headers.
617//!
618//! **GitHub has two rate limiters and this source is refused by both, so nothing here
619//! treats them as one thing.** The primary budget is the hourly allowance `gh api
620//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
621//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
622//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
623//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
624//! one part of.
625#![deny(missing_docs)]
626
627use std::collections::BTreeMap;
628use std::sync::{Arc, Mutex};
629use std::time::Duration;
630
631use chrono::{DateTime, Utc};
632use onetaskgraph_plugin_api::{
633 Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
634 ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
635 DependencySupport, Direction, Document, DocumentQuery, DocumentVersionCoverage, Health,
636 HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
637 MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
638 ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
639 SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
640 StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
641 TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
642 WriteJournal, WriteSupport, system_clock,
643};
644use reqwest::{Client, StatusCode, Url};
645use schemars::{Schema, schema_for};
646use secrecy::{ExposeSecret, SecretString};
647use serde::{Deserialize, Serialize};
648use serde_json::{Value, json};
649
650pub mod accounting;
651mod assets;
652pub mod change_token;
653mod expected;
654pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
655mod visibility;
656
657use accounting::Accounting;
658
659/// The registry name for this plugin.
660pub const KIND: &str = "github-projects";
661/// GitHub's maximum connection page size.
662pub const MAX_PAGE_SIZE: u32 = 100;
663/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
664/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
665/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
666pub const SEARCH_PAGE_SIZE: u32 = 20;
667/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
668/// its comments: the largest batch the node-count model prices at one point.
669///
670/// Each aliased item is resolved once, and what GitHub charges for it is the connections
671/// under it — its labels, its page of board memberships, the field values of each of those
672/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
673/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
674/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
675/// one point and fails if one item more would still be priced at one.
676pub const DETAIL_BATCH: usize = 24;
677/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
678///
679/// Each reference asks GitHub for one connection — a page of its board memberships, each with
680/// its `Status` value read by name rather than through a connection of field values — so fifty
681/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
682/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
683/// issues per source. `tests/point_cost.rs` prices the document at one point.
684pub const REFERENCE_BATCH: usize = 50;
685/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
686/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
687/// point.
688///
689/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
690/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
691/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
692/// if one item more would still be priced at one.
693pub const COPY_DETAIL_BATCH: usize = 21;
694
695/// The most nodes any one document this source sends may be asked to return.
696///
697/// GitHub's own published per-query ceiling, taken from
698/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
699/// workspace cannot hold a stale copy of somebody else's number. A query above it is
700/// **refused before it is executed**, whoever is asking and whatever board they are
701/// asking about — so this is a bound on the documents rather than a budget that runs out.
702///
703/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
704/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
705/// everything the credential does — two numbers against two limits, and this constant
706/// bounds only the first. The second is computed offline too, per document:
707/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
708/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
709/// lane. There is no constant like this one to hold a price under, because points are an
710/// hourly allowance rather than a per-call bound.
711///
712/// Neither is a session's price. What `session-cost.md` records of a whole session is its
713/// **requests** and its **worst-case nodes**; what a whole session spends in points is
714/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
715/// [`accounting`]. The module section on the three ways this source reaches an item says how
716/// the count is arrived at, and which of the page sizes below decide it.
717pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
718
719/// Nested connection size for the connections that hang off one item.
720///
721/// It multiplies through every document that reaches an item under a page — the count
722/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
723/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
724/// every document under these constants and fails naming any that reaches the limit, so
725/// raising this is caught there rather than by GitHub.
726const NESTED_PAGE_SIZE: u32 = 50;
727/// How many of one issue's board memberships are read when an issue is reached directly.
728///
729/// An issue reached through a search or through its own node id carries its board half in
730/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
731/// under a page of issues, so every point of it multiplies through the whole document and
732/// is paid for whether or not any issue is on a second board — which is why it is
733/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
734///
735/// **Three, because what a page misses is now recovered rather than refused**, and the
736/// recovery is what the value is chosen against. An issue whose entry for this board sits
737/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
738/// that page's own cursor — so the value trades a bound every read pays for a request only
739/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
740/// boards would pay that request *per issue*, which is order N against the one page per
741/// hundred issues a read costs today. At three it is only reached by an issue on four or
742/// more boards at once, which keeps the recovery path exceptional rather than routine for
743/// a plausible deployment.
744const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
745/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
746/// its two connections for.
747///
748/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
749/// second is a duplicate a copy already takes the first of. Both connections are walked to
750/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
751/// never what the answer is. It is small because every point of it is paid on every lookup,
752/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
753/// worst-case nodes than the one whole-board read they replaced.
754const ORIGIN_PAGE_SIZE: u32 = 3;
755
756pub use github_graphql_node_count::{NodeCountError, Variables};
757
758/// The largest value this source can bind to each page-size variable its documents name.
759///
760/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
761/// constant above it wherever a caller's own limit could reach it — `$first` at
762/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
763/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
764/// not one configuration of it, which is what makes a bound computed under it a bound on
765/// every read.
766pub fn largest_page_sizes() -> Variables {
767 Variables::from([
768 ("first".to_owned(), MAX_PAGE_SIZE),
769 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
770 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
771 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
772 ])
773}
774
775/// The most nodes `document` could be asked to return, by GitHub's published rules.
776///
777/// Computed offline from the document's own text under [`largest_page_sizes`] — no
778/// network, no credential and no schema — by
779/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
780/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
781/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
782///
783/// # Errors
784///
785/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
786/// no single operation, or binds a page size this source does not name — each of which is
787/// a defect in the document rather than a number.
788pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
789 node_count(document, &largest_page_sizes())
790}
791
792/// The most rate-limit points one call of `document` could spend, by GitHub's published
793/// rules.
794///
795/// Computed offline from the document's own text under [`largest_page_sizes`] — no
796/// network, no credential and no schema — by
797/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
798/// This is `cost`, metered **per hour** against the allowance one credential shares across
799/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
800/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
801/// under, so what `tests/point_cost.rs` does with it is pin every document in
802/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
803/// figures against GitHub's own reported `cost`.
804///
805/// # Errors
806///
807/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
808/// no single operation, or binds a page size this source does not name — each of which is
809/// a defect in the document rather than a number.
810pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
811 github_graphql_node_count::point_cost(document, &largest_page_sizes())
812}
813
814/// The most nodes `document` could be asked to return under `variables`.
815///
816/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
817/// [`accounting`] is this under the bindings one request really sent — one spelling of the
818/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
819/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
820///
821/// # Errors
822///
823/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
824/// no single operation, or binds a page size `variables` does not name.
825pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
826 github_graphql_node_count::node_count(document, variables)
827}
828
829/// The issue-title prefix that makes a board issue a document.
830///
831/// A GitHub Projects board has no document type — it holds issues — so the discriminator
832/// is the title, and this is the whole of it: an issue whose title begins with these bytes
833/// is a document and every other issue is the task or project the sub-issue rule makes it.
834///
835/// It is spelled **once**, here, and read rather than restated everywhere else — including
836/// by the shared journeys, which take it from this constant so a board fixture cannot
837/// drift from what this source reads. `docs/metadata.md` records the two consequences that
838/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
839/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
840/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
841pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
842
843/// Exact GraphQL query documents issued by this plugin.
844///
845/// Keeping the production documents here lets the pinned-schema test validate the same
846/// bytes that are sent to GitHub, rather than a test-only copy which could drift
847/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
848/// field, and its guarded caller always supplies the complete existing option set with ids.
849pub mod graphql {
850 /// The board half of one item: the field values every document here reads it from.
851 ///
852 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
853 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
854 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
855 /// *the same value*, because
856 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
857 /// one path. Three spellings of it is what would drift, so there is one.
858 ///
859 /// The `Status` option and this source's own origin text field are the whole of it. It
860 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
861 /// content, so it holds nothing the content's own `labels` do not already say, and it
862 /// would sit a label connection two page sizes deep.
863 macro_rules! board_item_values {
864 () => {
865 r#"fieldValues(first:$nestedFirst){nodes{
866 ... on ProjectV2ItemFieldSingleSelectValue{name field{
867 ... on ProjectV2SingleSelectField{id name options{id name}}
868 }}
869 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
870 }pageInfo{hasNextPage}}"#
871 };
872 }
873
874 /// The variable every document reading an issue declares for the relationship events its
875 /// change token counts, with its value as the default, so no caller binds it.
876 ///
877 /// A variable rather than a literal list because every argument a production document
878 /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
879 /// its default to the pinned enum. Its default is the six event types GitHub records a
880 /// relationship added or removed with: a dependency on either end, as `blockedBy` and
881 /// `blocking`, so one a person makes from the far end moves this end's count too; and a
882 /// project membership on the task, as its parent, which GitHub records there whichever
883 /// issue the move was made from. A sub-issue's events on its parent are not counted: a
884 /// project carries no task token, and a copy writes nothing of a task its own sub-issues
885 /// decide. [`super::change_token`] says why the count is in the token.
886 macro_rules! relationship_events_variable {
887 () => {
888 ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
889 };
890 }
891
892 /// Everything this source reads about one issue, wherever it reaches that issue.
893 ///
894 /// A macro rather than a constant so the three documents below can `concat!` it: one
895 /// spelling of these fields is what makes an issue read through the board-scoped
896 /// search, through its own node id, and through its project's sub-issue relationship
897 /// resolve to *the same* item, which is the whole of what
898 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
899 ///
900 /// `projectItems` is what carries the board half of an issue: the board item's own id
901 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
902 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
903 /// issue rather than on the board, which is what makes the cost of a read proportional
904 /// to what was asked for instead of to the board's size.
905 ///
906 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
907 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
908 /// not on that page: a page here is where the search for the entry starts rather than
909 /// where it ends.
910 ///
911 /// It does **not** select the board's `Labels` field value, and that is the whole of
912 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
913 /// a label connection there sits under `fieldValues` under `projectItems` under a page
914 /// of issues, spending `$nestedFirst` twice down one path, and took
915 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
916 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
917 /// above, and that connection is where every label this source reports comes from. No
918 /// document in this module selects the board field any longer, [`BOARD`] included; the
919 /// module documentation records why nothing it could have held is lost.
920 macro_rules! board_issue {
921 () => {
922 concat!(
923 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
924 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
925 projectItems(first:$boardItems){nodes{id updatedAt project{id number}
926 "#,
927 board_item_values!(),
928 r#"}pageInfo{hasNextPage endCursor}}}"#
929 )
930 };
931 }
932
933 /// Every issue of one board, found by a search scoped to that board.
934 ///
935 /// This is how the projects a board holds are listed, and it selects no `items`
936 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
937 /// container walked page by page, so nothing nested inside a board item is paid for.
938 /// Which of the issues it returns is a project is then read off `parent` — GitHub
939 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
940 /// discriminator has to be applied to the field, which is a scalar on the issue and
941 /// costs nothing.
942 pub const SEARCH_ISSUES: &str = concat!(
943 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
944 relationship_events_variable!(),
945 r#"){
946 search(query:$search,type:$type,first:$first,after:$after){
947 pageInfo{hasNextPage endCursor}
948 nodes{__typename ...BoardIssue}
949 }
950 }"#,
951 board_issue!()
952 );
953
954 /// What a dependency read selects of each far end: enough to say which kind of item it
955 /// is, its body included for the kind marker.
956 macro_rules! related_issue {
957 () => {
958 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
959 };
960 }
961
962 /// One issue by its own node id, which is what a qualified id names here — with what a
963 /// write of it needs and the issue does not carry in `board_issue!`: the field
964 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
965 ///
966 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
967 /// answers a write made moments ago with the value from before it, and resolving a node
968 /// id does not.
969 ///
970 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
971 /// reads it by its own id, and with them that one read answers everything the write
972 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
973 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
974 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
975 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
976 /// they sit under one item, and this read is still one point.
977 pub const ISSUE: &str = concat!(
978 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
979 relationship_events_variable!(),
980 r#"){
981 node(id:$id){__typename ...BoardIssue ... on Issue{
982 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
983 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
984 ... on ProjectV2Field{__typename id name dataType}
985 }pageInfo{hasNextPage}}}}}
986 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
987 }}
988 }"#,
989 board_issue!(),
990 related_issue!()
991 );
992
993 /// One project's tasks: the sub-issues of the issue that project is, each with a page of
994 /// what blocks it.
995 ///
996 /// The work this costs is the project's own size. Nothing about it grows as the board
997 /// gains projects, or as those projects gain tasks.
998 ///
999 /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1000 /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1001 /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1002 /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1003 /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1004 /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1005 /// It adds a connection under each issue of the page — one rate-limit point per page,
1006 /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1007 /// answer questions that never read an edge.
1008 ///
1009 /// **The project issue itself rides here too**, with the page of what blocks it, because
1010 /// the reader of a project's tasks reads the project next: a copy writing the project reads
1011 /// it and its edges before it writes them. Carried on the request that already names the
1012 /// project, the first page answers that read and the dependency read after it, at the
1013 /// state GitHub answered in this command, rather than spending one more read on an item
1014 /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1015 /// copy's read of a task this listing carried is answered by it rather than sent again.
1016 /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1017 pub const SUB_ISSUES: &str = concat!(
1018 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1019 relationship_events_variable!(),
1020 r#"){
1021 node(id:$id){__typename ...BoardIssue
1022 ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1023 subIssues(first:$first,after:$after){
1024 pageInfo{hasNextPage endCursor}
1025 nodes{__typename ...BoardIssue ... on Issue{
1026 blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1027 }}
1028 }}}
1029 }"#,
1030 board_issue!(),
1031 related_issue!()
1032 );
1033
1034 /// What a read of the board's own `items` selects of each item's content.
1035 ///
1036 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1037 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1038 /// content by one spelling.
1039 macro_rules! board_item_content {
1040 () => {
1041 r#" content{
1042 ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
1043 ... on PullRequest{__typename id}
1044 ... on DraftIssue{__typename id title body createdAt updatedAt}
1045 }"#
1046 };
1047 }
1048
1049 /// Reads the board's fields and one page of its items.
1050 pub const BOARD: &str = concat!(
1051 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1052 relationship_events_variable!(),
1053 r#"){
1054 owner:repositoryOwner(login:$owner){
1055 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1056 }
1057 } fragment Board on ProjectV2 { id title
1058 fields(first:$nestedFirst){nodes{
1059 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1060 ... on ProjectV2Field{__typename id name dataType}
1061 }pageInfo{hasNextPage}}
1062 items(first:$first,after:$after){nodes{id updatedAt "#,
1063 board_item_values!(),
1064 board_item_content!(),
1065 r#"} pageInfo{hasNextPage endCursor}}
1066 }"#
1067 );
1068
1069 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1070 /// the board.
1071 ///
1072 /// **`originItems`** is the board's own items narrowed by its own field filter —
1073 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1074 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1075 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1076 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1077 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1078 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1079 /// fresh `addProjectV2ItemById` the way that connection does.
1080 ///
1081 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1082 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1083 /// indexes that comment, and the index catches up with a write in a second or two rather
1084 /// than in minutes, so it finds a carrier another process wrote that the first read is
1085 /// still behind on.
1086 ///
1087 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1088 /// — and resumes from its own cursor; a connection already walked to its end is resumed
1089 /// from its last cursor, which answers an empty page. Every candidate either read returns
1090 /// is confirmed against its own origin field before it is reported, so a token match of
1091 /// the search or anything else the filter admits never is.
1092 ///
1093 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1094 /// own whole reads counts this one among them.
1095 pub const ORIGIN_LOOKUP: &str = concat!(
1096 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1097 relationship_events_variable!(),
1098 r#"){
1099 originItems:repositoryOwner(login:$owner){
1100 ... on ProjectV2Owner{projectV2(number:$number){
1101 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1102 board_item_values!(),
1103 board_item_content!(),
1104 r#"} pageInfo{hasNextPage endCursor}}
1105 }}
1106 }
1107 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1108 pageInfo{hasNextPage endCursor}
1109 nodes{__typename ...BoardIssue}
1110 }
1111 }"#,
1112 board_issue!()
1113 );
1114
1115 /// Every closed item of the board, by the board's own field filter — what
1116 /// `task status reconcile` reads, and nothing else of the board.
1117 ///
1118 /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1119 /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1120 /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1121 /// closed, with a reason and a card that contradict each other, from the item node it
1122 /// already carries, so a filter that also let open items through would cost reads and
1123 /// write nothing wrong.
1124 ///
1125 /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1126 /// option writes that follow need no read of the board. The root is aliased `closedItems`
1127 /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1128 /// among them.
1129 pub const CLOSED_ITEMS: &str = concat!(
1130 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1131 relationship_events_variable!(),
1132 r#"){
1133 closedItems:repositoryOwner(login:$owner){
1134 ... on ProjectV2Owner{projectV2(number:$number){id
1135 fields(first:$nestedFirst){nodes{
1136 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1137 ... on ProjectV2Field{__typename id name dataType}
1138 }pageInfo{hasNextPage}}
1139 items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1140 board_item_values!(),
1141 board_item_content!(),
1142 r#"} pageInfo{hasNextPage endCursor}}
1143 }}
1144 }
1145 }"#
1146 );
1147
1148 /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1149 macro_rules! reference_alias {
1150 ($n:literal) => {
1151 concat!(
1152 "\n r",
1153 stringify!($n),
1154 ":repository(owner:$owner",
1155 stringify!($n),
1156 ",name:$name",
1157 stringify!($n),
1158 "){issue(number:$number",
1159 stringify!($n),
1160 "){...HeldIssue}}"
1161 )
1162 };
1163 }
1164
1165 /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1166 /// number — the one read `task status complete` makes of a source, and nothing else of
1167 /// the board: no `items`, no search and no project read.
1168 ///
1169 /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1170 /// references one point.** It selects no labels, and of each board membership only the
1171 /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1172 /// connection, its page of memberships, where the whole fragment asks for five. What a
1173 /// completion writes is a card and, at most, the issue's state, so the labels and the other
1174 /// field values it leaves out are never what it needs; an item read this way is held for
1175 /// that write alone and never answers another read. An issue whose entry for this board is
1176 /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1177 /// every other read of an issue recovers one.
1178 ///
1179 /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1180 /// fewer references binds the slots it has none for to its last one.
1181 pub const ISSUES_BY_REFERENCE: &str = concat!(
1182 r#"query($owner0:String!,$name0:String!,$number0:Int!,$owner1:String!,$name1:String!,$number1:Int!,$owner2:String!,$name2:String!,$number2:Int!,$owner3:String!,$name3:String!,$number3:Int!,$owner4:String!,$name4:String!,$number4:Int!,$owner5:String!,$name5:String!,$number5:Int!,$owner6:String!,$name6:String!,$number6:Int!,$owner7:String!,$name7:String!,$number7:Int!,$owner8:String!,$name8:String!,$number8:Int!,$owner9:String!,$name9:String!,$number9:Int!,$owner10:String!,$name10:String!,$number10:Int!,$owner11:String!,$name11:String!,$number11:Int!,$owner12:String!,$name12:String!,$number12:Int!,$owner13:String!,$name13:String!,$number13:Int!,$owner14:String!,$name14:String!,$number14:Int!,$owner15:String!,$name15:String!,$number15:Int!,$owner16:String!,$name16:String!,$number16:Int!,$owner17:String!,$name17:String!,$number17:Int!,$owner18:String!,$name18:String!,$number18:Int!,$owner19:String!,$name19:String!,$number19:Int!,$owner20:String!,$name20:String!,$number20:Int!,$owner21:String!,$name21:String!,$number21:Int!,$owner22:String!,$name22:String!,$number22:Int!,$owner23:String!,$name23:String!,$number23:Int!,$owner24:String!,$name24:String!,$number24:Int!,$owner25:String!,$name25:String!,$number25:Int!,$owner26:String!,$name26:String!,$number26:Int!,$owner27:String!,$name27:String!,$number27:Int!,$owner28:String!,$name28:String!,$number28:Int!,$owner29:String!,$name29:String!,$number29:Int!,$owner30:String!,$name30:String!,$number30:Int!,$owner31:String!,$name31:String!,$number31:Int!,$owner32:String!,$name32:String!,$number32:Int!,$owner33:String!,$name33:String!,$number33:Int!,$owner34:String!,$name34:String!,$number34:Int!,$owner35:String!,$name35:String!,$number35:Int!,$owner36:String!,$name36:String!,$number36:Int!,$owner37:String!,$name37:String!,$number37:Int!,$owner38:String!,$name38:String!,$number38:Int!,$owner39:String!,$name39:String!,$number39:Int!,$owner40:String!,$name40:String!,$number40:Int!,$owner41:String!,$name41:String!,$number41:Int!,$owner42:String!,$name42:String!,$number42:Int!,$owner43:String!,$name43:String!,$number43:Int!,$owner44:String!,$name44:String!,$number44:Int!,$owner45:String!,$name45:String!,$number45:Int!,$owner46:String!,$name46:String!,$number46:Int!,$owner47:String!,$name47:String!,$number47:Int!,$owner48:String!,$name48:String!,$number48:Int!,$owner49:String!,$name49:String!,$number49:Int!,$boardItems:Int!,$statusField:String!,$duplicates:Boolean!"#,
1183 relationship_events_variable!(),
1184 r#"){"#,
1185 reference_alias!(0),
1186 reference_alias!(1),
1187 reference_alias!(2),
1188 reference_alias!(3),
1189 reference_alias!(4),
1190 reference_alias!(5),
1191 reference_alias!(6),
1192 reference_alias!(7),
1193 reference_alias!(8),
1194 reference_alias!(9),
1195 reference_alias!(10),
1196 reference_alias!(11),
1197 reference_alias!(12),
1198 reference_alias!(13),
1199 reference_alias!(14),
1200 reference_alias!(15),
1201 reference_alias!(16),
1202 reference_alias!(17),
1203 reference_alias!(18),
1204 reference_alias!(19),
1205 reference_alias!(20),
1206 reference_alias!(21),
1207 reference_alias!(22),
1208 reference_alias!(23),
1209 reference_alias!(24),
1210 reference_alias!(25),
1211 reference_alias!(26),
1212 reference_alias!(27),
1213 reference_alias!(28),
1214 reference_alias!(29),
1215 reference_alias!(30),
1216 reference_alias!(31),
1217 reference_alias!(32),
1218 reference_alias!(33),
1219 reference_alias!(34),
1220 reference_alias!(35),
1221 reference_alias!(36),
1222 reference_alias!(37),
1223 reference_alias!(38),
1224 reference_alias!(39),
1225 reference_alias!(40),
1226 reference_alias!(41),
1227 reference_alias!(42),
1228 reference_alias!(43),
1229 reference_alias!(44),
1230 reference_alias!(45),
1231 reference_alias!(46),
1232 reference_alias!(47),
1233 reference_alias!(48),
1234 reference_alias!(49),
1235 "\n }",
1236 r#" fragment HeldIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
1237 projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1238 status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1239 ... on ProjectV2SingleSelectField{id name options{id name}}
1240 }}}
1241 }pageInfo{hasNextPage endCursor}}}"#
1242 );
1243
1244 /// The board's own id and field definitions, and not one of its items.
1245 ///
1246 /// What a write needs of the board when the item it writes does not say: the id a field
1247 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1248 /// origin fields. It selects no `items`, so what it costs is the board's field list
1249 /// however many items the board holds — and it decides nothing about which items those
1250 /// are, which is the question a read of one item by its own id answers instead.
1251 ///
1252 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1253 /// board's item reads by their root counts this one among them.
1254 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1255 boardFields:repositoryOwner(login:$owner){
1256 ... on ProjectV2Owner{projectV2(number:$number){id
1257 fields(first:$nestedFirst){nodes{
1258 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1259 ... on ProjectV2Field{__typename id name dataType}
1260 }pageInfo{hasNextPage}}
1261 }}
1262 }
1263 }"#;
1264
1265 /// One board draft by its own node id, with the board item it sits in.
1266 ///
1267 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1268 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1269 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1270 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1271 /// board listing hands it to, and nothing has to list the board to find one.
1272 pub const DRAFT: &str = concat!(
1273 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1274 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1275 projectV2Items(first:$boardItems){nodes{id project{id number}
1276 "#,
1277 board_item_values!(),
1278 r#"}pageInfo{hasNextPage endCursor}}}}
1279 }"#
1280 );
1281
1282 /// One issue's board memberships alone, walked past the page a read of it carried.
1283 ///
1284 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1285 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1286 /// boards than that page holds may have this board's entry past its end. This asks that
1287 /// one issue for its memberships and nothing else — the caller already holds the issue —
1288 /// so an answer of "this board does not hold it" is only ever given about a connection
1289 /// read to exhaustion.
1290 ///
1291 /// It selects the board item's id, its project number and the same
1292 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1293 /// very same resolver: an issue recovered this way reports the same title, the same
1294 /// status, the same labels and the same qualified id as one whose entry was on the
1295 /// page.
1296 ///
1297 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1298 /// multiplies through it and the membership connection can be walked at
1299 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1300 /// further request for any issue a person really keeps.
1301 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1302 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1303 node(id:$id){
1304 ... on Issue{projectItems(first:$first,after:$after){
1305 nodes{id updatedAt project{id number}
1306 "#,
1307 board_item_values!(),
1308 r#"}
1309 pageInfo{hasNextPage endCursor}}}
1310 }
1311 }"#
1312 );
1313 /// Whether the board's Project is public — half of what decides whether a write here can
1314 /// be read by anybody. Needs the `read:project` scope.
1315 pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1316 /// Resolves the configured repository's node id, which creating an issue requires.
1317 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1318 /// What creating an issue needs and has not read yet: the board's own id and field
1319 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1320 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1321 ///
1322 /// Sent at the point a create knows which repository it is for, when neither half is
1323 /// already known to this process; a create needing only one of them sends that one's own
1324 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1325 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1326 /// status.
1327 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1328 boardFields:repositoryOwner(login:$owner){
1329 ... on ProjectV2Owner{projectV2(number:$number){id
1330 fields(first:$nestedFirst){nodes{
1331 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1332 ... on ProjectV2Field{__typename id name dataType}
1333 }pageInfo{hasNextPage}}
1334 }}
1335 }
1336 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1337 }"#;
1338 /// Reads both dependency directions for one issue, with each far end's own kind — and
1339 /// the issue's own body, which is where an edge to another source is recorded, so that
1340 /// half of a dependency read needs no second read of the issue or of the board.
1341 pub const ISSUE_DEPENDENCIES: &str = concat!(
1342 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1343 ... on Issue{body
1344 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1345 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1346 }}}"#,
1347 related_issue!()
1348 );
1349 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1350 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1351 /// answered when it was.
1352 pub const CREATE_ISSUE: &str =
1353 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1354 /// Puts an existing issue on the configured board.
1355 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1356 /// Updates an issue's visible fields and its open or closed state in one call.
1357 pub const UPDATE_ISSUE: &str =
1358 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1359 /// Updates an existing draft's user-visible fields.
1360 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1361 /// Updates a text or single-select value on one project item.
1362 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}}}}}}}}"#;
1363 /// Writes up to six board fields and clears up to three in one ordered mutation: every
1364 /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1365 /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1366 pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$fourth:UpdateProjectV2ItemFieldValueInput!,$fifth:UpdateProjectV2ItemFieldValueInput!,$sixth:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$clearSecond:ClearProjectV2ItemFieldValueInput!,$clearThird:ClearProjectV2ItemFieldValueInput!,$writeFirst:Boolean!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeFourth:Boolean!,$writeFifth:Boolean!,$writeSixth:Boolean!,$writeClear:Boolean!,$writeClearSecond:Boolean!,$writeClearThird:Boolean!){updateProjectV2ItemFieldValue(input:$input) @include(if:$writeFirst){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} fourth:updateProjectV2ItemFieldValue(input:$fourth) @include(if:$writeFourth){projectV2Item{id}} fifth:updateProjectV2ItemFieldValue(input:$fifth) @include(if:$writeFifth){projectV2Item{id}} sixth:updateProjectV2ItemFieldValue(input:$sixth) @include(if:$writeSixth){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}} clearedSecond:clearProjectV2ItemFieldValue(input:$clearSecond) @include(if:$writeClearSecond){projectV2Item{id}} clearedThird:clearProjectV2ItemFieldValue(input:$clearThird) @include(if:$writeClearThird){projectV2Item{id}}}"#;
1367 /// Clears one project item's value of one field, which is what a `none` priority is.
1368 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}}}}}}}}"#;
1369 /// Creates one field: a single-select one with its options, or a text one for a projected
1370 /// metadata value. Only the guarded field setup may use this document, and only for a
1371 /// field the board lacks.
1372 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1373 /// Replaces a single-select field's options. Only the guarded field setup — the
1374 /// `status-options` and `fields` operations — may use this document, because GitHub
1375 /// treats the input as the complete option list.
1376 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1377 /// A fresh snapshot of the Status field and every board item's assignment.
1378 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}}}}}}"#;
1379 /// Files one issue under another as a sub-issue, which is what project membership is.
1380 pub const ADD_SUB_ISSUE: &str =
1381 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1382 /// Takes one issue back out of its parent.
1383 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1384 /// Adds GitHub's native issue blocked-by relationship.
1385 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1386 /// Removes one native issue blocked-by relationship.
1387 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1388 /// Deletes one issue, which takes its board item with it.
1389 ///
1390 /// The engine sends this in one situation only: undoing a copy that could not finish,
1391 /// over the items that same copy created. Deleting the issue removes the board item
1392 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1393 pub const DELETE_ISSUE: &str =
1394 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1395
1396 /// Everything this source reads about one issue comment, wherever it reaches one.
1397 ///
1398 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1399 /// and a comment just edited are handed to one mapper, so they are selected by one
1400 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1401 /// longer exists, and `login` is the one member every kind of actor carries.
1402 macro_rules! issue_comment {
1403 () => {
1404 "id author{login} createdAt updatedAt body url"
1405 };
1406 }
1407
1408 /// One task's comments: a page of its issue's own `comments` connection.
1409 ///
1410 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1411 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1412 /// list every time somebody edited it; left unordered the connection answers in the order
1413 /// the comments were written, which is the order GitHub documents for the same collection
1414 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1415 /// node count and the caller's own page size is pushed straight down.
1416 pub const ISSUE_COMMENTS: &str = concat!(
1417 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1418 issue_comment!(),
1419 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1420 );
1421 /// One issue by its own node id, with a page of its comments: what `task show` and a
1422 /// comment listing read, in one request.
1423 ///
1424 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1425 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1426 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1427 /// connection there would multiply through both of those documents' price, and neither
1428 /// needs one.
1429 pub const ISSUE_DETAIL: &str = concat!(
1430 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1431 relationship_events_variable!(),
1432 r#"){
1433 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1434 issue_comment!(),
1435 r#"}pageInfo{hasNextPage endCursor}}}}
1436 }"#,
1437 board_issue!()
1438 );
1439
1440 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1441 /// page of its comments when `$comments` asks for them.
1442 macro_rules! issue_details_alias {
1443 ($n:literal) => {
1444 concat!(
1445 "\n i",
1446 stringify!($n),
1447 ":node(id:$id",
1448 stringify!($n),
1449 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1450 issue_comment!(),
1451 "}pageInfo{hasNextPage endCursor}}}}"
1452 )
1453 };
1454 }
1455
1456 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1457 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1458 ///
1459 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1460 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1461 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1462 /// neither — so every connection under it would be priced at nothing and the pin in
1463 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1464 /// one-item read the model already prices, so the batch costs what its aliases cost.
1465 ///
1466 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1467 /// slots it has no item for to the last item it does, and reads that item again; the
1468 /// price is the document's, whatever its variables, so a short batch costs what a full
1469 /// one does and nothing more.
1470 pub const ISSUE_DETAILS: &str = concat!(
1471 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!"#,
1472 relationship_events_variable!(),
1473 r#"){"#,
1474 issue_details_alias!(0),
1475 issue_details_alias!(1),
1476 issue_details_alias!(2),
1477 issue_details_alias!(3),
1478 issue_details_alias!(4),
1479 issue_details_alias!(5),
1480 issue_details_alias!(6),
1481 issue_details_alias!(7),
1482 issue_details_alias!(8),
1483 issue_details_alias!(9),
1484 issue_details_alias!(10),
1485 issue_details_alias!(11),
1486 issue_details_alias!(12),
1487 issue_details_alias!(13),
1488 issue_details_alias!(14),
1489 issue_details_alias!(15),
1490 issue_details_alias!(16),
1491 issue_details_alias!(17),
1492 issue_details_alias!(18),
1493 issue_details_alias!(19),
1494 issue_details_alias!(20),
1495 issue_details_alias!(21),
1496 issue_details_alias!(22),
1497 issue_details_alias!(23),
1498 "\n }",
1499 board_issue!()
1500 );
1501
1502 /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
1503 /// page of its comments and the issues it is blocked by.
1504 macro_rules! issue_copy_details_alias {
1505 ($n:literal) => {
1506 concat!(
1507 "\n i",
1508 stringify!($n),
1509 ":node(id:$id",
1510 stringify!($n),
1511 "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
1512 issue_comment!(),
1513 "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
1514 )
1515 };
1516 }
1517
1518 /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
1519 /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
1520 /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
1521 /// at a time, so a copy of a changed task costs this read and nothing else.
1522 ///
1523 /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
1524 /// what `task show-many` is priced by and which reads no edge.
1525 pub const ISSUE_COPY_DETAILS: &str = concat!(
1526 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!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1527 relationship_events_variable!(),
1528 r#"){"#,
1529 issue_copy_details_alias!(0),
1530 issue_copy_details_alias!(1),
1531 issue_copy_details_alias!(2),
1532 issue_copy_details_alias!(3),
1533 issue_copy_details_alias!(4),
1534 issue_copy_details_alias!(5),
1535 issue_copy_details_alias!(6),
1536 issue_copy_details_alias!(7),
1537 issue_copy_details_alias!(8),
1538 issue_copy_details_alias!(9),
1539 issue_copy_details_alias!(10),
1540 issue_copy_details_alias!(11),
1541 issue_copy_details_alias!(12),
1542 issue_copy_details_alias!(13),
1543 issue_copy_details_alias!(14),
1544 issue_copy_details_alias!(15),
1545 issue_copy_details_alias!(16),
1546 issue_copy_details_alias!(17),
1547 issue_copy_details_alias!(18),
1548 issue_copy_details_alias!(19),
1549 issue_copy_details_alias!(20),
1550 "\n }",
1551 board_issue!(),
1552 related_issue!()
1553 );
1554
1555 /// Which issue one comment is on, read before that comment is edited or removed.
1556 ///
1557 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1558 /// comment id given against the wrong task would change a comment on another issue.
1559 pub const COMMENT_ISSUE: &str =
1560 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
1561 /// Adds one comment to an issue, signed as the account the token belongs to.
1562 pub const ADD_COMMENT: &str = concat!(
1563 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1564 issue_comment!(),
1565 r#"}}}}"#
1566 );
1567 /// Replaces the body of one issue comment.
1568 pub const UPDATE_COMMENT: &str = concat!(
1569 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1570 issue_comment!(),
1571 r#"}}}"#
1572 );
1573 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1574 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1575
1576 /// What a mutation of a write the store's cache expects selects of the issue it wrote:
1577 /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
1578 /// change token's every part and the first page of its comments.
1579 ///
1580 /// Sent **only** while that write runs, in place of the document named beside each
1581 /// below, so every other write's documents, prices and request counts are what they
1582 /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
1583 /// mutation's response is stamped seconds after the change it made, so against GitHub
1584 /// itself a response rarely determines a version; the cache is then left unserveable
1585 /// for the task, and its next read refreshes it.
1586 macro_rules! whole_issue {
1587 () => {
1588 concat!(
1589 r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
1590 issue_comment!(),
1591 r#"}pageInfo{hasNextPage endCursor}}}"#
1592 )
1593 };
1594 }
1595
1596 /// The variables every document of `whole_issue!` declares beside its own.
1597 macro_rules! whole_variables {
1598 () => {
1599 concat!(
1600 r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1601 relationship_events_variable!()
1602 )
1603 };
1604 }
1605
1606 /// [`ADD_COMMENT`], selecting the whole issue commented on.
1607 pub const ADD_COMMENT_WHOLE: &str = concat!(
1608 r#"mutation($input:AddCommentInput!"#,
1609 whole_variables!(),
1610 r#"){addComment(input:$input){subject{id "#,
1611 whole_issue!(),
1612 r#"} commentEdge{node{"#,
1613 issue_comment!(),
1614 r#"}}}}"#,
1615 board_issue!()
1616 );
1617 /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
1618 pub const UPDATE_COMMENT_WHOLE: &str = concat!(
1619 r#"mutation($input:UpdateIssueCommentInput!"#,
1620 whole_variables!(),
1621 r#"){updateIssueComment(input:$input){issueComment{"#,
1622 issue_comment!(),
1623 r#" issue{"#,
1624 whole_issue!(),
1625 r#"}}}}"#,
1626 board_issue!()
1627 );
1628 /// [`UPDATE_ISSUE`], selecting the whole issue updated.
1629 pub const UPDATE_ISSUE_WHOLE: &str = concat!(
1630 r#"mutation($input:UpdateIssueInput!"#,
1631 whole_variables!(),
1632 r#"){updateIssue(input:$input){issue{id "#,
1633 whole_issue!(),
1634 r#"}}}"#,
1635 board_issue!()
1636 );
1637 /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
1638 pub const UPDATE_FIELD_WHOLE: &str = concat!(
1639 r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1640 whole_variables!(),
1641 r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1642 whole_issue!(),
1643 r#"}}}}"#,
1644 board_issue!()
1645 );
1646 /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
1647 pub const CLEAR_FIELD_WHOLE: &str = concat!(
1648 r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1649 whole_variables!(),
1650 r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1651 whole_issue!(),
1652 r#"}}}}"#,
1653 board_issue!()
1654 );
1655
1656 /// The document sent in `document`'s place while a write the store's cache expects runs,
1657 /// when it has one.
1658 #[must_use]
1659 pub fn whole_twin(document: &str) -> Option<&'static str> {
1660 [
1661 (ADD_COMMENT, ADD_COMMENT_WHOLE),
1662 (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
1663 (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
1664 (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
1665 (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
1666 ]
1667 .into_iter()
1668 .find(|(plain, _)| *plain == document)
1669 .map(|(_, whole)| whole)
1670 }
1671
1672 /// Every document above, with what this source is doing when it sends one.
1673 ///
1674 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1675 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1676 /// document added later with "talking to GitHub" and never say so.
1677 ///
1678 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1679 /// const` here that this list omits, so the two cannot part — which is the same guard
1680 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1681 pub const DOCUMENTS: [(&str, &str); 42] = [
1682 (SEARCH_ISSUES, "searching this board's issues"),
1683 (ISSUE, "reading one issue"),
1684 (
1685 ISSUE_BOARD_ITEMS,
1686 "reading one issue's board memberships past the page it came with",
1687 ),
1688 (SUB_ISSUES, "reading a project's tasks"),
1689 (BOARD, "reading the board"),
1690 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1691 (CLOSED_ITEMS, "reading the board's closed items"),
1692 (
1693 ISSUES_BY_REFERENCE,
1694 "reading a batch of issues by their references",
1695 ),
1696 (BOARD_FIELDS, "reading the board's fields"),
1697 (DRAFT, "reading one draft"),
1698 (REPOSITORY, "reading the destination repository"),
1699 (
1700 PROJECT_VISIBILITY,
1701 "reading whether the board's project is public",
1702 ),
1703 (
1704 CREATION_CONTEXT,
1705 "reading the board's fields and the destination repository",
1706 ),
1707 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1708 (CREATE_ISSUE, "creating an issue"),
1709 (ADD_TO_BOARD, "adding an issue to the board"),
1710 (UPDATE_ISSUE, "updating an issue"),
1711 (UPDATE_DRAFT, "updating a draft item"),
1712 (UPDATE_FIELD, "writing a board field"),
1713 (UPDATE_FIELDS, "writing board fields together"),
1714 (CLEAR_FIELD, "clearing a board field"),
1715 (
1716 CREATE_FIELD,
1717 "creating a board single-select field with its options",
1718 ),
1719 (
1720 STATUS_OPTIONS_SNAPSHOT,
1721 "snapshotting board Status options and assignments",
1722 ),
1723 (
1724 STATUS_OPTIONS_UPDATE,
1725 "safely replacing the board Status option list",
1726 ),
1727 (ADD_SUB_ISSUE, "filing an issue under its project"),
1728 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1729 (ADD_BLOCKED_BY, "recording a dependency"),
1730 (REMOVE_BLOCKED_BY, "removing a dependency"),
1731 (DELETE_ISSUE, "deleting an issue"),
1732 (ISSUE_COMMENTS, "reading a task's comments"),
1733 (ISSUE_DETAIL, "reading one issue with its comments"),
1734 (
1735 ISSUE_DETAILS,
1736 "reading a batch of issues with their comments",
1737 ),
1738 (
1739 ISSUE_COPY_DETAILS,
1740 "reading a batch of issues with their comments and dependencies for a copy",
1741 ),
1742 (COMMENT_ISSUE, "reading which issue a comment is on"),
1743 (ADD_COMMENT, "adding a comment"),
1744 (UPDATE_COMMENT, "editing a comment"),
1745 (DELETE_COMMENT, "deleting a comment"),
1746 (
1747 ADD_COMMENT_WHOLE,
1748 "adding a comment, answered with the whole issue",
1749 ),
1750 (
1751 UPDATE_COMMENT_WHOLE,
1752 "editing a comment, answered with the whole issue",
1753 ),
1754 (
1755 UPDATE_ISSUE_WHOLE,
1756 "updating an issue, answered with it whole",
1757 ),
1758 (
1759 UPDATE_FIELD_WHOLE,
1760 "writing a board field, answered with the whole issue",
1761 ),
1762 (
1763 CLEAR_FIELD_WHOLE,
1764 "clearing a board field, answered with the whole issue",
1765 ),
1766 ];
1767}
1768
1769/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
1770/// under, the variable carrying its input, and the boolean variable that includes it.
1771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1772pub struct FieldSlot {
1773 /// The key of the answer's `data` this field's payload is under.
1774 pub alias: &'static str,
1775 /// The variable carrying this field's input.
1776 pub variable: &'static str,
1777 /// The boolean variable whose `true` sends this field.
1778 pub include: &'static str,
1779}
1780
1781/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
1782pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
1783 FieldSlot {
1784 alias: "updateProjectV2ItemFieldValue",
1785 variable: "input",
1786 include: "writeFirst",
1787 },
1788 FieldSlot {
1789 alias: "second",
1790 variable: "second",
1791 include: "writeSecond",
1792 },
1793 FieldSlot {
1794 alias: "third",
1795 variable: "third",
1796 include: "writeThird",
1797 },
1798 FieldSlot {
1799 alias: "fourth",
1800 variable: "fourth",
1801 include: "writeFourth",
1802 },
1803 FieldSlot {
1804 alias: "fifth",
1805 variable: "fifth",
1806 include: "writeFifth",
1807 },
1808 FieldSlot {
1809 alias: "sixth",
1810 variable: "sixth",
1811 include: "writeSixth",
1812 },
1813];
1814
1815/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
1816pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
1817 FieldSlot {
1818 alias: "cleared",
1819 variable: "clear",
1820 include: "writeClear",
1821 },
1822 FieldSlot {
1823 alias: "clearedSecond",
1824 variable: "clearSecond",
1825 include: "writeClearSecond",
1826 },
1827 FieldSlot {
1828 alias: "clearedThird",
1829 variable: "clearThird",
1830 include: "writeClearThird",
1831 },
1832];
1833
1834/// Which of GitHub's two rate limiters refused a request.
1835///
1836/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1837/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1838/// the whole reason this is carried rather than collapsed into "rate limited".
1839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1840enum Limiter {
1841 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1842 Primary,
1843 /// The burst limiter over content-generating requests, which nothing reports.
1844 Secondary,
1845}
1846
1847/// The wordings GitHub answers a secondary rate limit with.
1848///
1849/// It sends them under a forbidden status, under a too-many-requests status, and inside
1850/// the `errors` of a *successful* response, which is why the text is what this matches on
1851/// rather than the status. `abuse detection` is the wording GitHub used before the
1852/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1853/// what a burst of content creation is refused with.
1854///
1855/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1856/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1857/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1858/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1859pub const SECONDARY_WORDINGS: [&str; 5] = [
1860 "secondary rate limit",
1861 "temporarily blocked from content creation",
1862 "abuse detection",
1863 "submitted too quickly",
1864 "exceeded a secondary",
1865];
1866
1867/// The wordings GitHub answers an exhausted primary budget with.
1868///
1869/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1870/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1871/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1872/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1873/// two phrases is a substring of it, so without it that answer read as a refusal that will
1874/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1875/// one reason.
1876pub const PRIMARY_WORDINGS: [&str; 4] = [
1877 "api rate limit exceeded",
1878 "api rate limit already exceeded",
1879 "rate limit exceeded",
1880 "rate_limited",
1881];
1882
1883/// What a response *says about itself*, which is the only place a refusal can be read.
1884///
1885/// Deliberately not the whole response body. A board is a place people write about their
1886/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1887/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1888/// reported. So the item data is never read: what is read is GitHub's own REST-style
1889/// `message` envelope, which is what a forbidden status carries, and the `message` and
1890/// `type` of each GraphQL error, which is where a *successful* response says it.
1891///
1892/// A body that is not JSON at all has nothing structured to read, so only a failing
1893/// response's own text is taken — a successful response that is not JSON is malformed
1894/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1895fn refusal_wording(status: StatusCode, body: &str) -> String {
1896 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1897 return if status.is_success() {
1898 String::new()
1899 } else {
1900 body.to_owned()
1901 };
1902 };
1903 let mut said: Vec<&str> = parsed
1904 .get("message")
1905 .and_then(Value::as_str)
1906 .into_iter()
1907 .collect();
1908 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1909 for error in errors {
1910 said.extend(
1911 ["message", "type"]
1912 .into_iter()
1913 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1914 );
1915 }
1916 }
1917 said.join("; ")
1918}
1919
1920impl Limiter {
1921 /// Which limiter refused this response, or `None` when none of them did.
1922 ///
1923 /// The wording is read first and the status only decides what carries none of it,
1924 /// because GitHub answers a secondary limit with a forbidden status far more often
1925 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1926 /// really is a credential this token lacks.
1927 ///
1928 /// A response is a refusal because of its status or its own wording. A spent budget
1929 /// only ever explains one; it never turns an answer into a refusal.
1930 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1931 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1932 if SECONDARY_WORDINGS
1933 .iter()
1934 .any(|wording| normalized.contains(wording))
1935 {
1936 return Some(Self::Secondary);
1937 }
1938 if status == StatusCode::TOO_MANY_REQUESTS {
1939 return Some(Self::Primary);
1940 }
1941 // An exhausted budget *explains* a response that failed; it does not make one that
1942 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1943 // request the budget allowed as well as on the ones it then refuses, so reading
1944 // the header alone threw away a good answer — and, once refusals were retried,
1945 // replayed a request that had already taken effect.
1946 if !status.is_success() && budget_exhausted {
1947 return Some(Self::Primary);
1948 }
1949 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1950 // `errors` of an HTTP 200, where nothing about the status says so at all.
1951 if status.is_success()
1952 && PRIMARY_WORDINGS
1953 .iter()
1954 .any(|wording| normalized.contains(wording))
1955 {
1956 return Some(Self::Primary);
1957 }
1958 None
1959 }
1960
1961 /// What this limiter is called where an operator can look it up.
1962 const fn name(self) -> &'static str {
1963 match self {
1964 Self::Primary => "GitHub's primary API rate limit",
1965 Self::Secondary => "GitHub's secondary rate limit",
1966 }
1967 }
1968
1969 /// What the endpoint an operator would go and check says about this limiter.
1970 const fn where_to_look(self) -> &'static str {
1971 match self {
1972 Self::Primary => {
1973 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1974 comes back."
1975 }
1976 Self::Secondary => {
1977 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1978 primary budget and does not report this one, so budget showing there says \
1979 nothing about this refusal, and every further attempt extends it."
1980 }
1981 }
1982 }
1983
1984 /// The next step this limiter actually calls for.
1985 const fn what_to_do(self) -> &'static str {
1986 match self {
1987 Self::Primary => {
1988 "wait for the reset `gh api rate_limit` reports, then run the command again."
1989 }
1990 Self::Secondary => {
1991 "leave this board alone for a few minutes, then run the command again — or \
1992 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1993 }
1994 }
1995 }
1996}
1997
1998/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1999#[derive(Debug, Clone, Copy)]
2000struct Limited {
2001 limiter: Limiter,
2002 hint: Option<u64>,
2003}
2004
2005impl Limited {
2006 /// What the caller is told once this source has waited as long as it may.
2007 ///
2008 /// Both limiters report as [`SourceError::RateLimited`], because that is what
2009 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
2010 /// about *which* limiter it was makes it a different kind of failure. What differs is
2011 /// the operator's next step, and that is what the message carries — a secondary
2012 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
2013 /// budget looks fine, and then back to retry the very burst that was refused.
2014 fn exhausted(
2015 self,
2016 doing: &str,
2017 waits: u32,
2018 waited: Duration,
2019 needed: Duration,
2020 budget: Duration,
2021 ) -> SourceError {
2022 SourceError::RateLimited {
2023 retry_after_seconds: self.hint,
2024 message: Some(format!(
2025 "{} refused this source while {doing}; it waited {} out over {} and was refused \
2026 again, and the next wait of {} would take it past the {} one call may spend \
2027 waiting. {} next: {}",
2028 self.limiter.name(),
2029 plural(waits, "refusal"),
2030 seconds(waited),
2031 seconds(needed),
2032 seconds(budget),
2033 self.limiter.where_to_look(),
2034 self.limiter.what_to_do(),
2035 )),
2036 }
2037 }
2038}
2039
2040/// One HTTP attempt's result, with what its response said about the rate limit.
2041///
2042/// The two travel together so the record and the outcome are written from the same place:
2043/// what a response said about the budget is only readable while that response is in hand,
2044/// and what the attempt *meant* is only decidable once its body has been read.
2045struct Attempted {
2046 result: Result<Value, Attempt>,
2047 limits: accounting::RateLimit,
2048 /// GitHub's own reported cost for this call, for a document that asked for it.
2049 reported_cost: Option<u64>,
2050 response_outcome: accounting::ResponseOutcome,
2051}
2052
2053/// One attempt's outcome: an error to report, or a rate limit to wait out.
2054enum Attempt {
2055 Failed(SourceError),
2056 Limited(Limited),
2057}
2058
2059fn plural(count: u32, thing: &str) -> String {
2060 if count == 1 {
2061 format!("{count} {thing}")
2062 } else {
2063 format!("{count} {thing}s")
2064 }
2065}
2066
2067fn seconds(duration: Duration) -> String {
2068 format!("{:.1}s", duration.as_secs_f64())
2069}
2070
2071/// The member [`stamp_served`] writes onto every node of a response that says when it last
2072/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
2073const SERVED_AT: &str = "onetaskgraphServedAt";
2074
2075/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
2076/// node's time later knows the server's clock on the response that read it — which is what
2077/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
2078fn stamp_served(data: &mut Value, served: &str) {
2079 match data {
2080 Value::Object(members) => {
2081 if members.contains_key("updatedAt") {
2082 members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
2083 }
2084 for member in members.values_mut() {
2085 stamp_served(member, served);
2086 }
2087 }
2088 Value::Array(items) => {
2089 for item in items {
2090 stamp_served(item, served);
2091 }
2092 }
2093 _ => {}
2094 }
2095}
2096
2097/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
2098///
2099/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
2100/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
2101/// header, and neither is what makes a response a refusal — so the whole cost of one this
2102/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
2103/// instead. Refusing the response over the header would turn a readable refusal into an
2104/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
2105fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
2106 value
2107 .and_then(|value| value.to_str().ok())
2108 .and_then(|value| value.trim().parse::<u64>().ok())
2109}
2110
2111/// Every mutation this source sends creates content — an issue, a board item, a field of
2112/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
2113/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
2114/// and what the keyword says are the same set. That is what makes the keyword a sound test
2115/// rather than a convenient one: pacing an edit or a removal the limiter might not have
2116/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
2117fn is_mutation(query: &str) -> bool {
2118 query.trim_start().starts_with("mutation")
2119}
2120
2121/// What this source was doing, for a diagnostic that has to say so.
2122///
2123/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
2124/// a document added without a description is caught by that list's own gate instead of
2125/// falling through to the vague arm below.
2126fn operation_description(query: &str) -> &'static str {
2127 graphql::DOCUMENTS
2128 .iter()
2129 .find(|(document, _)| *document == query)
2130 .map_or("talking to GitHub", |(_, doing)| *doing)
2131}
2132
2133/// GitHub's published ceiling on content-generating requests, per minute.
2134///
2135/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
2136/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
2137/// from it, so a pacing value checked only against itself cannot go stale here.
2138pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
2139/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
2140/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
2141/// governs; it is pinned beside its sibling so the gate would notice either one moving.
2142pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
2143/// Shortest interval between two content-creating mutations, in milliseconds.
2144///
2145/// GitHub documents two secondary limits on content-generating requests:
2146/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
2147/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
2148/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
2149/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
2150/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
2151/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
2152/// deliberately *not* what this paces at. An installation that wants the hourly bound
2153/// honoured for a long sequence of copies says so through
2154/// `pacing.min_mutation_interval_ms`.
2155pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
2156/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
2157///
2158/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
2159/// own advice for a secondary limit — wait, and wait longer each time — without spending
2160/// the first minute of a transient refusal doing nothing.
2161pub const RETRY_BACKOFF_MS: u64 = 1_000;
2162/// Total time one call may spend waiting out rate limits before it reports a failure.
2163///
2164/// Two minutes is long enough to ride out the refusals a paced copy still collects and
2165/// short enough that a command an operator is watching returns. The bound is what makes
2166/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
2167/// the limiter, not in a process nobody can tell from a wedged one.
2168pub const RETRY_BUDGET_MS: u64 = 120_000;
2169
2170fn default_token_env() -> String {
2171 "GH_PROJECTS_TOKEN".to_owned()
2172}
2173fn default_endpoint() -> String {
2174 "https://api.github.com/graphql".to_owned()
2175}
2176
2177/// The name of a `Status` single-select option on the board.
2178///
2179/// Validated on the way in rather than checked later, so a blank option name — which
2180/// nothing on a board can be — is a state this type cannot hold.
2181#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2182#[serde(try_from = "String")]
2183#[schemars(extend("minLength" = 1))]
2184pub struct ColumnName(String);
2185
2186impl ColumnName {
2187 /// The option name, as the board spells it.
2188 fn as_str(&self) -> &str {
2189 &self.0
2190 }
2191}
2192
2193impl TryFrom<String> for ColumnName {
2194 type Error = String;
2195
2196 fn try_from(name: String) -> Result<Self, Self::Error> {
2197 if name.trim().is_empty() {
2198 return Err("a status_mapping option name cannot be blank".to_owned());
2199 }
2200 Ok(Self(name))
2201 }
2202}
2203
2204/// The two closed states this product can mean.
2205///
2206/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
2207/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
2208/// product never decides on a person's behalf that two issues are the same work.
2209#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
2210#[serde(rename_all = "kebab-case")]
2211pub enum ClosedState {
2212 /// `COMPLETED` — precisely done.
2213 Completed,
2214 /// `NOT_PLANNED` — precisely cancelled.
2215 NotPlanned,
2216}
2217
2218impl ClosedState {
2219 const fn reason(self) -> &'static str {
2220 match self {
2221 Self::Completed => "COMPLETED",
2222 Self::NotPlanned => "NOT_PLANNED",
2223 }
2224 }
2225}
2226
2227/// Configuration for one GitHub Projects v2 board.
2228///
2229/// It serializes to a document this same type reads back as itself, so a caller writing a
2230/// configuration — a consumer filing onto a board it configures — can build one here rather
2231/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
2232#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2233#[serde(default, deny_unknown_fields)]
2234pub struct GitHubProjectsConfig {
2235 /// Login of the user or organization which owns the board.
2236 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
2237 /// The project number shown in the board's GitHub URL.
2238 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
2239 // 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.
2240 /// `owner/name` of the repository this source creates an issue in when the item's own
2241 /// `repositories` field does not decide it.
2242 ///
2243 /// An item naming exactly one repository is created there; a task or a document naming
2244 /// none or several is created in its parent project's repository; and a project, or a
2245 /// task or document with no parent, naming none or several is created here. A board
2246 /// has no repository of its own and `createIssue` requires one, so a write without
2247 /// this is refused naming the field. Reads never need it.
2248 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
2249 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2250 /// Environment variable containing a fine-grained token with Projects and Issues
2251 /// read/write plus Pull requests read-only access for every repository represented on
2252 /// the board.
2253 #[serde(default = "default_token_env")]
2254 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
2255 /// GraphQL endpoint. GitHub Enterprise installations may override it.
2256 #[serde(default = "default_endpoint")]
2257 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
2258 /// Per-instance mapping from a status category to the option of the board's one
2259 /// `Status` field it lands on, for a task and for a project.
2260 ///
2261 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
2262 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
2263 /// where a kind left out leaves the category unmapped for that kind. A category this
2264 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
2265 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
2266 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
2267 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
2268 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
2269 /// either kind. No two categories may name one option for the same kind, ignoring case.
2270 /// `unknown` may name one existing option; every unknown word then lands on it and
2271 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
2272 /// each unknown word because it never creates board options.
2273 #[serde(default)]
2274 pub status_mapping: StatusMapping,
2275 /// Per-instance mapping from a task's priority to an option of this board's
2276 /// single-select field named `Priority`.
2277 ///
2278 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
2279 /// other priority is refused before it reaches this board. Present, each of `urgent`,
2280 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
2281 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
2282 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
2283 /// no two levels may name one option. Reads and writes never create the field or an
2284 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
2285 /// the board lacks is refused pointing there.
2286 #[serde(default)]
2287 pub priority_mapping: Option<PriorityMappingConfig>,
2288 /// Caller metadata values this source also writes to board text fields, so the board can
2289 /// filter on them.
2290 ///
2291 /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
2292 /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
2293 /// value. On every item write — create, update and copy, of a task, a project or a
2294 /// document — a string found there is written to the field, and nothing is sent when the
2295 /// field already holds it; nothing there, or `null`, clears the field when it holds a
2296 /// value; any other JSON type is refused before any mutation. The metadata comment in the
2297 /// issue body stays the one home of the value, and every read reports metadata from it.
2298 /// Reads and writes never create the field: `onetaskgraph sources fields <source>
2299 /// --apply` does, as a text field, and a write to a board lacking it, or holding a
2300 /// non-text field of that name, is refused pointing there.
2301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2302 // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
2303 // list out, so both SDKs model an absent list as an empty one rather than as `null`.
2304 #[schemars(!skip_serializing_if)]
2305 pub metadata_fields: Vec<MetadataFieldConfig>,
2306 /// How fast this source writes, and how long it waits out a rate-limit refusal.
2307 ///
2308 /// Every field keeps its shipped default when it is absent, and the defaults are
2309 /// GitHub's own published limits rather than taste. See [`Pacing`].
2310 #[serde(default)]
2311 pub pacing: PacingConfig,
2312}
2313
2314/// Which option of the board's `Priority` field each priority lands on.
2315///
2316/// One member per level rather than a map, so a key that is not a level is refused where
2317/// the configuration is read, naming the levels there are. `none` is not a member: it is no
2318/// value in the field, not an option of it.
2319#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2320#[serde(default, deny_unknown_fields)]
2321pub struct PriorityMappingConfig {
2322 /// The option `urgent` lands on; `Urgent` when absent.
2323 pub urgent: Option<PriorityOptionName>,
2324 /// The option `high` lands on; `High` when absent.
2325 pub high: Option<PriorityOptionName>,
2326 /// The option `medium` lands on; `Medium` when absent.
2327 pub medium: Option<PriorityOptionName>,
2328 /// The option `low` lands on; `Low` when absent.
2329 pub low: Option<PriorityOptionName>,
2330}
2331
2332/// The name of an option of the board's `Priority` single-select field.
2333///
2334/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
2335/// blank name.
2336#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2337#[serde(try_from = "String")]
2338#[schemars(extend("minLength" = 1))]
2339pub struct PriorityOptionName(String);
2340
2341impl PriorityOptionName {
2342 /// The option name, as the board spells it.
2343 fn as_str(&self) -> &str {
2344 &self.0
2345 }
2346}
2347
2348impl TryFrom<String> for PriorityOptionName {
2349 type Error = String;
2350
2351 fn try_from(name: String) -> Result<Self, Self::Error> {
2352 if name.trim().is_empty() {
2353 return Err("a priority_mapping option name cannot be blank".to_owned());
2354 }
2355 Ok(Self(name))
2356 }
2357}
2358
2359/// The name of the board field a priority is held in.
2360pub const PRIORITY_FIELD: &str = "Priority";
2361
2362/// The four priorities a board option can hold, in the order a new `Priority` field lists
2363/// them. `none` is not among them: it is the field holding no value.
2364///
2365/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
2366/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
2367/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
2368/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
2369pub const PRIORITY_LEVELS: [Priority; 4] = [
2370 Priority::Urgent,
2371 Priority::High,
2372 Priority::Medium,
2373 Priority::Low,
2374];
2375
2376/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
2377/// see that list for what this pins.
2378#[must_use]
2379pub const fn level_position(priority: Priority) -> Option<usize> {
2380 match priority {
2381 Priority::None => None,
2382 Priority::Urgent => Some(0),
2383 Priority::High => Some(1),
2384 Priority::Medium => Some(2),
2385 Priority::Low => Some(3),
2386 }
2387}
2388
2389/// This instance's complete priority-to-option mapping, read in both directions.
2390///
2391/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
2392/// two levels name one option.
2393#[derive(Debug, Clone)]
2394struct PriorityMapping {
2395 options: [PriorityOptionName; 4],
2396}
2397
2398impl PriorityMapping {
2399 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2400 let shipped = |name: &str| PriorityOptionName(name.to_owned());
2401 let mapping = Self {
2402 options: [
2403 config.urgent.unwrap_or_else(|| shipped("Urgent")),
2404 config.high.unwrap_or_else(|| shipped("High")),
2405 config.medium.unwrap_or_else(|| shipped("Medium")),
2406 config.low.unwrap_or_else(|| shipped("Low")),
2407 ],
2408 };
2409 for (index, option) in mapping.options.iter().enumerate() {
2410 if let Some(earlier) = mapping.options[..index]
2411 .iter()
2412 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
2413 {
2414 return Err(SourceError::Config {
2415 message: format!(
2416 "priority_mapping of source {instance} sends both {} and {} to the board \
2417 option {:?}; one option cannot read back as two priorities",
2418 PRIORITY_LEVELS[earlier],
2419 PRIORITY_LEVELS[index],
2420 option.as_str()
2421 ),
2422 });
2423 }
2424 }
2425 Ok(mapping)
2426 }
2427
2428 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
2429 fn option(&self, priority: Priority) -> Option<&str> {
2430 level_position(priority).map(|index| self.options[index].as_str())
2431 }
2432
2433 /// The priority a board option name reports, or `None` when nothing maps to it.
2434 fn priority_of(&self, option: &str) -> Option<Priority> {
2435 self.options
2436 .iter()
2437 .position(|name| name.as_str().eq_ignore_ascii_case(option))
2438 .map(|index| PRIORITY_LEVELS[index])
2439 }
2440
2441 /// Every mapped option name, in the order a new `Priority` field lists them.
2442 fn names(&self) -> impl Iterator<Item = &str> {
2443 self.options.iter().map(PriorityOptionName::as_str)
2444 }
2445}
2446
2447/// One metadata value this source projects onto a board text field.
2448#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2449#[serde(deny_unknown_fields)]
2450pub struct MetadataFieldConfig {
2451 /// The name of the board text field the value is written to.
2452 pub field: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `MetadataField::resolve` refuses a blank, reserved, GitHub-owned or repeated name before the private validated entry is built.
2453 /// The top-level metadata key the value is read from. A key under the reserved
2454 /// `onetaskgraph.` prefix is refused.
2455 pub key: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `MetadataField::resolve` refuses a blank or reserved key before the private validated entry is built.
2456 /// Object keys walked inside that key's value, outermost first. Absent or empty, the
2457 /// key's own value is the one projected. Left out of a serialized entry when empty.
2458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2459 // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
2460 #[schemars(!skip_serializing_if)]
2461 pub path: Vec<String>,
2462}
2463
2464/// The board fields no metadata value may be projected onto, compared ignoring case: the
2465/// three this source writes itself, and those GitHub owns on every board.
2466// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] This list is the configuration contract `metadata_fields`' consumers agreed, refused at read so a mistaken entry is named early — not a mirror GitHub's field set has to stay in step with. What keeps a write off a field GitHub owns is the write's own check, `projection_field`, which refuses any field whose `dataType` is not `TEXT`: every built-in board field reports its own type (TITLE, ASSIGNEES, LABELS, …), and the live contract introspection holds GitHub to `ProjectV2Field.dataType`. So a built-in field GitHub adds later and this list lacks is still never written.
2467const UNPROJECTABLE_FIELDS: [&str; 13] = [
2468 STATUS_FIELD,
2469 PRIORITY_FIELD,
2470 ORIGIN_FIELD,
2471 "Title",
2472 "Assignees",
2473 "Labels",
2474 "Linked pull requests",
2475 "Milestone",
2476 "Repository",
2477 "Reviewers",
2478 "Parent issue",
2479 "Sub-issues progress",
2480 "Type",
2481];
2482
2483/// One validated [`MetadataFieldConfig`] entry.
2484#[derive(Debug, Clone)]
2485struct MetadataField {
2486 field: ProjectedField,
2487 key: ProjectedKey,
2488 path: Vec<String>,
2489}
2490
2491/// The name of a board text field a metadata value is projected onto: never blank, never one
2492/// this source or GitHub writes, and never one another entry of the same source names.
2493#[derive(Debug, Clone, PartialEq, Eq)]
2494struct ProjectedField(String);
2495
2496/// The top-level metadata key a projected value is read from: never blank, and never under
2497/// the namespace this product reserves.
2498#[derive(Debug, Clone, PartialEq, Eq)]
2499struct ProjectedKey(String);
2500
2501impl std::ops::Deref for ProjectedField {
2502 type Target = str;
2503 fn deref(&self) -> &str {
2504 &self.0
2505 }
2506}
2507
2508impl std::ops::Deref for ProjectedKey {
2509 type Target = str;
2510 fn deref(&self) -> &str {
2511 &self.0
2512 }
2513}
2514
2515/// What one item's metadata says one projected field should hold.
2516#[derive(Debug, Clone, PartialEq, Eq)]
2517enum Projected {
2518 /// A string to hold.
2519 Text(String),
2520 /// Nothing: the key or a step of the path is absent, or the value is `null`.
2521 Nothing,
2522}
2523
2524impl MetadataField {
2525 /// Validate every entry of one instance's `metadata_fields`, refusing the first that
2526 /// cannot stand, naming the source and the entry.
2527 fn resolve(
2528 configured: Vec<MetadataFieldConfig>,
2529 instance: &SourceName,
2530 ) -> Result<Vec<Self>, SourceError> {
2531 let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
2532 for (index, entry) in configured.into_iter().enumerate() {
2533 let refuse = |why: String| SourceError::Config {
2534 message: format!(
2535 "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
2536 entry.field, entry.key
2537 ),
2538 };
2539 if entry.field.trim().is_empty() {
2540 return Err(refuse("names a blank field".to_owned()));
2541 }
2542 if entry.key.trim().is_empty() {
2543 return Err(refuse("names a blank key".to_owned()));
2544 }
2545 if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
2546 return Err(refuse(format!(
2547 "names a key under the reserved \"{}.\" prefix, which this product keeps \
2548 for its own keys; next: project a caller key instead",
2549 MetadataKey::RESERVED_NAMESPACE
2550 )));
2551 }
2552 if let Some(owned) = UNPROJECTABLE_FIELDS
2553 .iter()
2554 .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
2555 {
2556 return Err(refuse(format!(
2557 "names the board field {owned:?}, which this source or GitHub already \
2558 writes; next: name a text field of your own"
2559 )));
2560 }
2561 if let Some(earlier) = resolved
2562 .iter()
2563 .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
2564 {
2565 return Err(refuse(format!(
2566 "names the same board field as metadata_fields[{earlier}]; one field \
2567 cannot hold two values"
2568 )));
2569 }
2570 resolved.push(Self {
2571 field: ProjectedField(entry.field),
2572 key: ProjectedKey(entry.key),
2573 path: entry.path,
2574 });
2575 }
2576 Ok(resolved)
2577 }
2578
2579 /// The path as a refusal spells it: `[]` for the key's own value.
2580 fn spelled_path(&self) -> String {
2581 serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
2582 }
2583
2584 /// What `metadata` says this field should hold, or the refusal for a value no text field
2585 /// can hold.
2586 fn projected(
2587 &self,
2588 metadata: &BTreeMap<String, Value>,
2589 instance: &SourceName,
2590 ) -> Result<Projected, SourceError> {
2591 let mut value = metadata.get(&*self.key);
2592 for step in &self.path {
2593 value = value.and_then(|held| held.get(step.as_str()));
2594 }
2595 let found = match value {
2596 None | Some(Value::Null) => return Ok(Projected::Nothing),
2597 Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
2598 Some(Value::Bool(_)) => "a boolean",
2599 Some(Value::Number(_)) => "a number",
2600 Some(Value::Array(_)) => "an array",
2601 Some(Value::Object(_)) => "an object",
2602 };
2603 Err(SourceError::Refused {
2604 message: format!(
2605 "source {instance} projects metadata key {:?} at path {} onto its board text \
2606 field {:?}, and this item holds {found} there; only a string, null or nothing \
2607 can be written to a text field; next: store a string there, or remove the \
2608 value",
2609 &*self.key,
2610 self.spelled_path(),
2611 &*self.field
2612 ),
2613 })
2614 }
2615}
2616
2617/// What one item's `Priority` field says, read through this instance's mapping.
2618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
2619enum HeldPriority {
2620 /// A priority this source reports: an option the mapping names, or no value (`none`).
2621 Read(Priority),
2622 /// An option the mapping does not name, which is never read as a level or as `none`.
2623 Unmapped(String),
2624}
2625
2626/// How fast this source writes, and how long it waits out a rate-limit refusal.
2627///
2628/// Configurable because a GitHub Enterprise installation sets its own limits and an
2629/// operator who has already been refused may want to go slower still — not because the
2630/// defaults are guesses.
2631#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2632#[serde(default, deny_unknown_fields)]
2633pub struct PacingConfig {
2634 /// Shortest interval between two content-creating mutations, in milliseconds.
2635 ///
2636 /// Zero sends them as fast as they are asked for, which is what a fixture server on
2637 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
2638 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.
2639 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
2640 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
2641 /// zero while there is a budget to spend, because a schedule of zero-length waits
2642 /// consumes none of it and so never ends.
2643 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.
2644 /// Total time one call may spend waiting out rate limits, in milliseconds.
2645 ///
2646 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
2647 /// the bound is what makes this a wait rather than a hang.
2648 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.
2649}
2650
2651/// The largest any pacing setting may be, in milliseconds.
2652///
2653/// One hour. GitHub's own harshest published bound on content-generating requests works
2654/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
2655/// anything a real limit asks for, and past it the settings stop describing pacing at all:
2656/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
2657/// and an interval beyond it is a command that never sends its second mutation. It also
2658/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
2659/// what a `Duration` can hold on every platform.
2660pub const MAX_PACING_MS: u64 = 3_600_000;
2661
2662/// [`PacingConfig`] with every default resolved and every value checked, which is what the
2663/// source holds.
2664#[derive(Debug, Clone, Copy)]
2665struct Pacing {
2666 min_mutation_interval: Duration,
2667 retry_backoff: Duration,
2668 retry_budget: Duration,
2669}
2670
2671impl Pacing {
2672 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
2673 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2674 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
2675 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
2676 message: format!(
2677 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
2678 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
2679 GitHub's own harshest published limit"
2680 ),
2681 }),
2682 Some(value) => Ok(Duration::from_millis(value)),
2683 None => Ok(Duration::from_millis(default)),
2684 };
2685 let retry_backoff = bounded(
2686 config.retry_backoff_ms,
2687 RETRY_BACKOFF_MS,
2688 "retry_backoff_ms",
2689 )?;
2690 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
2691 if retry_backoff.is_zero() && !retry_budget.is_zero() {
2692 return Err(SourceError::Config {
2693 message: format!(
2694 "pacing.retry_backoff_ms of source {instance} is 0 while \
2695 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
2696 none of that budget, so it would retry a refusal forever. Set a backoff of \
2697 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
2698 waiting at all",
2699 retry_budget.as_millis()
2700 ),
2701 });
2702 }
2703 Ok(Self {
2704 min_mutation_interval: bounded(
2705 config.min_mutation_interval_ms,
2706 MIN_MUTATION_INTERVAL_MS,
2707 "min_mutation_interval_ms",
2708 )?,
2709 retry_backoff,
2710 retry_budget,
2711 })
2712 }
2713}
2714
2715/// Factory for [`GitHubProjectsSource`].
2716#[derive(Debug, Clone, Copy, Default)]
2717pub struct Plugin;
2718
2719impl SourcePlugin for Plugin {
2720 fn kind(&self) -> &'static str {
2721 KIND
2722 }
2723 /// The endpoint, the owner and the board's number: what names one board.
2724 fn remote_identity(&self, config: &Value) -> Option<String> {
2725 let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
2726 Some(format!(
2727 "{}\n{}\n{}",
2728 config.endpoint, config.owner, config.project_number
2729 ))
2730 }
2731 fn config_schema(&self) -> Schema {
2732 schema_for!(GitHubProjectsConfig)
2733 }
2734 fn build(
2735 &self,
2736 name: &SourceName,
2737 config: &Value,
2738 secrets: &dyn SecretResolver,
2739 ) -> Result<Box<dyn TaskSource>, SourceError> {
2740 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2741 }
2742
2743 fn build_with_clock(
2744 &self,
2745 name: &SourceName,
2746 config: &Value,
2747 secrets: &dyn SecretResolver,
2748 clock: SharedClock,
2749 ) -> Result<Box<dyn TaskSource>, SourceError> {
2750 self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2751 }
2752}
2753
2754impl Plugin {
2755 /// Build a source recording every request it sends into an accounting the caller holds.
2756 ///
2757 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2758 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2759 /// session total rather than two — see [`accounting`] and
2760 /// [`GitHubProjectsSource::recording_into`].
2761 ///
2762 /// # Errors
2763 ///
2764 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2765 /// [`SourceError::Config`] for configuration this plugin cannot use and
2766 /// [`SourceError::Auth`] for a credential it cannot find.
2767 pub fn build_recording_into(
2768 &self,
2769 name: &SourceName,
2770 config: &Value,
2771 secrets: &dyn SecretResolver,
2772 ledger: Arc<Accounting>,
2773 ) -> Result<Box<dyn TaskSource>, SourceError> {
2774 self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2775 }
2776
2777 fn build_recording_with_clock(
2778 &self,
2779 name: &SourceName,
2780 config: &Value,
2781 secrets: &dyn SecretResolver,
2782 ledger: Arc<Accounting>,
2783 clock: SharedClock,
2784 ) -> Result<Box<dyn TaskSource>, SourceError> {
2785 let config: GitHubProjectsConfig =
2786 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2787 message: format!("source {name}: {e}"),
2788 })?;
2789 let prefix = format!("source {name}: ");
2790 let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2791 .map_err(|error| match error {
2792 // The shared `StatusMapping::distinct` names the source itself.
2793 SourceError::Config { message } if message.starts_with(&prefix) => {
2794 SourceError::Config { message }
2795 }
2796 SourceError::Config { message } => SourceError::Config {
2797 message: format!("{prefix}{message}"),
2798 },
2799 SourceError::Auth { message } => SourceError::Auth {
2800 message: format!("source {name}: {message}"),
2801 },
2802 other => other,
2803 })?;
2804 source.clock = clock;
2805 Ok(Box::new(source))
2806 }
2807}
2808
2809/// Where a status category lands on this board, once configuration is resolved.
2810#[derive(Debug, Clone, PartialEq, Eq)]
2811enum StatusTarget {
2812 /// Not usable against this instance for this kind, and why.
2813 Disabled(UnmappedStatus),
2814 /// The board's `Status` option of this name.
2815 Column(ColumnName),
2816 /// A closed issue, with both its board option and the reason that says which closed it means.
2817 // 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.
2818 Terminal(ColumnName, ClosedState),
2819}
2820
2821/// Every status category, in the order the vocabulary declares them.
2822///
2823/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2824/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2825/// added to the shared vocabulary fails to compile until it is named there, and this
2826/// crate's suite reconciles this list against that enum's own derived schema, which is
2827/// generated from the variants rather than written beside them. The schema is what
2828/// catches a list left one short — a list checking only the positions it already holds
2829/// would pass while every mapping indexed by the new position panicked.
2830pub const CATEGORIES: [StatusCategory; 8] = [
2831 StatusCategory::Draft,
2832 StatusCategory::Backlog,
2833 StatusCategory::Todo,
2834 StatusCategory::Queued,
2835 StatusCategory::InProgress,
2836 StatusCategory::Done,
2837 StatusCategory::Cancelled,
2838 StatusCategory::Unknown,
2839];
2840
2841/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2842#[must_use]
2843pub const fn category_position(category: StatusCategory) -> usize {
2844 match category {
2845 StatusCategory::Draft => 0,
2846 StatusCategory::Backlog => 1,
2847 StatusCategory::Todo => 2,
2848 StatusCategory::Queued => 3,
2849 StatusCategory::InProgress => 4,
2850 StatusCategory::Done => 5,
2851 StatusCategory::Cancelled => 6,
2852 StatusCategory::Unknown => 7,
2853 }
2854}
2855
2856/// The spelling a status category is configured and reported under.
2857fn category_name(category: StatusCategory) -> &'static str {
2858 match category {
2859 StatusCategory::Draft => "draft",
2860 StatusCategory::Backlog => "backlog",
2861 StatusCategory::Todo => "todo",
2862 StatusCategory::Queued => "queued",
2863 StatusCategory::InProgress => "in-progress",
2864 StatusCategory::Done => "done",
2865 StatusCategory::Cancelled => "cancelled",
2866 StatusCategory::Unknown => "unknown",
2867 }
2868}
2869
2870/// A shipped default's option name.
2871///
2872/// The literals below are this file's own and non-blank, and they are validated by the
2873/// one constructor a configured name goes through rather than beside it.
2874fn shipped_column(name: &'static str) -> ColumnName {
2875 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2876}
2877
2878/// The shipped default for one category this instance's `status_mapping` does not mention,
2879/// for either kind.
2880fn shipped_default(category: StatusCategory) -> StatusTarget {
2881 match category {
2882 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2883 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2884 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2885 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2886 StatusCategory::Done => {
2887 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2888 }
2889 StatusCategory::Cancelled => {
2890 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2891 }
2892 StatusCategory::Draft | StatusCategory::Unknown => {
2893 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2894 }
2895 }
2896}
2897
2898/// The two kinds a status is written and read for, each with its own half of the mapping.
2899const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2900
2901/// This instance's complete category-to-target mapping for each kind, read in both
2902/// directions.
2903///
2904/// One target per category per kind, held at that category's own [`category_position`], so
2905/// a category missing from the mapping, named twice in it, or filed out of order is a state
2906/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2907/// targets are options of the board's one `Status` field.
2908#[derive(Debug, Clone)]
2909struct BoardStatuses {
2910 tasks: [StatusTarget; CATEGORIES.len()],
2911 projects: [StatusTarget; CATEGORIES.len()],
2912}
2913
2914impl BoardStatuses {
2915 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2916 /// would read back from one option.
2917 ///
2918 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2919 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2920 /// omits unmapped rather than defaulted.
2921 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2922 let resolve_kind =
2923 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2924 // `CATEGORIES[position] == category` for every category — the crate's suite
2925 // asserts it — so mapping the list in order fills each category's own slot.
2926 let mut targets = CATEGORIES.map(shipped_default);
2927 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2928 if !configured.mentions(category) {
2929 continue;
2930 }
2931 *slot = match configured.name_for(category, kind) {
2932 Err(why) => StatusTarget::Disabled(why),
2933 Ok(name) => {
2934 let option = ColumnName::try_from(name.as_str().to_owned())
2935 .map_err(|message| SourceError::Config { message })?;
2936 match category {
2937 StatusCategory::Done => {
2938 StatusTarget::Terminal(option, ClosedState::Completed)
2939 }
2940 StatusCategory::Cancelled => {
2941 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2942 }
2943 _ => StatusTarget::Column(option),
2944 }
2945 }
2946 };
2947 }
2948 StatusMapping::distinct(
2949 instance,
2950 kind,
2951 CATEGORIES
2952 .iter()
2953 .zip(&targets)
2954 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2955 )?;
2956 Ok(targets)
2957 };
2958 Ok(Self {
2959 tasks: resolve_kind(ItemKind::Task)?,
2960 projects: resolve_kind(ItemKind::Project)?,
2961 })
2962 }
2963
2964 /// Every category's target for `kind`, in category order.
2965 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2966 match kind {
2967 ItemKind::Task => &self.tasks,
2968 ItemKind::Project => &self.projects,
2969 }
2970 }
2971
2972 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2973 &self.targets(kind)[category_position(category)]
2974 }
2975
2976 /// The category a board option name reports for `kind`, or `None` when nothing of that
2977 /// kind maps to it.
2978 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2979 CATEGORIES.into_iter().find(|category| {
2980 self.target(kind, *category)
2981 .option()
2982 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2983 })
2984 }
2985
2986 /// Every option name either kind maps a category to, each once ignoring case, in
2987 /// category order with a task's name before a project's — what the guarded setup asks
2988 /// the `Status` field to hold.
2989 fn wanted(&self) -> Vec<String> {
2990 let mut wanted: Vec<String> = Vec::new();
2991 for category in CATEGORIES {
2992 for kind in STATUS_KINDS {
2993 if let Some(name) = self.target(kind, category).option()
2994 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2995 {
2996 wanted.push(name.to_owned());
2997 }
2998 }
2999 }
3000 wanted
3001 }
3002
3003 /// The status an item of `kind` reports, from the three things a read of it says: its
3004 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
3005 ///
3006 /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
3007 /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
3008 /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
3009 /// produce, so it is read permissively rather than refused — reads are faithful, and
3010 /// refusals belong on writes.
3011 ///
3012 /// **Its name follows the closure too, unless the option agrees with it.** The option's
3013 /// own name is kept when this kind's mapping reads that option as the same category, or
3014 /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
3015 /// named `Shipped`. An option the mapping reads as another category contradicts the
3016 /// closure, and so does no option at all: the name is then the option the mapping gives
3017 /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
3018 /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
3019 /// it. An `unknown` closure keeps the option, or `Closed` without one.
3020 ///
3021 /// An open item's option reads through its own kind's mapping, and an option that mapping
3022 /// does not name reads as `Unknown` under its own name.
3023 ///
3024 /// One function of those three rather than of a response, so a narrow status write can
3025 /// answer what a re-read would report by applying it to the state it has just written.
3026 fn status(
3027 &self,
3028 kind: ItemKind,
3029 option: Option<&str>,
3030 closed: bool,
3031 reason: Option<&str>,
3032 ) -> Status {
3033 if closed {
3034 let category = closed_category(reason);
3035 let agrees = option.filter(|option| {
3036 category == StatusCategory::Unknown
3037 || self
3038 .category_of(kind, option)
3039 .is_none_or(|read| read == category)
3040 });
3041 let name = agrees.or_else(|| self.target(kind, category).option());
3042 let fallback = match category {
3043 StatusCategory::Done => "Done",
3044 StatusCategory::Cancelled => "Cancelled",
3045 _ => "Closed",
3046 };
3047 return Status {
3048 category,
3049 name: name.or(option).unwrap_or(fallback).to_owned(),
3050 };
3051 }
3052 let name = option.unwrap_or("Open").to_owned();
3053 Status {
3054 category: self
3055 .category_of(kind, &name)
3056 .unwrap_or(StatusCategory::Unknown),
3057 name,
3058 }
3059 }
3060
3061 /// The option a closed item's `Status` has to hold for its card to agree with its
3062 /// closure, and the category that closure reads as — or `None` when the item is open,
3063 /// its closure reads `unknown`, or its option already agrees.
3064 ///
3065 /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
3066 /// in line, each to the one option the mapping gives its category — an item with no
3067 /// option, or at one the mapping does not name, included.
3068 fn contradicted(
3069 &self,
3070 kind: ItemKind,
3071 option: Option<&str>,
3072 closed: bool,
3073 reason: Option<&str>,
3074 ) -> Option<(ClosedAs, &str)> {
3075 if !closed {
3076 return None;
3077 }
3078 let closed_as = match closed_category(reason) {
3079 StatusCategory::Done => ClosedAs::Done,
3080 StatusCategory::Cancelled => ClosedAs::Cancelled,
3081 _ => return None,
3082 };
3083 let wanted = self.target(kind, closed_as.category()).option()?;
3084 if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
3085 return None;
3086 }
3087 Some((closed_as, wanted))
3088 }
3089}
3090
3091/// The category a closed issue reads as, from the reason it was closed with.
3092///
3093/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
3094/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
3095/// is one this source cannot read a category from.
3096fn closed_category(reason: Option<&str>) -> StatusCategory {
3097 match reason {
3098 None | Some("COMPLETED") => StatusCategory::Done,
3099 Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
3100 Some(_) => StatusCategory::Unknown,
3101 }
3102}
3103
3104impl BoardStatuses {
3105 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
3106 /// case; a kind lacking none is left out.
3107 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
3108 STATUS_KINDS
3109 .into_iter()
3110 .filter_map(|kind| {
3111 let missing: Vec<String> = self
3112 .targets(kind)
3113 .iter()
3114 .filter_map(StatusTarget::option)
3115 .filter(|wanted| {
3116 !existing
3117 .iter()
3118 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3119 })
3120 .map(str::to_owned)
3121 .collect();
3122 (!missing.is_empty()).then_some(KindMissing { kind, missing })
3123 })
3124 .collect()
3125 }
3126}
3127
3128impl StatusTarget {
3129 /// The board option this target selects, or `None` for an unmapped one.
3130 fn option(&self) -> Option<&str> {
3131 match self {
3132 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
3133 Self::Disabled(_) => None,
3134 }
3135 }
3136}
3137
3138// 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.
3139/// One repository this source can create an issue in, as `owner/name`.
3140///
3141/// Every `createIssue` this source sends names one of these: the item's own single
3142/// `repositories` entry, else its parent project issue's repository, else the configured
3143/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
3144/// that choice and says what it refuses before `createIssue`.
3145// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
3146#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3147struct RepositoryTarget {
3148 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3149 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
3150}
3151
3152impl RepositoryTarget {
3153 fn parse(value: &str) -> Result<Self, SourceError> {
3154 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
3155 message: format!(
3156 "repository must be spelled owner/name; {value:?} names no repository"
3157 ),
3158 })?;
3159 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
3160 return Err(SourceError::Config {
3161 message: format!(
3162 "repository must be spelled owner/name with a GitHub login and one \
3163 repository name; {value:?} is not"
3164 ),
3165 });
3166 }
3167 Ok(Self {
3168 owner: owner.to_owned(),
3169 name: name.to_owned(),
3170 })
3171 }
3172
3173 /// The one host whose repositories this source creates issues in, spelled once: it is
3174 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
3175 const HOST: &str = "github.com";
3176
3177 fn origin(&self) -> String {
3178 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
3179 }
3180
3181 /// The repository a normalized origin names, or why it is none this source can create
3182 /// an issue in: another host, or more or fewer than `owner/name` under this one.
3183 fn from_origin(origin: &Repository) -> Result<Self, String> {
3184 let not_here = || {
3185 format!(
3186 "{} is not a {}/owner/name repository",
3187 origin.as_str(),
3188 Self::HOST
3189 )
3190 };
3191 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
3192 if host != Self::HOST {
3193 return Err(not_here());
3194 }
3195 Self::parse(rest).map_err(|_| not_here())
3196 }
3197
3198 fn slug(&self) -> String {
3199 format!("{}/{}", self.owner, self.name)
3200 }
3201}
3202
3203/// A source which reads GitHub afresh for every operation.
3204pub struct GitHubProjectsSource {
3205 /// This source's configured name, used both to tell a far end naming this source
3206 /// from one naming a system it knows nothing about, and to name the instance a
3207 /// status refusal is about.
3208 name: SourceName,
3209 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
3210 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
3211 repository: Option<RepositoryTarget>,
3212 endpoint: Url,
3213 token: SecretString,
3214 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
3215 statuses: BoardStatuses,
3216 /// Where each priority lands on this board, or `None` when this instance holds none.
3217 priorities: Option<PriorityMapping>,
3218 /// The metadata values this instance projects onto board text fields, in configured order.
3219 metadata_fields: Vec<MetadataField>,
3220 client: Client,
3221 asset_client: Client,
3222 /// Every item this source has created in this command, in the order it created them —
3223 /// dropped by [`TaskSource::end_command`].
3224 ///
3225 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
3226 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
3227 /// a copy resolving a dependency on an item it had just created refused it as not
3228 /// found. A board read is completed from this — an item remembered here and absent from
3229 /// the read is added back, because the board really does hold it and only the read is
3230 /// behind.
3231 ///
3232 /// It is not a cache of a user's work: nothing is remembered that this process did not
3233 /// itself just write, it lives and dies with the process, and it is never consulted for
3234 /// an item this source did not create.
3235 created: Mutex<Vec<Resolved>>,
3236 /// Every item that already existed and that this source has written in this command, as
3237 /// it wrote it — dropped by [`TaskSource::end_command`].
3238 ///
3239 /// The other half of [`Self::created`], held on the same terms and for the reason a
3240 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
3241 /// filter is an index behind a write this process made moments ago, so a query matching
3242 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
3243 /// is remembered that this process did not itself just write.
3244 updated: Mutex<Vec<Resolved>>,
3245 /// Every issue this source has added a comment to or edited a comment of in this command
3246 /// — dropped by [`TaskSource::end_command`].
3247 ///
3248 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
3249 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
3250 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
3251 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
3252 /// from before — until the index caught up. Each id here is a candidate of every such
3253 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
3254 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
3255 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
3256 /// process wrote is still found only once the index has it.
3257 commented: Mutex<Vec<NativeId>>,
3258 /// How fast this source writes, and how long it waits out a refusal.
3259 pacing: Pacing,
3260 /// When the last content-creating mutation finished, or the moment the furthest-out
3261 /// reserved slot releases the next one, whichever is later — so the one after it can be
3262 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
3263 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
3264 /// what it is measured from.
3265 last_mutation: Mutex<Option<Duration>>,
3266 clock: SharedClock,
3267 numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
3268 /// The board as this process last read it, for the length of one command — dropped by
3269 /// [`TaskSource::end_command`].
3270 ///
3271 /// A copy of a project used to re-read the whole board, paged, before writing each of
3272 /// its items, which is by far the largest part of a copy's request count and none of
3273 /// its work. Nothing else changes this board while a command runs — this source's own
3274 /// writes are the only writer — so one read answers them all.
3275 ///
3276 /// It is not a store of a user's work and it is not the cache the no-persistence
3277 /// invariant forbids: it lives and dies with the process exactly as `created` does,
3278 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
3279 /// an item this command created and then depends on resolves whether or not GitHub's
3280 /// own eventually-consistent read has caught up. A write to an item already on the
3281 /// board updates the entry here too, so what this holds is the last read plus this
3282 /// process's own writes rather than a snapshot taken before them.
3283 board_cache: Mutex<Option<Board>>,
3284 /// Every issue this board's own search reported, for the length of one command — dropped
3285 /// by [`TaskSource::end_command`].
3286 ///
3287 /// The second half of a board read, and cached for the same reason and on the same
3288 /// terms as the first: it lives and dies with the process, nothing is written down, and
3289 /// a write this process makes updates the entry here exactly as it updates the one in
3290 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
3291 /// that lists this board's projects and its tasks pays for one search rather than two.
3292 search_cache: Mutex<Option<Vec<Resolved>>>,
3293 /// What each narrowed question GitHub was asked answered, keyed by that question, for
3294 /// the length of one command — dropped by [`TaskSource::end_command`].
3295 ///
3296 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
3297 /// and dies with the process, nothing is written down, a write this process makes
3298 /// updates the entry here as it updates the other two, and every answer is completed
3299 /// with this process's own writes each time it is given. A command that asks the same
3300 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
3301 /// write — pays for it once, which is what the whole-board read it replaced gave it.
3302 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
3303 search_next: Mutex<BTreeMap<String, Option<String>>>,
3304 /// Records already resolved in this command, reused by writes and for comment identity.
3305 /// Explicit item reads still reach GitHub. Nothing is persisted, and
3306 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
3307 /// its item as a person has since left it.
3308 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3309 /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
3310 /// answered as holding nothing — each taken by the next read of that item by id and by
3311 /// nothing else, dropped by any mutation naming it, and cleared by
3312 /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
3313 /// read-before-write, made in the same request as the others' rather than on its own.
3314 read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
3315 /// Every item a listing of one project's members carried whole in this command — the
3316 /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
3317 /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
3318 /// it again. No other read is answered from it, so a read by id outside a copy's
3319 /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
3320 /// item, and cleared by [`TaskSource::end_command`].
3321 carried: Mutex<BTreeMap<NativeId, Resolved>>,
3322 /// Items an issue read by reference reached, held for the one status write each was read
3323 /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
3324 /// what such a write needs. Dropped by [`TaskSource::end_command`].
3325 held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3326 /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
3327 /// asked for under, with the project that selector named — for the length of one command,
3328 /// dropped by [`TaskSource::end_command`].
3329 ///
3330 /// A caller pages through a project's tasks one engine page at a time, and every page
3331 /// is cut from the whole list of them, so without this each page walked every
3332 /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
3333 /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
3334 /// dies with the process, nothing is written down, a write this process makes updates or
3335 /// removes the entry here as it does there, and every answer is completed with this
3336 /// process's own writes each time it is given.
3337 children_cache: Mutex<ProjectChildren>,
3338 /// The board's own id and field definitions as this process last read them on their
3339 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
3340 ///
3341 /// What a write needs of the board and its item does not say, read once per command
3342 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
3343 /// lives and dies with the process and nothing is written down. It holds no item and so
3344 /// can answer no question about one — see [`Self::board_fields`].
3345 fields_cache: Mutex<Option<BoardFields>>,
3346 /// Each destination repository's node id, resolved once per repository
3347 /// rather than per issue created.
3348 ///
3349 /// A repository's node id does not change, and re-reading it for every issue of a copy
3350 /// spent one request per item on an answer this source already had. It is a map rather
3351 /// than one entry because a copy files each item in the repository its own
3352 /// `repositories` field names, so a plan across five repositories asks GitHub five
3353 /// times and not once per item.
3354 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
3355 /// What every request this source sends is recorded into.
3356 ///
3357 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
3358 /// a request leaves this crate, so nothing has to be switched on for a session to be
3359 /// counted. It is shared rather than owned so a caller accounting for a whole session —
3360 /// its own schema verification, board lookups, residue sweep and cleanup beside this
3361 /// source's reads and writes — adds up one accounting instead of two. See
3362 /// [`accounting`] for what a record carries and what a session's spend is and is not.
3363 ledger: Arc<Accounting>,
3364 /// What [`expected`] holds for a write the store's cache expects: the journal while one is
3365 /// open, the issues primed from the cache, and the comments known on each.
3366 expected: expected::Held,
3367}
3368
3369/// GitHub's closed single-select color vocabulary.
3370#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3371#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
3372pub enum StatusOptionColor {
3373 /// Gray.
3374 Gray,
3375 /// Blue.
3376 Blue,
3377 /// Green.
3378 Green,
3379 /// Yellow.
3380 Yellow,
3381 /// Purple.
3382 Purple,
3383 /// Red.
3384 Red,
3385 /// Orange.
3386 Orange,
3387 /// Pink.
3388 Pink,
3389}
3390
3391/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
3392/// applies its additions.
3393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3394pub enum SetupMode {
3395 /// Read without mutation.
3396 Plan,
3397 /// Apply and verify.
3398 Apply,
3399}
3400
3401/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
3402/// against it goes on compiling.
3403pub type StatusOptionsMode = SetupMode;
3404
3405/// The explicit result of the requested operation.
3406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3407#[serde(rename_all = "kebab-case")]
3408pub enum StatusOptionsOutcome {
3409 /// A read-only plan.
3410 Planned,
3411 /// Apply found nothing missing.
3412 Unchanged,
3413 /// Additions were applied and verified.
3414 Applied,
3415}
3416
3417/// A GitHub single-select option's opaque GraphQL node identifier.
3418#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3419#[serde(transparent)]
3420pub struct StatusOptionId(#[schemars(length(min = 1))] String);
3421
3422impl TryFrom<String> for StatusOptionId {
3423 type Error = String;
3424
3425 fn try_from(id: String) -> Result<Self, Self::Error> {
3426 if id.trim().is_empty() {
3427 return Err("a GitHub Status option id cannot be blank".to_owned());
3428 }
3429 Ok(Self(id))
3430 }
3431}
3432
3433/// One existing or proposed option in a guarded Status-field update.
3434#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3435pub struct StatusOption {
3436 /// GitHub's stable id.
3437 pub id: StatusOptionId,
3438 /// The visible option name.
3439 pub name: ColumnName,
3440 /// GitHub's single-select color token.
3441 pub color: StatusOptionColor,
3442 /// The option description, including an empty one.
3443 pub description: String,
3444}
3445
3446/// One board item's Status assignment, retained as recovery data.
3447#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3448pub struct StatusAssignment {
3449 /// The project item id whose assignment this is.
3450 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
3451 // carried verbatim as operator recovery data; introducing a semantic type would claim
3452 // validation rules GitHub does not publish and no operation here interprets.
3453 pub item_id: String,
3454 /// The selected option, absent when the item has no status.
3455 #[serde(skip_serializing_if = "Option::is_none")]
3456 pub option: Option<AssignedStatusOption>,
3457}
3458
3459/// The inseparable id and name of an assigned option.
3460#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3461pub struct AssignedStatusOption {
3462 /// GitHub's stable id.
3463 pub id: StatusOptionId,
3464 /// The visible name.
3465 pub name: ColumnName,
3466}
3467
3468/// The plan and verified outcome of reconciling configured Status options.
3469#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3470pub struct StatusOptionsReport {
3471 /// The configured source name.
3472 pub source: SourceName,
3473 /// Configured option names absent before the operation.
3474 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
3475 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
3476 // serialized string here preserves the report's intentionally simple public contract.
3477 pub missing: Vec<String>,
3478 /// What the requested operation did.
3479 pub outcome: StatusOptionsOutcome,
3480 /// The complete option list observed before any mutation.
3481 pub existing: Vec<StatusOption>,
3482}
3483
3484#[derive(Debug, Clone, PartialEq, Eq)]
3485struct StatusSnapshot {
3486 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3487 // passed back as the mutation's project identity; a newtype could enforce no stronger
3488 // invariant because GitHub publishes no grammar for it.
3489 board_id: String,
3490 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3491 // passed back as the mutation's field identity; a newtype could enforce no stronger
3492 // invariant because GitHub publishes no grammar for it.
3493 field_id: String,
3494 options: Vec<StatusOption>,
3495 assignments: Vec<StatusAssignment>,
3496}
3497
3498/// The name of the board field a status is held in.
3499const STATUS_FIELD: &str = "Status";
3500
3501/// Every item's value of each field `report` names, as it stood before the setup wrote
3502/// anything — what a person puts back when the setup is refused part way.
3503fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
3504 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
3505 .fields
3506 .iter()
3507 .map(|field| (field.field.name(), before.assignments(field.field)))
3508 .collect();
3509 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
3510 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
3511 })
3512}
3513
3514/// One board field the guarded setup reads and writes — every one it reads, and the only
3515/// ones it writes.
3516#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
3517pub enum BoardField {
3518 /// The single-select `Status` field every instance's `status_mapping` resolves into.
3519 Status,
3520 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
3521 Priority,
3522}
3523
3524impl BoardField {
3525 /// The field's name on the board.
3526 #[must_use]
3527 pub const fn name(self) -> &'static str {
3528 match self {
3529 Self::Status => STATUS_FIELD,
3530 Self::Priority => PRIORITY_FIELD,
3531 }
3532 }
3533
3534 /// The field a board calls `name`, or `None` for one this setup does not own.
3535 fn named(name: &str) -> Option<Self> {
3536 [Self::Status, Self::Priority]
3537 .into_iter()
3538 .find(|field| field.name() == name)
3539 }
3540}
3541
3542/// What the guarded setup did to one field.
3543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3544#[serde(rename_all = "kebab-case")]
3545pub enum FieldOutcome {
3546 /// A read-only plan.
3547 Planned,
3548 /// Apply found the field there with every configured option.
3549 Unchanged,
3550 /// Missing options were added to the field that was there, and verified.
3551 Applied,
3552 /// The field was not there; it was created holding the configured options, and verified.
3553 Created,
3554}
3555
3556/// One field's plan, or its verified outcome.
3557#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3558pub struct FieldReport {
3559 /// Which field.
3560 pub field: BoardField,
3561 /// Whether the board had the field before the operation.
3562 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
3563 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
3564 // "outcome", "existing"}` — so folding one into the other would change a published JSON
3565 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
3566 // one constructor, and it derives `outcome` from `exists` in one match.
3567 pub exists: bool,
3568 /// Configured option names the field lacked before the operation — every one of them,
3569 /// in the order a new field lists them, when the field was not there at all.
3570 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3571 // mapping name and has therefore already passed its nonblank validation; the serialized
3572 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
3573 pub missing: Vec<String>,
3574 /// For the `Status` field, which item kind each missing name is configured for: one
3575 /// entry per kind `status_mapping` names a missing option for, task before project, each
3576 /// listing that kind's missing names in category order. A name both kinds use is in
3577 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
3578 /// `Priority`, which only a task holds.
3579 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3580 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3581 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
3582 #[schemars(!skip_serializing_if)]
3583 pub kinds: Vec<KindMissing>,
3584 /// What the requested operation did.
3585 pub outcome: FieldOutcome,
3586 /// The field's complete option list observed before any mutation; empty when the field
3587 /// was not there.
3588 pub existing: Vec<StatusOption>,
3589}
3590
3591/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
3592#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3593pub struct KindMissing {
3594 /// The kind these names are configured for.
3595 pub kind: ItemKind,
3596 /// The names that kind maps a category to and the field lacked, in category order.
3597 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3598 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
3599 // intentionally simple public contract.
3600 pub missing: Vec<String>,
3601}
3602
3603/// The plan and verified outcome of setting up every field a source's configuration names.
3604#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3605pub struct FieldsReport {
3606 /// The configured source name.
3607 pub source: SourceName,
3608 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
3609 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
3610 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
3611 // per field would change a published JSON shape. The states the list could hold and the
3612 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
3613 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
3614 pub fields: Vec<FieldReport>,
3615 /// Each board text field the source's `metadata_fields` projects a value onto, in
3616 /// configured order. Empty — and left out of the JSON — when it projects none.
3617 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3618 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3619 // both SDKs model an absent list as an empty one rather than as `null`.
3620 #[schemars(!skip_serializing_if)]
3621 pub metadata_fields: Vec<MetadataFieldReport>,
3622}
3623
3624/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
3625/// its GraphQL type when it has none — which is what a projected field's conflict reports.
3626///
3627/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
3628/// no conflict.
3629// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
3630// refuses exactly what `try_from` below does.
3631#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3632#[serde(transparent)]
3633pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
3634
3635/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
3636/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
3637/// neither SDK's validator can be relied on to support.
3638const NON_TEXT_FIELD_TYPE_PATTERN: &str =
3639 r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
3640
3641impl TryFrom<String> for NonTextFieldType {
3642 type Error = String;
3643
3644 fn try_from(kind: String) -> Result<Self, Self::Error> {
3645 if kind.is_empty() || kind.starts_with(char::is_whitespace) {
3646 return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
3647 }
3648 if kind == "TEXT" {
3649 return Err("a TEXT field is no conflict for a projected text field".to_owned());
3650 }
3651 Ok(Self(kind))
3652 }
3653}
3654
3655impl std::fmt::Display for NonTextFieldType {
3656 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3657 formatter.write_str(&self.0)
3658 }
3659}
3660
3661/// One projected metadata text field's plan, or its verified outcome.
3662///
3663/// The setup creates a missing one as a text field and never alters a field that is there:
3664/// a same-named field of another type is reported in `conflict` and left as it is.
3665#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3666pub struct MetadataFieldReport {
3667 /// The board field's name.
3668 pub field: String, // llmlint: ignore[invalid_states_unrepresentable] Copied from a configuration entry `MetadataField::resolve` already validated; the serialized string is the report's intentionally simple public contract, as `FieldReport::missing`'s names are.
3669 /// The metadata key the value is read from.
3670 pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
3671 /// The object keys walked inside that key's value; empty for the key's own value.
3672 pub path: Vec<String>,
3673 /// Whether the board had a text field of that name before the operation.
3674 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` mirrors `FieldReport`'s published shape; `GitHubProjectsSource::fields` is the one constructor and derives `outcome` from `exists` and `conflict` in one match.
3675 pub exists: bool,
3676 /// The type of a field of that name that is not a text field, which the setup leaves as
3677 /// it is; absent — and left out of the JSON — when there is none.
3678 #[serde(default, skip_serializing_if = "Option::is_none")]
3679 pub conflict: Option<NonTextFieldType>,
3680 /// What the requested operation did: `planned`; `created` for a field that was not
3681 /// there; `unchanged` for one that was, and for a conflict, which is never changed.
3682 pub outcome: FieldOutcome,
3683}
3684
3685/// Which options one field is configured with, in the order a new field would list them.
3686struct FieldPlan {
3687 field: BoardField,
3688 wanted: Vec<String>,
3689}
3690
3691/// One single-select field as the guarded setup snapshots it.
3692#[derive(Debug, Clone, PartialEq, Eq)]
3693struct SnapshotField {
3694 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3695 // passed back as the mutation's field identity; a newtype could enforce no stronger
3696 // invariant because GitHub publishes no grammar for it.
3697 field_id: String,
3698 options: Vec<StatusOption>,
3699}
3700
3701/// Every single-select field of a board and every item's value of each.
3702#[derive(Debug, Clone, PartialEq, Eq)]
3703struct BoardSnapshot {
3704 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3705 // passed back as the mutation's project identity; a newtype could enforce no stronger
3706 // invariant because GitHub publishes no grammar for it.
3707 board_id: String,
3708 fields: BTreeMap<BoardField, SnapshotField>,
3709 /// Each board item's id, and its value of each field this setup owns that it holds one of.
3710 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
3711}
3712
3713impl BoardSnapshot {
3714 /// Every item's value of `field`, in board order — the recovery data a drift refusal
3715 /// carries.
3716 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
3717 self.items
3718 .iter()
3719 .map(|(item_id, values)| StatusAssignment {
3720 item_id: item_id.clone(),
3721 option: values.get(&field).cloned(),
3722 })
3723 .collect()
3724 }
3725}
3726
3727impl GitHubProjectsSource {
3728 /// Report missing configured Status options and, when `apply` is true, add them with
3729 /// a whole-list mutation that preserves every existing id and verifies the result.
3730 ///
3731 /// # Errors
3732 ///
3733 /// Refuses a board without a single-select `Status` field. A post-write difference in
3734 /// any pre-existing option id or item assignment is refused with the complete pre-write
3735 /// assignment snapshot in the diagnostic for recovery.
3736 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
3737 // successful mutation, both drift refusals, source selection, missing Status, casing,
3738 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
3739 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
3740 // responses from entering the defensive malformed-response branches below.
3741 pub async fn status_options(
3742 &self,
3743 mode: StatusOptionsMode,
3744 ) -> Result<StatusOptionsReport, SourceError> {
3745 let before = self.status_snapshot().await?;
3746 // A terminal category's option is as configured as an open one's: a terminal
3747 // write validates it before closing and refuses when the board lacks it. Both
3748 // kinds' names are options of the one field, so both are asked for.
3749 let missing = self
3750 .statuses
3751 .wanted()
3752 .into_iter()
3753 .filter(|wanted| {
3754 !before
3755 .options
3756 .iter()
3757 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3758 })
3759 .collect::<Vec<_>>();
3760 let report = StatusOptionsReport {
3761 source: self.name.clone(),
3762 missing: missing.clone(),
3763 outcome: match (mode, missing.is_empty()) {
3764 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
3765 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
3766 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
3767 },
3768 existing: before.options.clone(),
3769 };
3770 if mode == StatusOptionsMode::Plan || missing.is_empty() {
3771 return Ok(report);
3772 }
3773 let mut options = before
3774 .options
3775 .iter()
3776 .map(|option| {
3777 json!({
3778 "id": option.id, "name": option.name, "color": option.color,
3779 "description": option.description,
3780 })
3781 })
3782 .collect::<Vec<_>>();
3783 options.extend(missing.iter().map(|name| {
3784 json!({
3785 "name": name, "color": "GRAY", "description": ""
3786 })
3787 }));
3788 self.graphql(
3789 graphql::STATUS_OPTIONS_UPDATE,
3790 json!({"input": {
3791 "projectId": before.board_id, "fieldId": before.field_id,
3792 "singleSelectOptions": options,
3793 }}),
3794 )
3795 .await?;
3796 let after = self.status_snapshot().await?;
3797 let options_preserved = before
3798 .options
3799 .iter()
3800 .all(|old| after.options.iter().any(|new| new == old));
3801 let additions_present = missing.iter().all(|wanted| {
3802 after
3803 .options
3804 .iter()
3805 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3806 });
3807 if !options_preserved || !additions_present || after.assignments != before.assignments {
3808 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
3809 SourceError::Malformed {
3810 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
3811 }
3812 })?;
3813 return Err(SourceError::Refused {
3814 message: format!(
3815 "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}"
3816 ),
3817 });
3818 }
3819 Ok(report)
3820 }
3821
3822 /// A fresh snapshot of the Status field and every board item's assignment of it.
3823 ///
3824 /// # Errors
3825 ///
3826 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
3827 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
3828 // Status alone, as this operation has always read it: a `Priority` field is another
3829 // operation's, so nothing about it can refuse this one.
3830 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
3831 let field = board
3832 .fields
3833 .remove(&BoardField::Status)
3834 .ok_or_else(|| self.no_status_field())?;
3835 Ok(StatusSnapshot {
3836 assignments: board.assignments(BoardField::Status),
3837 board_id: board.board_id,
3838 field_id: field.field_id,
3839 options: field.options,
3840 })
3841 }
3842
3843 /// The refusal a board with no `Status` field is answered with by the guarded setup.
3844 fn no_status_field(&self) -> SourceError {
3845 SourceError::Refused {
3846 message: format!("source {} board has no Status field", self.name),
3847 }
3848 }
3849
3850 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
3851 // the real CLI loopback journey, including pagination. The individual malformed guards
3852 // are defensive validation of a schema-pinned third-party response, not separate user
3853 // journeys; drift and missing-field failures cover the operation's recovery behavior.
3854 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
3855 /// every board item's value of each, walked to the end of the board's items. A field not
3856 /// in `owned` is read past whatever it holds.
3857 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
3858 let mut after: Option<String> = None;
3859 let mut snapshot: Option<BoardSnapshot> = None;
3860 loop {
3861 let data = self
3862 .graphql(
3863 graphql::STATUS_OPTIONS_SNAPSHOT,
3864 json!({
3865 "owner": self.owner, "number": self.project_number,
3866 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3867 }),
3868 )
3869 .await?;
3870 let board = data
3871 .pointer("/owner/projectV2")
3872 .filter(|board| board.is_object())
3873 .ok_or_else(|| SourceError::Refused {
3874 message: format!(
3875 "source {} has no accessible GitHub Projects board",
3876 self.name
3877 ),
3878 })?;
3879 if board
3880 .pointer("/fields/pageInfo/hasNextPage")
3881 .and_then(Value::as_bool)
3882 != Some(false)
3883 {
3884 return Err(SourceError::Malformed {
3885 message:
3886 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3887 .into(),
3888 });
3889 }
3890 let mut fields = BTreeMap::new();
3891 // Only the fields this setup owns, by name: a node the single-select fragment did not
3892 // match carries no name, and a person's own single-select field — a `Size`, a
3893 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3894 // `Status` or `Priority` field without its options is malformed, not absent.
3895 // 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.
3896 for (owned, field) in board
3897 .pointer("/fields/nodes")
3898 .and_then(Value::as_array)
3899 .ok_or_else(|| SourceError::Malformed {
3900 message: "GitHub project fields.nodes is not an array".into(),
3901 })?
3902 .iter()
3903 .filter_map(|field| {
3904 let named = BoardField::named(field.get("name")?.as_str()?)?;
3905 owned.contains(&named).then_some((named, field))
3906 })
3907 {
3908 let options = field
3909 .get("options")
3910 .and_then(Value::as_array)
3911 .ok_or_else(|| SourceError::Malformed {
3912 message: "GitHub single-select field options is not an array".into(),
3913 })?
3914 .iter()
3915 .map(|option| {
3916 Ok(StatusOption {
3917 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3918 .map_err(|message| SourceError::Malformed { message })?,
3919 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3920 .map_err(|message| SourceError::Malformed {
3921 message: format!(
3922 "GitHub single-select option name is invalid: {message}"
3923 ),
3924 })?,
3925 color: serde_json::from_value(
3926 option.get("color").cloned().unwrap_or(Value::Null),
3927 )
3928 .map_err(|error| {
3929 SourceError::Malformed {
3930 message: format!(
3931 "GitHub single-select option color is invalid: {error}"
3932 ),
3933 }
3934 })?,
3935 description: optional_str(option, "description")?
3936 .unwrap_or_default()
3937 .to_owned(),
3938 })
3939 })
3940 .collect::<Result<Vec<_>, SourceError>>()?;
3941 let snapshot = SnapshotField {
3942 field_id: required_nonblank_str(field, "id")?.to_owned(),
3943 options,
3944 };
3945 // A board's field names are unique, so a second one is an answer that cannot
3946 // say which field the setup would act on — refused rather than one chosen.
3947 if fields.insert(owned, snapshot).is_some() {
3948 return Err(SourceError::Malformed {
3949 message: format!(
3950 "GitHub answered two {} fields for this board",
3951 owned.name()
3952 ),
3953 });
3954 }
3955 }
3956 let board_id = required_nonblank_str(board, "id")?.to_owned();
3957 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3958 board_id,
3959 fields,
3960 items: Vec::new(),
3961 });
3962 let items = board
3963 .pointer("/items/nodes")
3964 .and_then(Value::as_array)
3965 .ok_or_else(|| SourceError::Malformed {
3966 message: "GitHub project items.nodes is not an array".into(),
3967 })?;
3968 for item in items {
3969 let field_values =
3970 item.get("fieldValues")
3971 .ok_or_else(|| SourceError::Malformed {
3972 message: "GitHub project item is missing fieldValues".into(),
3973 })?;
3974 if field_values
3975 .pointer("/pageInfo/hasNextPage")
3976 .and_then(Value::as_bool)
3977 != Some(false)
3978 {
3979 return Err(SourceError::Malformed {
3980 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3981 });
3982 }
3983 let values = item
3984 .pointer("/fieldValues/nodes")
3985 .and_then(Value::as_array)
3986 .ok_or_else(|| SourceError::Malformed {
3987 message: "GitHub project item fieldValues.nodes is not an array".into(),
3988 })?;
3989 let item_id = required_nonblank_str(item, "id")?;
3990 let mut assigned = BTreeMap::new();
3991 for value in values {
3992 let Some(field) = value
3993 .pointer("/field/name")
3994 .and_then(Value::as_str)
3995 .and_then(BoardField::named)
3996 .filter(|field| owned.contains(field))
3997 else {
3998 continue;
3999 };
4000 let held = assigned.insert(
4001 field,
4002 AssignedStatusOption {
4003 id: StatusOptionId::try_from(
4004 required_str(value, "optionId")?.to_owned(),
4005 )
4006 .map_err(|message| SourceError::Malformed { message })?,
4007 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
4008 .map_err(|message| SourceError::Malformed {
4009 message: format!(
4010 "GitHub assigned {} name is invalid: {message}",
4011 field.name()
4012 ),
4013 })?,
4014 },
4015 );
4016 // An item holds one value of a field, so a second one leaves no way to
4017 // tell which it holds — and a verification or recovery built on either
4018 // could restore the wrong one.
4019 if held.is_some() {
4020 return Err(SourceError::Malformed {
4021 message: format!(
4022 "GitHub answered two {} values for board item {item_id}",
4023 field.name()
4024 ),
4025 });
4026 }
4027 }
4028 current.items.push((item_id.to_owned(), assigned));
4029 }
4030 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
4031 message: "GitHub project is missing items".into(),
4032 })?;
4033 let has_next = page
4034 .pointer("/pageInfo/hasNextPage")
4035 .and_then(Value::as_bool)
4036 .ok_or_else(|| SourceError::Malformed {
4037 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
4038 })?;
4039 if !has_next {
4040 break;
4041 }
4042 let next =
4043 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
4044 validate_cursor_progress(after.as_deref(), next)?;
4045 after = Some(next.to_owned());
4046 }
4047 snapshot.ok_or_else(|| SourceError::Malformed {
4048 message: "GitHub returned no board field snapshot".into(),
4049 })
4050 }
4051 // llmlint: ignore-end[changed_behavior_has_e2e]
4052
4053 /// Report every board field this source's configuration names and, with
4054 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
4055 /// the `Priority` field when the board has none.
4056 ///
4057 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
4058 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
4059 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
4060 /// color and description: the whole option list goes back with every existing id, because
4061 /// a re-minted id clears every item's value.
4062 ///
4063 /// # Errors
4064 ///
4065 /// Refuses a board without a single-select `Status` field. After an apply the board is
4066 /// read again, and a pre-existing option or any item's value of either field that moved is
4067 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
4068 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
4069 // unchanged apply, a created field, an added option to each field, drift refusal, a board
4070 // with no Status field and a non-github-projects source through the compiled CLI against
4071 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
4072 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
4073 let owned: Vec<BoardField> = if self.priorities.is_some() {
4074 vec![BoardField::Status, BoardField::Priority]
4075 } else {
4076 vec![BoardField::Status]
4077 };
4078 let before = self.board_snapshot(&owned).await?;
4079 let mut plans = vec![FieldPlan {
4080 field: BoardField::Status,
4081 wanted: self.statuses.wanted(),
4082 }];
4083 if !before.fields.contains_key(&BoardField::Status) {
4084 return Err(self.no_status_field());
4085 }
4086 if let Some(mapping) = &self.priorities {
4087 plans.push(FieldPlan {
4088 field: BoardField::Priority,
4089 wanted: mapping.names().map(str::to_owned).collect(),
4090 });
4091 }
4092 // The snapshot reads single-select fields alone, so a field it did not find may still
4093 // be on the board under the name, of another type: creating one beside it would fail
4094 // part way, or leave two fields of one name. Asked of the board's own field list, and
4095 // only when a field is missing.
4096 if plans
4097 .iter()
4098 .any(|plan| !before.fields.contains_key(&plan.field))
4099 {
4100 let board = self.board_fields().await?;
4101 for plan in plans
4102 .iter()
4103 .filter(|plan| !before.fields.contains_key(&plan.field))
4104 {
4105 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
4106 return Err(SourceError::Refused {
4107 message: format!(
4108 "source {}'s board has a {} field that is not a single-select field \
4109 (it is a {}), so it cannot hold this source's options; next: rename \
4110 or remove that field, then run this again",
4111 self.name,
4112 plan.field.name(),
4113 optional_str(field, "__typename")?.unwrap_or("field of another type")
4114 ),
4115 });
4116 }
4117 }
4118 }
4119 let mut reports = Vec::new();
4120 for plan in &plans {
4121 let held = before.fields.get(&plan.field);
4122 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
4123 let mut missing: Vec<String> = Vec::new();
4124 for wanted in &plan.wanted {
4125 let present = existing
4126 .iter()
4127 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4128 || missing
4129 .iter()
4130 .any(|named| named.eq_ignore_ascii_case(wanted));
4131 if !present {
4132 missing.push(wanted.clone());
4133 }
4134 }
4135 let kinds = match plan.field {
4136 BoardField::Status => self.statuses.missing_by_kind(&existing),
4137 BoardField::Priority => Vec::new(),
4138 };
4139 reports.push(FieldReport {
4140 field: plan.field,
4141 exists: held.is_some(),
4142 kinds,
4143 outcome: match (mode, held.is_some(), missing.is_empty()) {
4144 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
4145 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
4146 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
4147 (SetupMode::Apply, false, _) => FieldOutcome::Created,
4148 },
4149 missing,
4150 existing,
4151 });
4152 }
4153 // A projected field is a text field, which the single-select snapshot cannot see, so
4154 // it is asked of the board's own field list — and only when one is configured.
4155 let mut metadata_fields = Vec::new();
4156 if !self.metadata_fields.is_empty() {
4157 let board = self.board_fields().await?;
4158 for projection in &self.metadata_fields {
4159 let conflict = match Board::field(&board.fields, &projection.field)? {
4160 None => None,
4161 Some(field) => {
4162 let typename = optional_str(field, "__typename")?;
4163 let data_type = optional_str(field, "dataType")?;
4164 if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
4165 None
4166 } else {
4167 let named = data_type
4168 .filter(|kind| *kind != "TEXT")
4169 .or(typename)
4170 .unwrap_or("field of another type");
4171 Some(NonTextFieldType::try_from(named.to_owned()).map_err(
4172 |message| SourceError::Malformed {
4173 message: format!(
4174 "GitHub answered the board's {} field with a type this \
4175 setup cannot read: {message}",
4176 &*projection.field
4177 ),
4178 },
4179 )?)
4180 }
4181 }
4182 };
4183 let exists =
4184 conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
4185 metadata_fields.push(MetadataFieldReport {
4186 field: projection.field.to_string(),
4187 key: projection.key.to_string(),
4188 path: projection.path.clone(),
4189 exists,
4190 outcome: match (mode, exists || conflict.is_some()) {
4191 (SetupMode::Plan, _) => FieldOutcome::Planned,
4192 (SetupMode::Apply, true) => FieldOutcome::Unchanged,
4193 (SetupMode::Apply, false) => FieldOutcome::Created,
4194 },
4195 conflict,
4196 });
4197 }
4198 }
4199 let report = FieldsReport {
4200 source: self.name.clone(),
4201 fields: reports,
4202 metadata_fields,
4203 };
4204 let writes: Vec<&FieldReport> = report
4205 .fields
4206 .iter()
4207 .filter(|field| !field.missing.is_empty() || !field.exists)
4208 .collect();
4209 let creates: Vec<&MetadataFieldReport> = report
4210 .metadata_fields
4211 .iter()
4212 .filter(|field| field.outcome == FieldOutcome::Created)
4213 .collect();
4214 if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
4215 return Ok(report);
4216 }
4217 let mut landed: Vec<&str> = Vec::new();
4218 for field in &writes {
4219 let added = field
4220 .missing
4221 .iter()
4222 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
4223 let sent = match before.fields.get(&field.field) {
4224 Some(held) => {
4225 let mut options = held
4226 .options
4227 .iter()
4228 .map(|option| {
4229 json!({
4230 "id": option.id, "name": option.name, "color": option.color,
4231 "description": option.description,
4232 })
4233 })
4234 .collect::<Vec<_>>();
4235 options.extend(added);
4236 self.graphql(
4237 graphql::STATUS_OPTIONS_UPDATE,
4238 json!({"input": {
4239 "projectId": before.board_id, "fieldId": held.field_id,
4240 "singleSelectOptions": options,
4241 }}),
4242 )
4243 .await
4244 }
4245 None => {
4246 self.graphql(
4247 graphql::CREATE_FIELD,
4248 json!({"input": {
4249 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
4250 "name": field.field.name(),
4251 "singleSelectOptions": added.collect::<Vec<_>>(),
4252 }}),
4253 )
4254 .await
4255 }
4256 };
4257 // A mutation that failed does not establish that GitHub left its field as it was,
4258 // so every failure from here on carries the recovery data a drift refusal does.
4259 match sent {
4260 Ok(_) => landed.push(field.field.name()),
4261 Err(error) => {
4262 let changed = if landed.is_empty() {
4263 String::new()
4264 } else {
4265 format!("changed the {} field and then ", landed.join(" and "))
4266 };
4267 return Err(SourceError::Refused {
4268 message: format!(
4269 "the guarded field setup {changed}failed on the {} field, which it may \
4270 have changed part way: {error}; the pre-write item assignments \
4271 are:\n{}",
4272 field.field.name(),
4273 recovery(&report, &before)?
4274 ),
4275 });
4276 }
4277 }
4278 }
4279 for field in &creates {
4280 let sent = self
4281 .graphql(
4282 graphql::CREATE_FIELD,
4283 json!({"input": {
4284 "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
4285 }}),
4286 )
4287 .await;
4288 if let Err(error) = sent {
4289 let changed = if landed.is_empty() {
4290 String::new()
4291 } else {
4292 format!("changed the {} field and then ", landed.join(" and "))
4293 };
4294 return Err(SourceError::Refused {
4295 message: format!(
4296 "the guarded field setup {changed}failed creating the {} text field: \
4297 {error}; run it again to create what is still missing",
4298 field.field
4299 ),
4300 });
4301 }
4302 landed.push(&field.field);
4303 }
4304 // The board has been written, so a verification read that fails leaves it unverified
4305 // rather than unchanged, and says what to put back.
4306 let after = match self.board_snapshot(&owned).await {
4307 Ok(after) => after,
4308 Err(error) => {
4309 return Err(SourceError::Refused {
4310 message: format!(
4311 "the guarded field setup changed the {} field and then could not read the \
4312 board back to verify it: {error}; the pre-write item assignments are:\n{}",
4313 landed.join(" and "),
4314 recovery(&report, &before)?
4315 ),
4316 });
4317 }
4318 };
4319 let mut moved = Vec::new();
4320 for field in &report.fields {
4321 let name = field.field.name();
4322 let now = after
4323 .fields
4324 .get(&field.field)
4325 .map(|held| held.options.as_slice())
4326 .unwrap_or_default();
4327 if !field.existing.iter().all(|old| now.contains(old)) {
4328 moved.push(format!(
4329 "a pre-existing {name} option id, name, color or description"
4330 ));
4331 }
4332 if !field.missing.iter().all(|wanted| {
4333 now.iter()
4334 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4335 }) {
4336 moved.push(format!("an added {name} option"));
4337 }
4338 if after.assignments(field.field) != before.assignments(field.field) {
4339 moved.push(format!("an item's {name} value"));
4340 }
4341 }
4342 if !creates.is_empty() {
4343 // Read afresh: the view held for this command is the one from before the creates.
4344 *self.fields_cache()? = None;
4345 let board = match self.board_fields().await {
4346 Ok(board) => board,
4347 Err(error) => {
4348 return Err(SourceError::Refused {
4349 message: format!(
4350 "the guarded field setup changed the {} field and then could not \
4351 read the board back to verify it: {error}; run it again to verify \
4352 it, which creates nothing a second time",
4353 landed.join(" and ")
4354 ),
4355 });
4356 }
4357 };
4358 for field in &creates {
4359 let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
4360 held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
4361 && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
4362 });
4363 if !text {
4364 moved.push(format!("the created {} text field", field.field));
4365 }
4366 }
4367 }
4368 if !moved.is_empty() {
4369 return Err(SourceError::Refused {
4370 message: format!(
4371 "GitHub changed {} after the guarded field setup; the pre-write item \
4372 assignments are:\n{}",
4373 moved.join(", "),
4374 recovery(&report, &before)?
4375 ),
4376 });
4377 }
4378 Ok(report)
4379 }
4380
4381 /// Validate configuration and capture the named credential without exposing it.
4382 ///
4383 /// # Errors
4384 ///
4385 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
4386 /// [`SourceError::Auth`] when the named credential is missing or empty.
4387 pub fn new(
4388 name: &SourceName,
4389 config: GitHubProjectsConfig,
4390 secrets: &dyn SecretResolver,
4391 ) -> Result<Self, SourceError> {
4392 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
4393 }
4394
4395 /// The same, recording every request it sends into an accounting the caller holds too.
4396 ///
4397 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
4398 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
4399 /// up — passes the one it records those into, so the session total accounts for the
4400 /// whole session rather than for this source's share of it.
4401 ///
4402 /// # Errors
4403 ///
4404 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
4405 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
4406 pub fn recording_into(
4407 name: &SourceName,
4408 config: GitHubProjectsConfig,
4409 secrets: &dyn SecretResolver,
4410 ledger: Arc<Accounting>,
4411 ) -> Result<Self, SourceError> {
4412 if !valid_github_owner(&config.owner) {
4413 return Err(SourceError::Config {
4414 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
4415 });
4416 }
4417 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
4418 return Err(SourceError::Config {
4419 message: format!("project_number must be between 1 and {}", i32::MAX),
4420 });
4421 }
4422 if !valid_environment_name(&config.token_env) {
4423 return Err(SourceError::Config {
4424 message: "token_env must be a valid environment-variable name".into(),
4425 });
4426 }
4427 let repository = config
4428 .repository
4429 .as_deref()
4430 .map(RepositoryTarget::parse)
4431 .transpose()?;
4432 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
4433 message: format!("endpoint is not a valid URL: {e}"),
4434 })?;
4435 if endpoint.scheme() != "https"
4436 && !(endpoint.scheme() == "http"
4437 && endpoint
4438 .host_str()
4439 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
4440 {
4441 return Err(SourceError::Config {
4442 message:
4443 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
4444 .into(),
4445 });
4446 }
4447 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
4448 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),
4449 })?;
4450 Ok(Self {
4451 name: name.clone(),
4452 owner: config.owner,
4453 project_number: config.project_number,
4454 repository,
4455 asset_client: assets::client(&endpoint)?,
4456 endpoint,
4457 token,
4458 credential_name: config.token_env,
4459 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
4460 priorities: config
4461 .priority_mapping
4462 .map(|mapping| PriorityMapping::resolve(mapping, name))
4463 .transpose()?,
4464 metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
4465 client: Client::builder()
4466 .user_agent("onetaskgraph")
4467 .build()
4468 .map_err(|e| SourceError::Config {
4469 message: format!("cannot build HTTP client: {e}"),
4470 })?,
4471 created: Mutex::new(Vec::new()),
4472 updated: Mutex::new(Vec::new()),
4473 commented: Mutex::new(Vec::new()),
4474 pacing: Pacing::resolve(config.pacing, name)?,
4475 last_mutation: Mutex::new(None),
4476 clock: system_clock(),
4477 numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
4478 board_cache: Mutex::new(None),
4479 search_cache: Mutex::new(None),
4480 narrowed_cache: Mutex::new(BTreeMap::new()),
4481 resolved_cache: Mutex::new(BTreeMap::new()),
4482 read_ahead: Mutex::new(BTreeMap::new()),
4483 carried: Mutex::new(BTreeMap::new()),
4484 held_cache: Mutex::new(BTreeMap::new()),
4485 children_cache: Mutex::new(BTreeMap::new()),
4486 search_next: Mutex::new(BTreeMap::new()),
4487 fields_cache: Mutex::new(None),
4488 repository_cache: Mutex::new(BTreeMap::new()),
4489 ledger,
4490 expected: expected::Held::default(),
4491 })
4492 }
4493
4494 /// A snapshot of every request this source has sent, and what each cost.
4495 ///
4496 /// A value to hold and compare rather than a borrow of the accounting itself, so two
4497 /// of them can sit side by side. When this source was built with
4498 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
4499 /// point of building it that way.
4500 #[must_use]
4501 pub fn accounting(&self) -> accounting::Session {
4502 self.ledger.snapshot()
4503 }
4504
4505 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
4506 /// rate limit rather than handing it straight back as an error.
4507 ///
4508 /// Retrying is safe for every document here, including the mutations, and the reason
4509 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
4510 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
4511 /// this replays has already taken effect. An outcome this source cannot know — the
4512 /// send failed, or the body could not be read, so the mutation may well have landed —
4513 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
4514 /// attempt. A duplicate write would come from replaying one of those, and none is
4515 /// replayed.
4516 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4517 if !self.expected.journaling() {
4518 return self.graphql_sent(query, variables).await;
4519 }
4520 // While a write the store's cache expects runs, its mutations select the whole issue
4521 // they leave behind, so the response vouches for what the cache keeps — and every
4522 // request is recorded, so the write's parts are reported from their own responses.
4523 let (query, variables) = match graphql::whole_twin(query) {
4524 Some(twin) => (twin, expected::whole_variables(variables)),
4525 None => (query, variables),
4526 };
4527 let sent = self.graphql_sent(query, variables.clone()).await;
4528 self.journal(query, &variables, &sent).await;
4529 sent
4530 }
4531
4532 /// One document sent, retried while GitHub asks for a wait, exactly as asked.
4533 async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4534 if is_mutation(query)
4535 && ![
4536 graphql::ADD_COMMENT,
4537 graphql::UPDATE_COMMENT,
4538 graphql::DELETE_COMMENT,
4539 graphql::ADD_COMMENT_WHOLE,
4540 graphql::UPDATE_COMMENT_WHOLE,
4541 ]
4542 .contains(&query)
4543 {
4544 let mut cache = self.resolved_cache()?;
4545 let mut ahead = self.read_ahead()?;
4546 let mut carried = self.carried()?;
4547 for argument in ["input", "second", "third", "clear"] {
4548 if let Some(input) = variables.get(argument) {
4549 let names = |id: &NativeId, item_id: Option<&str>| {
4550 ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
4551 input
4552 .get(key)
4553 .and_then(Value::as_str)
4554 .is_some_and(|value| value == id.0 || Some(value) == item_id)
4555 })
4556 };
4557 cache.retain(|id, item| !names(id, Some(&item.item_id)));
4558 ahead.retain(|id, item| {
4559 !names(id, item.as_ref().map(|item| item.item_id.as_str()))
4560 });
4561 carried.retain(|id, item| !names(id, Some(&item.item_id)));
4562 }
4563 }
4564 }
4565 let doing = operation_description(query);
4566 let mut waited = Duration::ZERO;
4567 let mut waits = 0_u32;
4568 let mut backoff = self.pacing.retry_backoff;
4569 loop {
4570 if is_mutation(query) {
4571 let spacing = self.reserve_mutation_slot();
4572 if !spacing.is_zero() {
4573 self.clock.sleep(spacing).await;
4574 }
4575 }
4576 let attempt = self.send_once(query, &variables, waits + 1).await;
4577 if is_mutation(query) {
4578 self.finish_mutation();
4579 }
4580 let limited = match attempt {
4581 Ok(data) => return Ok(data),
4582 Err(Attempt::Failed(error)) => return Err(error),
4583 Err(Attempt::Limited(limited)) => limited,
4584 };
4585 // GitHub really does send `retry-after: 0`, and retrying at once is the one
4586 // move that extends a secondary limit, so a hint below the schedule's own next
4587 // wait is raised to it.
4588 let wait = match limited.hint {
4589 Some(hint) => Duration::from_secs(hint).max(backoff),
4590 None => backoff,
4591 };
4592 let remaining = self.pacing.retry_budget.saturating_sub(waited);
4593 // A wait of nothing spends none of the budget, so it is exhaustion rather
4594 // than a retry. `Pacing::resolve` rules out every way of configuring one
4595 // except a budget of zero, where reporting the first refusal is the ask.
4596 if wait.is_zero() || wait > remaining {
4597 return Err(limited.exhausted(
4598 doing,
4599 waits,
4600 waited,
4601 wait,
4602 self.pacing.retry_budget,
4603 ));
4604 }
4605 self.clock.sleep(wait).await;
4606 waited += wait;
4607 waits += 1;
4608 backoff = backoff.saturating_mul(2);
4609 }
4610 }
4611
4612 /// The next moment a content-creating mutation may leave this source, as a wait from
4613 /// now.
4614 ///
4615 /// The slot is reserved under the lock and the waiting happens outside it, so two
4616 /// callers take two slots rather than the same one — and no lock is held across an
4617 /// await.
4618 ///
4619 /// The moment it is spaced from is the previous mutation's *completion*, which
4620 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
4621 /// own is the wrong thing to measure from.
4622 fn reserve_mutation_slot(&self) -> Duration {
4623 if self.pacing.min_mutation_interval.is_zero() {
4624 return Duration::ZERO;
4625 }
4626 // A poisoned lock here costs pacing, not correctness, and refusing the write over
4627 // it would turn an earlier failure into a second one for no gain.
4628 let mut last = self
4629 .last_mutation
4630 .lock()
4631 .unwrap_or_else(std::sync::PoisonError::into_inner);
4632 let now = self.clock.now();
4633 // `checked_add` rather than `+`: adding durations can panic on overflow, and
4634 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
4635 let at = last.map_or(now, |previous| {
4636 previous
4637 .checked_add(self.pacing.min_mutation_interval)
4638 .map_or(now, |earliest| earliest.max(now))
4639 });
4640 *last = Some(at);
4641 at.saturating_sub(now)
4642 }
4643
4644 /// Record that a content-creating mutation has finished, so the next one is spaced
4645 /// from here rather than from the moment this one was released.
4646 ///
4647 /// This source can only choose when a request *departs*; the limiter counts when it
4648 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
4649 /// departure from the last therefore hands the limiter a gap of the interval less that
4650 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
4651 /// exactly how a copy paced well inside a board's threshold was refused by it on a
4652 /// slower machine while passing on a quick one.
4653 ///
4654 /// Spacing from completion removes the subtraction rather than budgeting for it. The
4655 /// previous request had already arrived before its response came back, so its arrival
4656 /// is no later than this moment, and the next mutation is released at least the
4657 /// interval after this moment and arrives no earlier than it is released: the gap the
4658 /// limiter measures is therefore at least the interval, whatever transit costs and on
4659 /// whatever platform. The price is that a mutation's own round trip no longer counts
4660 /// towards its spacing, which makes this source slightly slower than the configured
4661 /// rate rather than slightly faster — the safe side of a limit that punishes being
4662 /// wrong by refusing reads for the next fifty minutes.
4663 ///
4664 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
4665 /// and one that never left costs only a wait nobody needed.
4666 fn finish_mutation(&self) {
4667 if self.pacing.min_mutation_interval.is_zero() {
4668 return;
4669 }
4670 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
4671 let mut last = self
4672 .last_mutation
4673 .lock()
4674 .unwrap_or_else(std::sync::PoisonError::into_inner);
4675 let now = self.clock.now();
4676 // `max` rather than an assignment: a concurrent caller may already have reserved a
4677 // slot further out, and completing this request must never pull that slot back in.
4678 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
4679 }
4680
4681 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
4682 /// failure that waiting cannot help — and recorded, whichever of the three it was.
4683 ///
4684 /// This is the one place a request leaves this crate, which is why the accounting is
4685 /// here rather than at each of the callers: a read path added later is counted without
4686 /// anybody remembering to count it, and
4687 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
4688 /// when one is not.
4689 async fn send_once(
4690 &self,
4691 query: &str,
4692 variables: &Value,
4693 number: u32,
4694 ) -> Result<Value, Attempt> {
4695 let executed = self.ledger.priced_document(query);
4696 let Attempted {
4697 result,
4698 limits,
4699 reported_cost,
4700 response_outcome,
4701 } = self.attempt(executed.as_ref(), variables).await;
4702 // No `otherwise` name: every document this source sends is one of its own, and the
4703 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
4704 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
4705 let outcome = match &result {
4706 Ok(_) => accounting::Outcome::Answered,
4707 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
4708 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
4709 };
4710 self.ledger
4711 .record(sending.finished(outcome, limits).with_execution(
4712 Some(executed.into_owned()),
4713 response_outcome,
4714 number,
4715 ));
4716 result
4717 }
4718
4719 /// The attempt itself, with what its response said about the rate limit alongside.
4720 ///
4721 /// The two are returned together rather than recorded here because every one of the
4722 /// early exits below is a different outcome, and a record written at each of them is a
4723 /// record one of them can be added without.
4724 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
4725 let mut limits = accounting::RateLimit::default();
4726 let mut reported_cost = None;
4727 let mut response_outcome = accounting::ResponseOutcome::TransportError;
4728 let result = self
4729 .attempted(
4730 query,
4731 variables,
4732 &mut limits,
4733 &mut reported_cost,
4734 &mut response_outcome,
4735 )
4736 .await;
4737 Attempted {
4738 result,
4739 limits,
4740 reported_cost,
4741 response_outcome,
4742 }
4743 }
4744
4745 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
4746 async fn attempted(
4747 &self,
4748 query: &str,
4749 variables: &Value,
4750 limits: &mut accounting::RateLimit,
4751 reported_cost: &mut Option<u64>,
4752 response_outcome: &mut accounting::ResponseOutcome,
4753 ) -> Result<Value, Attempt> {
4754 let response = self
4755 .client
4756 .post(self.endpoint.clone())
4757 .bearer_auth(self.token.expose_secret())
4758 .json(&json!({"query": query, "variables": variables}))
4759 .send()
4760 .await
4761 .map_err(|e| {
4762 Attempt::Failed(SourceError::Unavailable {
4763 message: format!("GitHub GraphQL request failed: {e}"),
4764 })
4765 })?;
4766 let status = response.status();
4767 if !status.is_success() {
4768 *response_outcome = accounting::ResponseOutcome::HttpError;
4769 }
4770 let header = |name: &str| whole_seconds(response.headers().get(name));
4771 let served = response
4772 .headers()
4773 .get(reqwest::header::DATE)
4774 .and_then(|value| value.to_str().ok())
4775 .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
4776 .map(|at| at.with_timezone(&Utc));
4777 *limits = accounting::RateLimit::read(|name| {
4778 response
4779 .headers()
4780 .get(name)
4781 .and_then(|value| value.to_str().ok())
4782 .map(str::to_owned)
4783 });
4784 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
4785 // that are not text at all — is "not known to be exhausted". This never makes a
4786 // response a refusal on its own: it says which limiter a refusal is attributed to
4787 // and where its hint comes from, so a value this cannot read costs a hint rather
4788 // than an answer.
4789 let exhausted = response
4790 .headers()
4791 .get("x-ratelimit-remaining")
4792 .and_then(|value| value.to_str().ok())
4793 == Some("0");
4794 // `retry-after` is what GitHub asks for when it asks; when it does not and the
4795 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
4796 // which is the same question answered as an absolute time. Nothing else here is a
4797 // hint, and a schedule is what answers a refusal that carries none.
4798 let hint = header("retry-after").or_else(|| {
4799 exhausted
4800 .then(|| header("x-ratelimit-reset"))
4801 .flatten()
4802 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
4803 });
4804 // Read before it is parsed, because the evidence which tells a secondary rate
4805 // limit from a rejected credential is in the body of a response whose status says
4806 // only "forbidden" — and a non-success response was never parsed at all.
4807 let body = response.text().await.map_err(|e| {
4808 Attempt::Failed(SourceError::Unavailable {
4809 message: format!("GitHub GraphQL response could not be read: {e}"),
4810 })
4811 })?;
4812 *reported_cost = serde_json::from_str::<Value>(&body)
4813 .ok()
4814 .as_ref()
4815 .and_then(|body| body.pointer("/data/rateLimit/cost"))
4816 .and_then(Value::as_u64);
4817 let parsed = serde_json::from_str::<Value>(&body).ok();
4818 *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
4819 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
4820 return Err(Attempt::Limited(Limited { limiter, hint }));
4821 }
4822 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
4823 return Err(Attempt::Failed(SourceError::Auth {
4824 message: format!(
4825 "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"
4826 ),
4827 }));
4828 }
4829 if !status.is_success() {
4830 return Err(Attempt::Failed(SourceError::Unavailable {
4831 message: format!("GitHub GraphQL returned HTTP {status}"),
4832 }));
4833 }
4834 let answered = if query == graphql::ISSUES_BY_REFERENCE {
4835 self.answer_unresolved(&body)
4836 } else {
4837 self.answer(&body)
4838 };
4839 let mut data = answered.map_err(Attempt::Failed)?;
4840 if let Some(served) = served {
4841 stamp_served(&mut data, &served.to_rfc3339());
4842 }
4843 Ok(data)
4844 }
4845
4846 /// What one successful HTTP response says, once its GraphQL errors are read.
4847 /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
4848 /// for this credential — a repository it cannot see, an issue number it does not hold —
4849 /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
4850 /// that reference rather than a failure of the others. Any other error refuses the read,
4851 /// as there.
4852 fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
4853 let parsed: Option<Value> = serde_json::from_str(body).ok();
4854 // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
4855 // `issue`, and the answer holds `null` at exactly that path: anything else — another
4856 // kind of error, one naming no slot, or one beside a value it says is not there — is a
4857 // refusal of the whole read.
4858 let unresolved = |data: &Value, error: &Value| {
4859 error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
4860 && error
4861 .get("path")
4862 .and_then(Value::as_array)
4863 .is_some_and(|path| {
4864 let alias = path.first().and_then(Value::as_str).and_then(|alias| {
4865 alias
4866 .strip_prefix('r')
4867 .and_then(|slot| slot.parse::<usize>().ok())
4868 .filter(|slot| *slot < REFERENCE_BATCH)
4869 .map(|_| alias)
4870 });
4871 match (alias, path.get(1).and_then(Value::as_str), path.len()) {
4872 (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
4873 (Some(alias), Some("issue"), 2) => data
4874 .get(alias)
4875 .and_then(|repository| repository.get("issue"))
4876 .is_some_and(Value::is_null),
4877 _ => false,
4878 }
4879 })
4880 };
4881 let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
4882 parsed.get("data").is_some_and(|data| {
4883 data.is_object()
4884 && parsed
4885 .get("errors")
4886 .and_then(Value::as_array)
4887 .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
4888 })
4889 });
4890 match parsed {
4891 Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
4892 _ => self.answer(body),
4893 }
4894 }
4895
4896 fn answer(&self, body: &str) -> Result<Value, SourceError> {
4897 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
4898 message: format!("GitHub returned invalid JSON: {e}"),
4899 })?;
4900 let errors = body
4901 .get("errors")
4902 .map(|value| {
4903 value.as_array().ok_or_else(|| SourceError::Malformed {
4904 message: "GitHub response errors is not an array".into(),
4905 })
4906 })
4907 .transpose()?;
4908 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
4909 let messages = errors
4910 .iter()
4911 .filter_map(|e| e.get("message").and_then(Value::as_str))
4912 .collect::<Vec<_>>()
4913 .join("; ");
4914 let message = if messages.is_empty() {
4915 "GitHub returned GraphQL errors".into()
4916 } else {
4917 messages
4918 };
4919 let normalized = message.to_ascii_lowercase();
4920 if normalized.contains("resource not accessible") || normalized.contains("scope") {
4921 return Err(SourceError::Auth {
4922 message: format!(
4923 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
4924 self.credential_name
4925 ),
4926 });
4927 }
4928 return Err(SourceError::Refused { message });
4929 }
4930 body.get("data")
4931 .filter(|data| data.is_object())
4932 .cloned()
4933 .ok_or_else(|| SourceError::Malformed {
4934 message: "GitHub response has no data object".into(),
4935 })
4936 }
4937
4938 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
4939 // GraphQL cannot independently page them inside the outer item page. This source page is
4940 // deliberately bounded at that published maximum; the live drift journey exercises it.
4941 async fn board_page(
4942 &self,
4943 items_after: Option<&str>,
4944 items_first: u32,
4945 ) -> Result<Value, SourceError> {
4946 let data = self
4947 .graphql(
4948 graphql::BOARD,
4949 json!({"owner":self.owner,"number":self.project_number,
4950 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
4951 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
4952 )
4953 .await?;
4954 data.pointer("/owner/projectV2")
4955 .filter(|v| !v.is_null())
4956 .cloned()
4957 .ok_or_else(|| SourceError::Refused {
4958 message: format!(
4959 "GitHub project {}/{} was not found or is not visible to the token",
4960 self.owner, self.project_number
4961 ),
4962 })
4963 }
4964
4965 /// The search that finds the issues of this board, narrowed by `also` when it is
4966 /// given.
4967 ///
4968 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
4969 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
4970 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
4971 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
4972 /// from a task by the `parent` field each issue carries rather than by the search.
4973 fn board_search(&self, also: Option<&str>) -> String {
4974 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
4975 match also {
4976 Some(also) => format!("{scope} {also}"),
4977 None => scope,
4978 }
4979 }
4980
4981 /// One issue this source reached directly, as the board item a read of the board would
4982 /// have produced — or `None` when this board does not hold it.
4983 ///
4984 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
4985 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
4986 /// item's own id, that item's field values, and the issue as its content. One resolver
4987 /// for both routes is what makes an issue read through a search, through its own node
4988 /// id, or through its project's sub-issues report the same title, the same status, the
4989 /// same labels and the same qualified id.
4990 ///
4991 /// An issue with no entry for *this* board is not this source's to report, which is
4992 /// what keeps an id naming some other repository's issue from being answered as an item
4993 /// of this board. That answer is given about an **exhausted** connection and never
4994 /// about an unread page: the entry is looked for on the page in hand, and only if that
4995 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
4996 /// rest of it.
4997 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
4998 self.resolve_issue_as(issue, true).await
4999 }
5000
5001 /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
5002 /// and writes only when `remember` says to.
5003 async fn resolve_issue_as(
5004 &self,
5005 issue: &Value,
5006 remember: bool,
5007 ) -> Result<Option<Resolved>, SourceError> {
5008 if optional_str(issue, "__typename")? != Some("Issue") {
5009 return Ok(None);
5010 }
5011 let memberships = issue
5012 .get("projectItems")
5013 .ok_or_else(|| SourceError::Malformed {
5014 message: "GitHub issue is missing projectItems".into(),
5015 })?;
5016 let nodes = memberships
5017 .get("nodes")
5018 .and_then(Value::as_array)
5019 .ok_or_else(|| SourceError::Malformed {
5020 message: "GitHub issue projectItems.nodes is not an array".into(),
5021 })?;
5022 let held = match self.board_entry(nodes) {
5023 Some(held) => held.clone(),
5024 None => {
5025 let info = memberships
5026 .get("pageInfo")
5027 .ok_or_else(|| SourceError::Malformed {
5028 message: "GitHub issue projectItems has no pageInfo".into(),
5029 })?;
5030 // The page held no entry for this board. Whether that means the issue is
5031 // not on it is a question about the rest of the connection, and only a
5032 // connection with no rest answers it here.
5033 if !required_bool(info, "hasNextPage")? {
5034 return Ok(None);
5035 }
5036 let cursor = required_str(info, "endCursor")?;
5037 validate_cursor_progress(None, cursor)?;
5038 let issue_id = required_str(issue, "id")?;
5039 match self.board_membership(issue_id, cursor).await? {
5040 Some(held) => held,
5041 None => return Ok(None),
5042 }
5043 }
5044 };
5045 // The membership's own `updatedAt` rides along: it is the board half of the item's change
5046 // token, which a field change moves where the issue's own time does not.
5047 let item = json!({
5048 "id": required_str(&held, "id")?,
5049 "updatedAt": held.get("updatedAt"),
5050 "project": held.get("project"),
5051 "fieldValues": held.get("fieldValues"),
5052 "content": issue,
5053 });
5054 if remember {
5055 self.resolve(&item)
5056 } else {
5057 self.resolve_unremembered(&item)
5058 }
5059 }
5060
5061 /// This board's own entry among one page of an issue's `Issue.projectItems`.
5062 ///
5063 /// One spelling of *which membership is this board's*, so the page a read carries and
5064 /// the pages [`Self::board_membership`] walks are searched by the same rule.
5065 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
5066 nodes.iter().find(|node| {
5067 node.pointer("/project/number").and_then(Value::as_u64)
5068 == Some(u64::from(self.project_number))
5069 })
5070 }
5071
5072 /// The rest of one issue's board memberships, from `after`, for this board's entry.
5073 ///
5074 /// The recovery read: a page of memberships that holds no entry for this board says
5075 /// nothing about the memberships past it, so the connection is walked to exhaustion
5076 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
5077 /// positive answer — the whole connection was read and no entry named this board —
5078 /// rather than a failure, and the walk is held to
5079 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
5080 /// with a cursor that does not advance is refused instead of spun on.
5081 async fn board_membership(
5082 &self,
5083 issue: &str,
5084 after: &str,
5085 ) -> Result<Option<Value>, SourceError> {
5086 let mut after = after.to_owned();
5087 loop {
5088 let data = self
5089 .graphql(
5090 graphql::ISSUE_BOARD_ITEMS,
5091 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
5092 "nestedFirst":NESTED_PAGE_SIZE}),
5093 )
5094 .await?;
5095 let Some(connection) = data
5096 .pointer("/node/projectItems")
5097 .filter(|value| !value.is_null())
5098 else {
5099 // The id resolved to nothing, or to something with no memberships to walk —
5100 // which is the same answer as a connection holding no entry for this board.
5101 return Ok(None);
5102 };
5103 let nodes = connection
5104 .get("nodes")
5105 .and_then(Value::as_array)
5106 .ok_or_else(|| SourceError::Malformed {
5107 message: "GitHub issue projectItems.nodes is not an array".into(),
5108 })?;
5109 if let Some(held) = self.board_entry(nodes) {
5110 return Ok(Some(held.clone()));
5111 }
5112 let info = connection
5113 .get("pageInfo")
5114 .ok_or_else(|| SourceError::Malformed {
5115 message: "GitHub issue projectItems has no pageInfo".into(),
5116 })?;
5117 let next = required_bool(info, "hasNextPage")?
5118 .then(|| required_str(info, "endCursor"))
5119 .transpose()?;
5120 match next {
5121 Some(next) => {
5122 validate_cursor_progress(Some(&after), next)?;
5123 after = next.to_owned();
5124 }
5125 None => return Ok(None),
5126 }
5127 }
5128 }
5129
5130 /// One page of a board-scoped issue search, and where the next page resumes.
5131 async fn search_page(
5132 &self,
5133 search: &str,
5134 first: u32,
5135 after: Option<&str>,
5136 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
5137 let data = self
5138 .graphql(
5139 graphql::SEARCH_ISSUES,
5140 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
5141 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
5142 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5143 )
5144 .await?;
5145 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
5146 message: "GitHub search response has no search connection".into(),
5147 })?;
5148 let mut found = Vec::new();
5149 for node in connection
5150 .get("nodes")
5151 .and_then(Value::as_array)
5152 .ok_or_else(|| SourceError::Malformed {
5153 message: "GitHub search nodes is not an array".into(),
5154 })?
5155 {
5156 if let Some(resolved) = self.resolve_issue(node).await? {
5157 found.push(resolved);
5158 }
5159 }
5160 let info = connection
5161 .get("pageInfo")
5162 .ok_or_else(|| SourceError::Malformed {
5163 message: "GitHub search connection has no pageInfo".into(),
5164 })?;
5165 let next = required_bool(info, "hasNextPage")?
5166 .then(|| required_str(info, "endCursor"))
5167 .transpose()?
5168 .map(str::to_owned);
5169 if let Some(next) = &next {
5170 validate_cursor_progress(after, next)?;
5171 }
5172 Ok((found, next))
5173 }
5174
5175 /// Every issue this board holds, completed with what this run wrote.
5176 ///
5177 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
5178 /// is an index and is eventually consistent, so an issue this run created seconds ago
5179 /// can be absent from it, and a project listed straight after being written would
5180 /// otherwise be missing from its own board. What is added back is only what this
5181 /// process itself wrote, out of [`Self::created`], which lives and dies with the
5182 /// process.
5183 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
5184 let found = self.searched_issues().await?;
5185 self.completed_with_written(found, |_| true)
5186 }
5187
5188 /// Every issue this board's own search reports, walked to exhaustion, read once per
5189 /// source.
5190 ///
5191 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
5192 /// needs it too and the two would otherwise walk the same search twice in one command.
5193 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
5194 /// is.
5195 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
5196 let cached = self.search_cache()?.clone();
5197 if let Some(held) = cached {
5198 return Ok(held);
5199 }
5200 let mut after: Option<String> = None;
5201 let mut found = Vec::new();
5202 let search = self.board_search(None);
5203 loop {
5204 let (page, next) = self
5205 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
5206 .await?;
5207 found.extend(page);
5208 match next {
5209 Some(next) => after = Some(next),
5210 None => break,
5211 }
5212 }
5213 *self.search_cache()? = Some(found.clone());
5214 Ok(found)
5215 }
5216
5217 /// Every issue this board's own search reports with `also` beside its scope, walked to
5218 /// exhaustion — held for no later read, which may not be narrowed alike.
5219 async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
5220 let mut after: Option<String> = None;
5221 let mut found = Vec::new();
5222 let search = self.board_search(Some(also));
5223 loop {
5224 let (page, next) = self
5225 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
5226 .await?;
5227 found.extend(page);
5228 match next {
5229 Some(next) => after = Some(next),
5230 None => return Ok(found),
5231 }
5232 }
5233 }
5234
5235 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
5236 fn search_cache(
5237 &self,
5238 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
5239 self.search_cache
5240 .lock()
5241 .map_err(|_| SourceError::Unavailable {
5242 message: "this source's view of the board's issues was left inconsistent by an \
5243 earlier failure; next: run the command again"
5244 .into(),
5245 })
5246 }
5247
5248 /// `found`, with everything this run wrote that `keep` accepts and the read did not
5249 /// report.
5250 ///
5251 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
5252 /// at all: the search index is behind, and a node read of an item filed moments ago can
5253 /// be too.
5254 fn completed_with_written(
5255 &self,
5256 mut found: Vec<Resolved>,
5257 keep: impl Fn(&Resolved) -> bool,
5258 ) -> Result<Vec<Resolved>, SourceError> {
5259 for own in self.created()?.iter().filter(|own| keep(own)) {
5260 if !found.iter().any(|item| item.id == own.id) {
5261 found.push(own.clone());
5262 }
5263 }
5264 Ok(found)
5265 }
5266
5267 /// What resolving one node id reached.
5268 ///
5269 /// Three answers rather than an `Option`, because a board *draft* is none of the other
5270 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
5271 /// is completed by a read of the draft itself rather than reported as nothing.
5272 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
5273 let asked = self
5274 .graphql(
5275 graphql::ISSUE,
5276 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
5277 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5278 )
5279 .await;
5280 let data = match asked {
5281 Ok(data) => data,
5282 // A string that is not a node id at all is not a failure to report: it is an id
5283 // this board does not hold, which is what every read of one already answers.
5284 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
5285 Err(error) => return Err(error),
5286 };
5287 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
5288 return Ok(Reached::Nothing);
5289 };
5290 if optional_str(node, "__typename")? == Some("DraftIssue") {
5291 return Ok(Reached::Draft);
5292 }
5293 Ok(match self.resolve_issue(node).await? {
5294 Some(item) => Reached::Held(Box::new(item)),
5295 None => Reached::Nothing,
5296 })
5297 }
5298
5299 /// One item of this board by its own id, whatever kind it is.
5300 ///
5301 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
5302 /// run wrote is read first, because a node read of an item created moments ago can
5303 /// still be behind the board field values written onto it — see [`Self::created`].
5304 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5305 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
5306 return Ok(Some(own.clone()));
5307 }
5308 if let Some(held) = self.read_ahead()?.remove(id) {
5309 return Ok(held);
5310 }
5311 match self.reach(id).await? {
5312 Reached::Held(item) => Ok(Some(*item)),
5313 Reached::Nothing => Ok(None),
5314 Reached::Draft => self.draft_by_id(id).await,
5315 }
5316 }
5317
5318 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
5319 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
5320 /// than one request per id.
5321 ///
5322 /// What this run wrote answers first, as it does there, and only the rest is read. One id
5323 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
5324 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
5325 /// id at a time, so that id is answered as not held and the others as themselves; a draft
5326 /// is completed by a read of the draft, exactly as there.
5327 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
5328 let mut found: Vec<Option<Option<Resolved>>> = {
5329 let created = self.created()?;
5330 ids.iter()
5331 .map(|id| {
5332 created
5333 .iter()
5334 .find(|own| own.id == *id)
5335 .map(|own| Some(own.clone()))
5336 })
5337 .collect()
5338 };
5339 let unread: Vec<NativeId> = ids
5340 .iter()
5341 .zip(&found)
5342 .filter(|(_, found)| found.is_none())
5343 .map(|(id, _)| id.clone())
5344 .collect();
5345 let mut read = Vec::with_capacity(unread.len());
5346 if let [one] = unread.as_slice() {
5347 read.push(self.item_by_id(one).await?);
5348 } else {
5349 for batch in unread.chunks(DETAIL_BATCH) {
5350 let data = match self
5351 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
5352 .await
5353 {
5354 Ok(data) => data,
5355 Err(error) if unresolvable_node(&error) => {
5356 for id in batch {
5357 read.push(self.item_by_id(id).await?);
5358 }
5359 continue;
5360 }
5361 Err(error) => return Err(error),
5362 };
5363 for (slot, id) in batch.iter().enumerate() {
5364 let node =
5365 data.get(format!("i{slot}"))
5366 .ok_or_else(|| SourceError::Malformed {
5367 message: format!(
5368 "GitHub answered a batch read with no item for {}",
5369 id.0
5370 ),
5371 })?;
5372 read.push(if node.is_null() {
5373 None
5374 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
5375 self.draft_by_id(id).await?
5376 } else {
5377 if optional_str(node, "__typename")? == Some("Issue")
5378 && required_str(node, "id")? != id.0
5379 {
5380 return Err(SourceError::Malformed {
5381 message: format!(
5382 "GitHub answered the read of {} with issue {}",
5383 id.0,
5384 required_str(node, "id")?
5385 ),
5386 });
5387 }
5388 self.resolve_issue(node).await?
5389 });
5390 }
5391 }
5392 }
5393 let mut read = read.into_iter();
5394 Ok(found
5395 .iter_mut()
5396 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
5397 .collect())
5398 }
5399
5400 fn read_ahead(
5401 &self,
5402 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
5403 self.read_ahead
5404 .lock()
5405 .map_err(|_| SourceError::Unavailable {
5406 message:
5407 "items read ahead for a copy were left inconsistent; run the command again"
5408 .into(),
5409 })
5410 }
5411
5412 fn resolved_cache(
5413 &self,
5414 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
5415 self.resolved_cache
5416 .lock()
5417 .map_err(|_| SourceError::Unavailable {
5418 message: "resolved item records were left inconsistent; run the command again"
5419 .into(),
5420 })
5421 }
5422
5423 /// Reuse a record this invocation already resolved. The mutation sender invalidates
5424 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
5425 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5426 let cached = self.resolved_cache()?.get(id).cloned();
5427 if let Some(item) = cached {
5428 return Ok(Some(item));
5429 }
5430 self.item_by_id(id).await
5431 }
5432
5433 /// Take out the item an issue read by reference reached for `id`, when one is held: the
5434 /// status write it was read for is the one caller, so no other read or write is ever
5435 /// answered from a read that selected only what that write needs.
5436 fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5437 Ok(self
5438 .held_cache
5439 .lock()
5440 .map_err(|_| SourceError::Unavailable {
5441 message: "items read by issue reference were left inconsistent; run the \
5442 command again"
5443 .into(),
5444 })?
5445 .remove(id))
5446 }
5447
5448 /// One board draft by its own id, with the board item it sits in — or `None` when no
5449 /// item of this board is that draft's.
5450 ///
5451 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
5452 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
5453 /// links a draft to one board item, so the page this read carries is the whole of that
5454 /// connection, and a page that reports more than it holds is refused rather than read
5455 /// as an answer about memberships nobody read.
5456 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5457 let data = self
5458 .graphql(
5459 graphql::DRAFT,
5460 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
5461 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
5462 )
5463 .await?;
5464 // Gone between the two reads is an answer — the draft is no longer there. Anything
5465 // else than the draft [`Self::reach`] was just told this id is, is not one.
5466 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
5467 return Ok(None);
5468 };
5469 if optional_str(draft, "__typename")? != Some("DraftIssue") {
5470 return Err(SourceError::Malformed {
5471 message: format!(
5472 "GitHub answered {} as a draft and then as something else",
5473 id.0
5474 ),
5475 });
5476 }
5477 if required_str(draft, "id")? != id.0 {
5478 return Err(SourceError::Malformed {
5479 message: format!("GitHub answered a different draft for {}", id.0),
5480 });
5481 }
5482 let memberships = draft
5483 .get("projectV2Items")
5484 .ok_or_else(|| SourceError::Malformed {
5485 message: format!("GitHub draft {} is missing projectV2Items", id.0),
5486 })?;
5487 let nodes = memberships
5488 .get("nodes")
5489 .and_then(Value::as_array)
5490 .ok_or_else(|| SourceError::Malformed {
5491 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
5492 })?;
5493 let info = memberships
5494 .get("pageInfo")
5495 .ok_or_else(|| SourceError::Malformed {
5496 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
5497 })?;
5498 // Read whether or not this board's entry is on the page: a page claiming more than
5499 // the one item GitHub links a draft to is a malformed answer either way.
5500 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
5501 return Err(SourceError::Malformed {
5502 message: format!(
5503 "GitHub draft {} reports more board items than the one GitHub links a draft \
5504 to",
5505 id.0
5506 ),
5507 });
5508 }
5509 if let Some(node) = nodes.first()
5510 && node
5511 .pointer("/project/number")
5512 .and_then(Value::as_u64)
5513 .is_none()
5514 {
5515 return Err(SourceError::Malformed {
5516 message: format!(
5517 "GitHub draft {} board item has no numeric project number",
5518 id.0
5519 ),
5520 });
5521 }
5522 let Some(held) = self.board_entry(nodes) else {
5523 return Ok(None);
5524 };
5525 if required_str(
5526 held.get("project").ok_or_else(|| SourceError::Malformed {
5527 message: format!("GitHub draft {} board item has no project", id.0),
5528 })?,
5529 "id",
5530 )? != self.board_fields().await?.id.as_str()
5531 {
5532 return Ok(None);
5533 }
5534 let item = json!({
5535 "id": required_str(held, "id")?,
5536 "project": held.get("project"),
5537 "fieldValues": held.get("fieldValues"),
5538 "content": draft,
5539 });
5540 self.resolve(&item)
5541 }
5542
5543 /// The board's own id and field definitions, for a write whose item does not carry
5544 /// them — never its items.
5545 ///
5546 /// A board this command has already listed supplies them, since it read them beside its
5547 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
5548 /// is consulted about which items the board holds: see the module documentation for
5549 /// why a question about one known item is answered by reading that item.
5550 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
5551 if let Some(board) = self.board_cache()?.as_ref() {
5552 return Ok(BoardFields {
5553 id: BoardId::parse(&board.id)?,
5554 fields: board.fields.clone(),
5555 });
5556 }
5557 if let Some(held) = self.fields_cache()?.clone() {
5558 return Ok(held);
5559 }
5560 let data = self
5561 .graphql(
5562 graphql::BOARD_FIELDS,
5563 json!({"owner":self.owner,"number":self.project_number,
5564 "nestedFirst":NESTED_PAGE_SIZE}),
5565 )
5566 .await?;
5567 self.fields_read(&data)
5568 }
5569
5570 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
5571 /// the rest of this command.
5572 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
5573 let board = data
5574 .pointer("/boardFields/projectV2")
5575 .filter(|value| !value.is_null())
5576 .ok_or_else(|| SourceError::Refused {
5577 message: format!(
5578 "GitHub project {}/{} was not found or is not visible to the token",
5579 self.owner, self.project_number
5580 ),
5581 })?;
5582 let read = BoardFields {
5583 id: BoardId::parse(required_str(board, "id")?)?,
5584 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5585 };
5586 *self.fields_cache()? = Some(read.clone());
5587 Ok(read)
5588 }
5589
5590 /// Read what creating an issue in `repository` needs and this command has not read yet —
5591 /// the board's fields and the repository's node id — in one request when it needs both.
5592 ///
5593 /// When either is already known this sends nothing, and the other is read by its own
5594 /// document where it is asked for, so no create reads anything twice.
5595 async fn creation_context(
5596 &self,
5597 repository: &RepositoryTarget,
5598 incoming: &Incoming<'_>,
5599 ) -> Result<(), SourceError> {
5600 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
5601 if fields_known || self.repository_cache()?.contains_key(repository) {
5602 return Ok(());
5603 }
5604 let data = self
5605 .graphql(
5606 graphql::CREATION_CONTEXT,
5607 json!({"owner":self.owner,"number":self.project_number,
5608 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
5609 "repositoryName":repository.name}),
5610 )
5611 .await?;
5612 self.fields_read(&data)?;
5613 self.repository_read(&data, repository, incoming)?;
5614 Ok(())
5615 }
5616
5617 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
5618 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
5619 self.fields_cache
5620 .lock()
5621 .map_err(|_| SourceError::Unavailable {
5622 message: "this source's view of the board's fields was left inconsistent by an \
5623 earlier failure; next: run the command again"
5624 .into(),
5625 })
5626 }
5627
5628 /// What a write to `item` needs of the board, read off that item when it says enough and
5629 /// off [`Self::board_fields`] when it does not.
5630 ///
5631 /// A node read of an item names its board and carries the definition of every field it
5632 /// holds a value of — so an item naming its board, holding a value of the origin field,
5633 /// and, when the write carries a status, holding a `Status` value, needs no read of the
5634 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
5635 /// of may still be on the board, and a view reading it as absent would refuse a write the
5636 /// board can take or skip a field write the board needs, so such an item — and a create,
5637 /// which has no item yet — takes the board's fields from their own read instead.
5638 async fn fields_for(
5639 &self,
5640 item: Option<&Resolved>,
5641 writes_status: bool,
5642 selects_priority: bool,
5643 ) -> Result<BoardFields, SourceError> {
5644 if let Some(board) = item.and_then(Resolved::carried_board) {
5645 return Ok(board);
5646 }
5647 if let Some(item) = item
5648 && let Some(board_id) = item.named_board()
5649 && item.defines(ORIGIN_FIELD)
5650 && (!writes_status || item.defines("Status"))
5651 && (!selects_priority || item.defines(PRIORITY_FIELD))
5652 && self
5653 .metadata_fields
5654 .iter()
5655 .all(|projection| item.defines(&projection.field))
5656 {
5657 return Ok(BoardFields {
5658 id: board_id,
5659 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5660 });
5661 }
5662 self.board_fields().await
5663 }
5664
5665 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
5666 /// when that id names nothing here with a sub-issue relationship to walk.
5667 ///
5668 /// `None` and an empty answer are different: `None` is *this is not an issue of this
5669 /// GitHub*, which is what sends a project selector on to be read as a name, and an
5670 /// empty vector is a project that holds nothing.
5671 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
5672 let mut after: Option<String> = None;
5673 let mut children = Vec::new();
5674 // The project issue as the first page carried it, held once the walk has ended.
5675 let mut parent: Option<Resolved> = None;
5676 loop {
5677 let asked = self
5678 .graphql(
5679 graphql::SUB_ISSUES,
5680 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
5681 "nestedFirst":NESTED_PAGE_SIZE,
5682 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5683 )
5684 .await;
5685 let data = match asked {
5686 Ok(data) => data,
5687 // A string that is not a node id at all is not a failure to report: it is
5688 // the ordinary answer to a selector naming a project by its name.
5689 Err(error) if unresolvable_node(&error) => return Ok(None),
5690 Err(error) => return Err(error),
5691 };
5692 let Some(connection) = data
5693 .pointer("/node/subIssues")
5694 .filter(|value| !value.is_null())
5695 else {
5696 // No such node, or one with no sub-issue relationship — a board draft is
5697 // the one this board can really hold.
5698 return Ok(None);
5699 };
5700 if after.is_none()
5701 && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
5702 {
5703 parent = self
5704 .resolve_issue(node)
5705 .await?
5706 .filter(|resolved| resolved.id == *id);
5707 }
5708 for node in connection
5709 .get("nodes")
5710 .and_then(Value::as_array)
5711 .ok_or_else(|| SourceError::Malformed {
5712 message: "GitHub subIssues.nodes is not an array".into(),
5713 })?
5714 {
5715 if let Some(resolved) = self.resolve_issue(node).await? {
5716 children.push(resolved);
5717 }
5718 }
5719 let info = connection
5720 .get("pageInfo")
5721 .ok_or_else(|| SourceError::Malformed {
5722 message: "GitHub subIssues connection has no pageInfo".into(),
5723 })?;
5724 let next = required_bool(info, "hasNextPage")?
5725 .then(|| required_str(info, "endCursor"))
5726 .transpose()?;
5727 match next {
5728 Some(next) => {
5729 validate_cursor_progress(after.as_deref(), next)?;
5730 after = Some(next.to_owned());
5731 }
5732 None => {
5733 // Held only once the walk has ended, so one failing part way holds
5734 // nothing and every read it would have answered is made.
5735 let mut carried = self.carried()?;
5736 for item in parent.into_iter().chain(children.iter().cloned()) {
5737 carried.insert(item.id.clone(), item);
5738 }
5739 return Ok(Some(children));
5740 }
5741 }
5742 }
5743 }
5744
5745 fn carried(
5746 &self,
5747 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
5748 self.carried.lock().map_err(|_| SourceError::Unavailable {
5749 message: "items a project's listing carried were left inconsistent; run the \
5750 command again"
5751 .into(),
5752 })
5753 }
5754
5755 /// Which issue of this board a project *name* is, or `None` when none is.
5756 ///
5757 /// One bounded query which filters on that name at the server, rather than a walk of
5758 /// every issue the board holds. The name is compared again here: the qualifier narrows
5759 /// what GitHub sends, and this source decides what it names.
5760 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
5761 let search = self.board_search(Some(&title_qualifier(name)));
5762 let mut after = None;
5763 loop {
5764 let (candidates, next) = self
5765 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5766 .await?;
5767 if let Some(item) = candidates.into_iter().find(|item| {
5768 item.kind == BoardKind::Work(ItemKind::Project)
5769 && item.title.eq_ignore_ascii_case(name)
5770 }) {
5771 return Ok(Some(item.id));
5772 }
5773 match next {
5774 Some(next) => after = Some(next),
5775 None => return Ok(None),
5776 }
5777 }
5778 }
5779
5780 /// Everything filed under one project of this board: the sub-issues of the issue that
5781 /// project is.
5782 ///
5783 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
5784 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
5785 /// gains projects, or as another project gains tasks.
5786 ///
5787 /// A qualified id names the issue and is asked for its sub-issues directly: one
5788 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
5789 /// read as a project *name*, which costs the one bounded search
5790 /// [`Self::project_by_name`] makes.
5791 ///
5792 /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
5793 /// — and each answer is still cut to the items whose parent is this project, so one this
5794 /// process has since filed elsewhere is not reported here, and completed with what this
5795 /// process filed under it.
5796 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
5797 let held = self.children_cache()?.get(selector).cloned();
5798 let (project, children) = match held {
5799 Some(held) => held,
5800 None => {
5801 let answered = match self.sub_issues(selector).await? {
5802 Some(children) => (selector.clone(), children),
5803 None => match self.project_by_name(&selector.0).await? {
5804 Some(project) => {
5805 let children = self.sub_issues(&project).await?.unwrap_or_default();
5806 (project, children)
5807 }
5808 None => return Ok(Vec::new()),
5809 },
5810 };
5811 self.children_cache()?
5812 .insert(selector.clone(), answered.clone());
5813 answered
5814 }
5815 };
5816 let mut children: Vec<Resolved> = children
5817 .into_iter()
5818 .filter(|child| child.parent.as_ref() == Some(&project))
5819 .collect();
5820 for own in self.updated()?.iter() {
5821 if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
5822 children.push(own.clone());
5823 }
5824 }
5825 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
5826 }
5827
5828 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
5829 /// completed with what this run wrote — the candidates a comment-activity read confirms.
5830 ///
5831 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
5832 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
5833 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
5834 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
5835 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
5836 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
5837 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
5838 /// rather than silently narrowing a caller's answer.
5839 ///
5840 /// The instant is written to the second, rounded down, which can only widen what the
5841 /// search returns; confirmation against each candidate's own comments is what makes the
5842 /// answer exact. The search is an index that lags a write by a second or two — the module
5843 /// documentation records it — so a caller that asks again from its last instant should
5844 /// overlap the two by more than that.
5845 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
5846 let found = self.searched(&updated_qualifier(since)).await?;
5847 self.completed_with_written(found, |_| true)
5848 }
5849
5850 /// Every issue of this board GitHub's issue search reports for the board-scoped search
5851 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
5852 ///
5853 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
5854 /// own record is the fresher of the two.
5855 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
5856 let search = self.board_search(Some(also));
5857 let mut after: Option<String> = None;
5858 let mut found = Vec::new();
5859 loop {
5860 let (page, next) = self
5861 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5862 .await?;
5863 found.extend(page);
5864 match next {
5865 Some(next) => after = Some(next),
5866 None => return Ok(found),
5867 }
5868 }
5869 }
5870
5871 /// A bounded task answer; the versioned cursor carries the connection position, how
5872 /// many rows of the page starting there were already handed out, and the own-write ids
5873 /// already observed, including across a new source instance.
5874 ///
5875 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
5876 /// sliced from the pages it needs; why is the module documentation's paging contract.
5877 async fn search_tasks(
5878 &self,
5879 query: &TaskQuery,
5880 page: &PageRequest,
5881 also: &str,
5882 ) -> Result<Page<Task>, SourceError> {
5883 let mut position = match &page.cursor {
5884 None => SearchPosition::default(),
5885 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
5886 .ok()
5887 .filter(|position| {
5888 position.version == SEARCH_CURSOR_VERSION
5889 && position.connection.valid_resume(position.offset)
5890 })
5891 .ok_or_else(|| SourceError::Config {
5892 message: "page cursor is invalid".into(),
5893 })?,
5894 };
5895 let search = self.board_search(Some(also));
5896 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5897 let own = self.with_own_writes(Vec::new())?;
5898 // An issue this process commented on is a candidate of a comment-activity read
5899 // whether or not the search has caught up with the comment; see `Self::commented`.
5900 let commented = match query.commented_since {
5901 Some(_) => self.commented()?.clone(),
5902 None => Vec::new(),
5903 };
5904 for id in own.iter().map(|item| &item.id).chain(&commented) {
5905 if !position.own.contains(id) {
5906 position.own.push(id.clone());
5907 }
5908 }
5909 let mut tasks = Vec::new();
5910 while !position.connection.exhausted() && tasks.len() < limit {
5911 let first = SEARCH_PAGE_SIZE;
5912 // Page size is part of the key: a short cached answer cannot answer a wider ask.
5913 let key =
5914 serde_json::to_string(&("page", &search, &position.connection.after(), first))
5915 .expect("search page key is serializable");
5916 let cached = if query.commented_since.is_none() {
5917 self.narrowed_cache()?.get(&key).cloned()
5918 } else {
5919 None
5920 };
5921 let (found, next) = match cached {
5922 Some(found) => {
5923 let next = self
5924 .search_next
5925 .lock()
5926 .map_err(|_| SourceError::Unavailable {
5927 message:
5928 "search pagination was left inconsistent; run the command again"
5929 .into(),
5930 })?
5931 .get(&key)
5932 .cloned()
5933 .flatten();
5934 (found, next)
5935 }
5936 None => {
5937 let (found, next) = self
5938 .search_page(&search, first, position.connection.after())
5939 .await?;
5940 if query.commented_since.is_none() {
5941 self.search_next
5942 .lock()
5943 .map_err(|_| SourceError::Unavailable {
5944 message:
5945 "search pagination was left inconsistent; run the command again"
5946 .into(),
5947 })?
5948 .insert(key.clone(), next.clone());
5949 self.narrowed_cache()?.insert(key, found.clone());
5950 }
5951 (found, next)
5952 }
5953 };
5954 let rows = found.len();
5955 for mut item in found.into_iter().skip(position.offset) {
5956 if tasks.len() == limit {
5957 break;
5958 }
5959 position.offset += 1;
5960 if position.own.contains(&item.id) {
5961 if position.seen.contains(&item.id) {
5962 continue;
5963 }
5964 position.seen.push(item.id.clone());
5965 // The search's own copy of an issue this process only commented on is as
5966 // good as a node read of it, since its comments are read either way.
5967 let only_commented = commented.contains(&item.id)
5968 && !own.iter().any(|written| written.id == item.id);
5969 if !only_commented {
5970 let updated_at = item.updated_at;
5971 let Some(written) = self.search_written(&own, &item.id).await? else {
5972 continue;
5973 };
5974 item = written;
5975 item.updated_at = item.updated_at.max(updated_at);
5976 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5977 }
5978 }
5979 if item.kind == BoardKind::Work(ItemKind::Task) {
5980 let task = item.task()?;
5981 if task_matches(&task, query, &query.project)
5982 && self.commented_since(&item, query.commented_since).await?
5983 {
5984 tasks.push(task);
5985 }
5986 }
5987 }
5988 if position.offset < rows {
5989 continue;
5990 }
5991 position.offset = 0;
5992 position.connection = match next {
5993 Some(after) => SearchConnection::Continuing {
5994 after: Cursor(after),
5995 },
5996 None => SearchConnection::Exhausted {},
5997 };
5998 }
5999 if position.connection.exhausted() {
6000 for id in position.own.clone() {
6001 if position.seen.contains(&id) {
6002 continue;
6003 }
6004 if tasks.len() == limit {
6005 break;
6006 }
6007 position.seen.push(id.clone());
6008 let Some(item) = self.search_written(&own, &id).await? else {
6009 continue;
6010 };
6011 if item.kind == BoardKind::Work(ItemKind::Task) {
6012 let task = item.task()?;
6013 if task_matches(&task, query, &query.project)
6014 && self.commented_since(&item, query.commented_since).await?
6015 {
6016 tasks.push(task);
6017 }
6018 }
6019 }
6020 }
6021 let more = !position.connection.exhausted()
6022 || position.own.iter().any(|id| !position.seen.contains(id));
6023 Ok(Page {
6024 items: tasks,
6025 next: more.then(|| {
6026 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
6027 }),
6028 })
6029 }
6030
6031 /// A resumed process has the ids but no write records; resolve only a record the
6032 /// current page needs, by its uncached node read rather than the lagging search index.
6033 async fn search_written(
6034 &self,
6035 own: &[Resolved],
6036 id: &NativeId,
6037 ) -> Result<Option<Resolved>, SourceError> {
6038 match own.iter().find(|item| item.id == *id) {
6039 Some(item) => Ok(Some(item.clone())),
6040 None => self.item_by_id(id).await,
6041 }
6042 }
6043
6044 /// The candidates for a task query carrying a text, metadata or origin predicate, read
6045 /// without enumerating the board — or `None` for a query carrying none of the three, which
6046 /// keeps the reads it always had.
6047 ///
6048 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
6049 /// because it names at most a handful of items. Text and metadata are answered by one
6050 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
6051 /// further by `updated:>=` when the query also asks for comment activity, since both
6052 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
6053 /// process afterwards by the same predicates [`task_matches`] applies to every read.
6054 ///
6055 /// Completed with what this process wrote, its own record winning over the index's copy
6056 /// of the same item: see [`Self::with_own_writes`].
6057 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
6058 let asked = match (&query.origin, narrowing_qualifiers(query)) {
6059 (Some(origin), _) => Narrowing::Origin(origin.clone()),
6060 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
6061 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
6062 None => qualifiers,
6063 }),
6064 (None, None) => return Ok(None),
6065 };
6066 // A question about comment activity is asked afresh every time, as it always was: it
6067 // is the one a caller polls from one source while waiting for the index, and an
6068 // answer held from the first poll would be the answer to every later one.
6069 let key = query.commented_since.is_none().then(|| asked.key());
6070 let cached = match &key {
6071 Some(key) => self.narrowed_cache()?.get(key).cloned(),
6072 None => None,
6073 };
6074 let found = match cached {
6075 Some(found) => found,
6076 None => {
6077 let found = match &asked {
6078 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
6079 Narrowing::Search(also) => self.searched(also).await?,
6080 };
6081 if let Some(key) = key {
6082 self.narrowed_cache()?.insert(key, found.clone());
6083 }
6084 found
6085 }
6086 };
6087 self.with_own_writes(found).map(Some)
6088 }
6089
6090 /// The candidates for a project or unscoped document query carrying a searchable text,
6091 /// read without enumerating the board — or `None` for a query with no text or a blank one,
6092 /// which keeps the read it always had.
6093 ///
6094 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
6095 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
6096 /// costs is the issues that match and never the board. Its answer is held for the command
6097 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
6098 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
6099 /// substring rule, exactly as an item of the wider read was, and is completed with what this
6100 /// process wrote: see [`Self::with_own_writes`].
6101 async fn text_searched(
6102 &self,
6103 text: Option<&TextQuery>,
6104 ) -> Result<Option<Vec<Resolved>>, SourceError> {
6105 let Some(also) = text_qualifiers(text) else {
6106 return Ok(None);
6107 };
6108 let key = Narrowing::Search(also.clone()).key();
6109 let cached = self.narrowed_cache()?.get(&key).cloned();
6110 let found = match cached {
6111 Some(found) => found,
6112 None => {
6113 let found = self.searched(&also).await?;
6114 self.narrowed_cache()?.insert(key, found.clone());
6115 found
6116 }
6117 };
6118 self.with_own_writes(found).map(Some)
6119 }
6120
6121 /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
6122 /// [`Self::narrowed`] reads for a task query carrying that origin — one
6123 /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
6124 /// what this process wrote — each confirmed against its own origin entry.
6125 ///
6126 /// What answers a project or document listing by origin, so finding every copy of one plan
6127 /// costs the items that carry its origin and never a page of the board.
6128 async fn copied_from(
6129 &self,
6130 origin: &str,
6131 kind: BoardKind,
6132 ) -> Result<Vec<Resolved>, SourceError> {
6133 let wanted = TaskQuery {
6134 origin: Some(origin.to_owned()),
6135 ..TaskQuery::default()
6136 };
6137 Ok(self
6138 .narrowed(&wanted)
6139 .await?
6140 .unwrap_or_default()
6141 .into_iter()
6142 .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
6143 .collect())
6144 }
6145
6146 /// Every item of this board that may carry `origin` — a superset of those that do — found
6147 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
6148 ///
6149 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
6150 /// which reads the field every carrier holds, whichever release wrote it — and the
6151 /// board-scoped issue search for the same id as a phrase in the body, where this source
6152 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
6153 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
6154 /// query's, exactly.
6155 ///
6156 /// Both connections are walked to exhaustion, each from its own cursor. One that has
6157 /// already ended is sent its last cursor again, which answers an empty page, so the one
6158 /// document serves every page of either. What the two leave is stated in the module
6159 /// documentation: a carrier another process added within the last second or two, before
6160 /// either index has it.
6161 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
6162 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
6163 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
6164 let mut items_after: Option<String> = None;
6165 let mut search_after: Option<String> = None;
6166 let mut found: Vec<Resolved> = Vec::new();
6167 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
6168 if !found.iter().any(|held| held.id == resolved.id) {
6169 found.push(resolved);
6170 }
6171 };
6172 loop {
6173 let data = self
6174 .graphql(
6175 graphql::ORIGIN_LOOKUP,
6176 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
6177 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
6178 "itemsAfter":items_after,"searchAfter":search_after,
6179 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6180 "duplicates":true}),
6181 )
6182 .await?;
6183 let items = data
6184 .pointer("/originItems/projectV2/items")
6185 .filter(|value| !value.is_null())
6186 .ok_or_else(|| SourceError::Refused {
6187 message: format!(
6188 "GitHub project {}/{} was not found or is not visible to the token",
6189 self.owner, self.project_number
6190 ),
6191 })?;
6192 for item in optional_nodes(Some(items), "project items")?
6193 .into_iter()
6194 .flatten()
6195 {
6196 // The board's own items list its drafts too, and a draft is not an issue: no
6197 // narrowed read answers with one, whatever its origin field holds.
6198 if let Some(resolved) = self.resolve(item)?
6199 && resolved.content_kind == ContentKind::Issue
6200 {
6201 keep(resolved, &mut found);
6202 }
6203 }
6204 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
6205 message: "GitHub search response has no search connection".into(),
6206 })?;
6207 for node in optional_nodes(Some(searched), "search")?
6208 .into_iter()
6209 .flatten()
6210 {
6211 if let Some(resolved) = self.resolve_issue(node).await? {
6212 keep(resolved, &mut found);
6213 }
6214 }
6215 let items_next = resumed(items, items_after.as_deref())?;
6216 let search_next = resumed(searched, search_after.as_deref())?;
6217 if !items_next.has_more() && !search_next.has_more() {
6218 return Ok(found);
6219 }
6220 items_after = items_next.cursor();
6221 search_after = search_next.cursor();
6222 }
6223 }
6224
6225 /// `found`, with every item this process created or wrote in its place, and every one of
6226 /// them the read did not report added.
6227 ///
6228 /// This process's own record wins over the read's copy of the same item, because a read
6229 /// of an item written moments ago can still be behind what was written onto it — the
6230 /// origin field included, which is the one a narrowed read is confirmed against — and a
6231 /// read that still names an item under a predicate this process's write moved it out of
6232 /// must not return it. The one thing the read knows that the record cannot is when GitHub
6233 /// last saw the item change, which is what a comment-activity read rules a candidate out
6234 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
6235 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
6236 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
6237 // A board draft is not an issue, so no narrowed read returns one, and this process
6238 // having written one does not make it an answer either.
6239 let own: Vec<Resolved> = self
6240 .created()?
6241 .iter()
6242 .chain(self.updated()?.iter())
6243 .filter(|own| own.content_kind == ContentKind::Issue)
6244 .cloned()
6245 .collect();
6246 for mut own in own {
6247 self.resolved_cache()?.insert(own.id.clone(), own.clone());
6248 match found.iter_mut().find(|read| read.id == own.id) {
6249 Some(read) => {
6250 own.updated_at = own.updated_at.max(read.updated_at);
6251 *read = own;
6252 }
6253 None => found.push(own),
6254 }
6255 }
6256 Ok(found)
6257 }
6258
6259 /// Whether `item` has a comment created or last edited at or after `since` — always, when
6260 /// there is no instant to hold it to.
6261 ///
6262 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
6263 /// or after the instant moved it there: an issue not updated since holds no such comment,
6264 /// and its comments are never asked for — unless this process commented on it in this
6265 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
6266 /// only as far as the first that matches. A board draft is not an issue and has no
6267 /// comments, so it never matches.
6268 async fn commented_since(
6269 &self,
6270 item: &Resolved,
6271 since: Option<DateTime<Utc>>,
6272 ) -> Result<bool, SourceError> {
6273 let Some(since) = since else {
6274 return Ok(true);
6275 };
6276 if item.content_kind == ContentKind::DraftIssue {
6277 return Ok(false);
6278 }
6279 // An `updatedAt` this process's own record or a lagging index holds can predate a
6280 // comment this process wrote since, so only an issue it did not comment on is ruled
6281 // out by one.
6282 if item.updated_at.is_some_and(|updated| updated < since)
6283 && !self.commented()?.contains(&item.id)
6284 {
6285 return Ok(false);
6286 }
6287 let query = TaskQuery {
6288 commented_since: Some(since),
6289 ..TaskQuery::default()
6290 };
6291 let mut after: Option<String> = None;
6292 loop {
6293 let data = self
6294 .graphql(
6295 graphql::ISSUE_COMMENTS,
6296 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
6297 )
6298 .await?;
6299 let Some(connection) = data
6300 .get("node")
6301 .filter(|value| !value.is_null())
6302 .and_then(|node| node.get("comments"))
6303 .filter(|value| !value.is_null())
6304 else {
6305 // Removed since the search reported it: no longer an issue with comments.
6306 return Ok(false);
6307 };
6308 let comments = optional_nodes(Some(connection), "issue comments")?
6309 .into_iter()
6310 .flatten()
6311 .map(comment_from)
6312 .collect::<Result<Vec<_>, _>>()?;
6313 if query.comments_match(&comments) {
6314 return Ok(true);
6315 }
6316 match next_cursor(connection)? {
6317 Some(next) => {
6318 validate_cursor_progress(after.as_deref(), &next.0)?;
6319 after = Some(next.0);
6320 }
6321 None => return Ok(false),
6322 }
6323 }
6324 }
6325
6326 /// Every item on the board: the union of both enumerations GitHub offers of one.
6327 ///
6328 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
6329 /// board **draft** and reads the board's own fields beside its items, and only the search
6330 /// reports an item that connection is behind on. The module documentation is where the lag and the
6331 /// measurements behind it are written down.
6332 ///
6333 /// A search result is admitted on the same terms as any other issue this source reaches
6334 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
6335 /// names *this* board — so an issue the index still believes is here after it was taken
6336 /// off is refused rather than reported.
6337 ///
6338 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
6339 /// which is what the cache could otherwise have broken.
6340 async fn board(&self) -> Result<Board, SourceError> {
6341 self.board_completed_by(None).await
6342 }
6343
6344 /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
6345 /// that half could add is then answered only if it holds.
6346 async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
6347 let cached = self.board_cache()?.clone();
6348 let mut board = match cached {
6349 Some(board) => board,
6350 None => {
6351 let read = self.read_board().await?;
6352 *self.board_cache()? = Some(read.clone());
6353 read
6354 }
6355 };
6356 let searched = match also {
6357 None => self.searched_issues().await?,
6358 Some(also) => self.searched_narrowly(also).await?,
6359 };
6360 for held in searched {
6361 if !board.items.iter().any(|item| item.id == held.id) {
6362 board.items.push(held);
6363 }
6364 }
6365 for own in self.created()?.iter() {
6366 if !board.items.iter().any(|item| item.id == own.id) {
6367 board.items.push(own.clone());
6368 }
6369 }
6370 Ok(board)
6371 }
6372
6373 /// This process's own view of the board, or the refusal a poisoned lock is.
6374 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
6375 self.board_cache
6376 .lock()
6377 .map_err(|_| SourceError::Unavailable {
6378 message: "this source's view of the board was left inconsistent by an earlier \
6379 failure; next: run the command again"
6380 .into(),
6381 })
6382 }
6383
6384 /// Bring this process's own view of the board up to an item it has just written.
6385 ///
6386 /// A created item goes to `created`, which is what completes a board read GitHub's own
6387 /// eventual consistency has left behind. An item that was already there is replaced
6388 /// where it sits, so a second write of it in the same command reads its real parent
6389 /// rather than the one it had before the first write.
6390 ///
6391 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
6392 /// that wins: an item this same run created is held in `created` and not in the cached
6393 /// board, and `board` completes the cached board *from* `created`, so replacing only
6394 /// the cached copy of such an item replaces nothing and the read still reports the
6395 /// title it was created with. The search is the third, and it is the one an item the
6396 /// board's own projection is behind on sits in *alone* — which is exactly the item this
6397 /// source is least able to re-read, so leaving it out would put the stale title back on
6398 /// the only items the completion in [`Self::board`] exists for.
6399 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
6400 self.resolved_cache()?.insert(item.id.clone(), item.clone());
6401 if created {
6402 self.created()?.push(item);
6403 return Ok(());
6404 }
6405 {
6406 let mut own = self.created()?;
6407 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
6408 *held = item;
6409 return Ok(());
6410 }
6411 }
6412 {
6413 let mut own = self.updated()?;
6414 match own.iter_mut().find(|held| held.id == item.id) {
6415 Some(held) => *held = item.clone(),
6416 None => own.push(item.clone()),
6417 }
6418 }
6419 if let Some(board) = self.board_cache()?.as_mut()
6420 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
6421 {
6422 *held = item.clone();
6423 }
6424 if let Some(found) = self.search_cache()?.as_mut()
6425 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
6426 {
6427 *held = item.clone();
6428 }
6429 for found in self.narrowed_cache()?.values_mut() {
6430 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6431 *held = item.clone();
6432 }
6433 }
6434 for (_, found) in self.children_cache()?.values_mut() {
6435 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6436 *held = item.clone();
6437 }
6438 }
6439 Ok(())
6440 }
6441
6442 /// Forget one item this process has just deleted, from every half of its own view.
6443 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
6444 self.resolved_cache()?.remove(id);
6445 self.read_ahead()?.remove(id);
6446 self.carried()?.remove(id);
6447 self.created()?.retain(|own| own.id != *id);
6448 self.updated()?.retain(|own| own.id != *id);
6449 self.commented()?.retain(|own| own != id);
6450 if let Some(board) = self.board_cache()?.as_mut() {
6451 board.items.retain(|item| item.id != *id);
6452 }
6453 if let Some(found) = self.search_cache()?.as_mut() {
6454 found.retain(|item| item.id != *id);
6455 }
6456 for found in self.narrowed_cache()?.values_mut() {
6457 found.retain(|item| item.id != *id);
6458 }
6459 for (_, found) in self.children_cache()?.values_mut() {
6460 found.retain(|item| item.id != *id);
6461 }
6462 Ok(())
6463 }
6464
6465 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
6466 fn narrowed_cache(
6467 &self,
6468 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
6469 self.narrowed_cache
6470 .lock()
6471 .map_err(|_| SourceError::Unavailable {
6472 message: "this source's view of a narrowed read was left inconsistent by an \
6473 earlier failure; next: run the command again"
6474 .into(),
6475 })
6476 }
6477
6478 /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
6479 /// is.
6480 fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
6481 self.children_cache
6482 .lock()
6483 .map_err(|_| SourceError::Unavailable {
6484 message: "this source's view of a project's tasks was left inconsistent by an \
6485 earlier failure; next: run the command again"
6486 .into(),
6487 })
6488 }
6489
6490 /// Every page of the board, read from GitHub.
6491 async fn read_board(&self) -> Result<Board, SourceError> {
6492 let mut after: Option<String> = None;
6493 let mut items = Vec::new();
6494 let mut board;
6495 loop {
6496 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
6497 for item in page
6498 .pointer("/items/nodes")
6499 .and_then(Value::as_array)
6500 .ok_or_else(|| SourceError::Malformed {
6501 message: "GitHub project items.nodes is not an array".into(),
6502 })?
6503 {
6504 if let Some(resolved) = self.resolve(item)? {
6505 items.push(resolved);
6506 }
6507 }
6508 let info = page
6509 .pointer("/items/pageInfo")
6510 .ok_or_else(|| SourceError::Malformed {
6511 message: "GitHub project items have no pageInfo".into(),
6512 })?;
6513 let has_next = required_bool(info, "hasNextPage")?;
6514 let next = has_next
6515 .then(|| required_str(info, "endCursor"))
6516 .transpose()?;
6517 board = page.clone();
6518 match next {
6519 Some(next) => {
6520 validate_cursor_progress(after.as_deref(), next)?;
6521 after = Some(next.to_owned());
6522 }
6523 None => break,
6524 }
6525 }
6526 Ok(Board {
6527 id: required_str(&board, "id")?.to_owned(),
6528 fields: board.get("fields").cloned().unwrap_or(Value::Null),
6529 items,
6530 })
6531 }
6532
6533 /// The existing items this source has written, for completing a narrowed read that is
6534 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
6535 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6536 self.updated.lock().map_err(|_| SourceError::Unavailable {
6537 message: "this source's record of what it wrote in this run was left inconsistent \
6538 by an earlier failure; next: run the command again"
6539 .into(),
6540 })
6541 }
6542
6543 /// The issues this source has commented on in this command; see
6544 /// [`Self::commented`](GitHubProjectsSource::commented).
6545 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
6546 self.commented.lock().map_err(|_| SourceError::Unavailable {
6547 message: "this source's record of what it commented on in this run was left \
6548 inconsistent by an earlier failure; next: run the command again"
6549 .into(),
6550 })
6551 }
6552
6553 /// Called only once GitHub has answered the comment write, so an issue whose comment
6554 /// failed is never made a candidate a later read would pay a node read for.
6555 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
6556 let mut commented = self.commented()?;
6557 if !commented.contains(issue) {
6558 commented.push(issue.clone());
6559 }
6560 Ok(())
6561 }
6562
6563 /// The items this source has created, for completing a board read that is behind.
6564 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6565 self.created.lock().map_err(|_| SourceError::Unavailable {
6566 message: "this source's record of what it created in this run was left \
6567 inconsistent by an earlier failure; next: run the command again"
6568 .into(),
6569 })
6570 }
6571
6572 /// One board item as this source reports it, or `None` for content it ignores.
6573 ///
6574 /// A pull request is neither a project nor a task — it is somebody's change, not a
6575 /// unit of plan — and an item whose content the token cannot see has nothing to
6576 /// report at all.
6577 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
6578 let resolved = self.resolve_unremembered(item)?;
6579 if let Some(resolved) = &resolved {
6580 self.resolved_cache()?
6581 .insert(resolved.id.clone(), resolved.clone());
6582 }
6583 Ok(resolved)
6584 }
6585
6586 /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
6587 /// write's own response needs, which says nothing this process should answer a later read
6588 /// with.
6589 fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
6590 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
6591 message: "GitHub project item is missing content".into(),
6592 })?;
6593 if content.is_null() {
6594 return Ok(None);
6595 }
6596 let content_kind = match required_str(content, "__typename")? {
6597 "Issue" => ContentKind::Issue,
6598 "DraftIssue" => ContentKind::DraftIssue,
6599 _ => return Ok(None),
6600 };
6601 let field_values = item
6602 .get("fieldValues")
6603 .ok_or_else(|| SourceError::Malformed {
6604 message: "GitHub project item is missing fieldValues".into(),
6605 })?;
6606 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
6607 let nodes = field_values
6608 .get("nodes")
6609 .and_then(Value::as_array)
6610 .ok_or_else(|| SourceError::Malformed {
6611 message: "GitHub project item fieldValues.nodes is not an array".into(),
6612 })?;
6613 if let Some(labels) = content.get("labels") {
6614 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
6615 }
6616 let raw_body = optional_str(content, "body")?.map(str::to_owned);
6617 let (body, slot) = metadata_body(raw_body.clone())?;
6618 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
6619 .map(|id| NativeId(id.to_owned()));
6620 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
6621 // to read one from; it is a task, and never a project.
6622 let sub_issues = match content_kind {
6623 ContentKind::Issue => sub_issue_total(content)?,
6624 ContentKind::DraftIssue => 0,
6625 };
6626 let content_id = required_str(content, "id")?;
6627 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
6628 message: format!("GitHub issue {content_id}: {message}"),
6629 })?;
6630 let raw_title = required_str(content, "title")?;
6631 let kind = BoardKind::of(raw_title, parent.is_some(), sub_issues, marked);
6632 // The title a person wrote, which for a document is the one without the prefix —
6633 // the same way `content` above is the body without this source's metadata slot.
6634 let title = match kind {
6635 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
6636 BoardKind::Work(_) => raw_title.to_owned(),
6637 };
6638 let own_repository = content
6639 .pointer("/repository/nameWithOwner")
6640 .and_then(Value::as_str)
6641 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
6642 .transpose()
6643 .map_err(|message| SourceError::Malformed { message })?;
6644 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
6645 Repository::from_metadata(&slot)
6646 .map_err(|message| SourceError::Malformed { message })?
6647 } else {
6648 own_repository.clone().into_iter().collect()
6649 };
6650 let classification = Classification::from_metadata(&slot)
6651 .map_err(|message| SourceError::Malformed { message })?;
6652 let id = NativeId(content_id.to_owned());
6653 // Read only for a task, because only a task has either list: a project or a
6654 // document holding one of these keys holds nothing this source reports, and the
6655 // keys are left out of its caller-visible metadata all the same.
6656 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
6657 let listed = |key: &str| {
6658 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
6659 .map_err(|message| SourceError::Malformed { message })
6660 };
6661 (
6662 listed(TaskRef::DELIVERS_KEY)?,
6663 listed(TaskRef::DELIVERED_BY_KEY)?,
6664 )
6665 } else {
6666 (Vec::new(), Vec::new())
6667 };
6668 let (option, closed, reason) = Self::status_parts(nodes, content)?;
6669 let priority = self.held_priority(nodes)?;
6670 let item_labels = labels(content)?;
6671 // Present when the item was reached through its own issue, whose board entry
6672 // names the board; a read of the board's own items has the board already. An
6673 // empty id names nothing a field write could address, so it is read as absent and
6674 // the write goes back to reading the board.
6675 let board_id = item
6676 .pointer("/project/id")
6677 .and_then(Value::as_str)
6678 .filter(|id| !id.is_empty());
6679 let resolved = Resolved {
6680 item_id: required_str(item, "id")?.to_owned(),
6681 id,
6682 content_kind,
6683 kind,
6684 title,
6685 body: body.filter(|value| !value.is_empty()),
6686 raw_body,
6687 status: self
6688 .statuses
6689 .status(kind.status_kind(), option, closed, reason),
6690 option: option.map(str::to_owned),
6691 priority,
6692 closed,
6693 reason: closed.then(|| reason.map(str::to_owned)).flatten(),
6694 delivers,
6695 delivered_by,
6696 labels: item_labels.clone(),
6697 parent,
6698 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
6699 projected: self.held_projections(nodes)?,
6700 number: match content_kind {
6701 ContentKind::Issue => Some(issue_number(content)?),
6702 // A draft is filed in no repository, so nothing ever numbered it:
6703 // `DraftIssue` declares no `number` at all, exactly as it declares no
6704 // `subIssuesSummary` the branch above reads.
6705 ContentKind::DraftIssue => None,
6706 },
6707 url: optional_str(content, "url")?.map(str::to_owned),
6708 created_at: optional_time(content, "createdAt")?,
6709 updated_at: optional_time(content, "updatedAt")?,
6710 item_updated_at: optional_time(item, "updatedAt")?,
6711 comment_count: content
6712 .pointer("/commentCount/totalCount")
6713 .and_then(Value::as_u64),
6714 relationship_events: content
6715 .pointer("/relationshipEvents/totalCount")
6716 .and_then(Value::as_u64),
6717 // The issue node is stamped wherever it was read; a board item a read of the issue
6718 // put together from its memberships is not.
6719 served_at: match optional_time(content, SERVED_AT)? {
6720 Some(served) => Some(served),
6721 None => optional_time(item, SERVED_AT)?,
6722 },
6723 // Both connections were read to their end above — one reporting a further page is
6724 // refused — so these are every field value and every label the item holds. A
6725 // response that did not select the labels at all says nothing of them, and gives
6726 // no facets.
6727 facets: content.get("labels").is_some().then(|| {
6728 change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
6729 }),
6730 own_repository,
6731 repositories,
6732 classification,
6733 slot,
6734 board_id: board_id.map(str::to_owned),
6735 fields: field_definitions(nodes),
6736 board_fields: Self::carried_board_fields(content, board_id)?,
6737 blocked_by: carried_blocked_by(content)?,
6738 };
6739 Ok(Some(resolved))
6740 }
6741
6742 /// The field definitions of the board `board_id` names — the project this issue's own
6743 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
6744 /// `None` when the read carried none, carried no entry for that board, or the board item
6745 /// named no board, which a write then answers by reading the board's fields itself.
6746 ///
6747 /// Matched by the board's node id and never by its number alone: a project number is
6748 /// unique only within its owner, so another owner's board numbered alike can sit on the
6749 /// same page, and its field and option ids address nothing on this one.
6750 fn carried_board_fields(
6751 content: &Value,
6752 board_id: Option<&str>,
6753 ) -> Result<Option<Value>, SourceError> {
6754 let (Some(nodes), Some(board_id)) = (
6755 content.pointer("/boards/nodes").and_then(Value::as_array),
6756 board_id,
6757 ) else {
6758 return Ok(None);
6759 };
6760 let Some(board) = nodes.iter().find_map(|node| {
6761 let project = node.get("project")?;
6762 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
6763 }) else {
6764 return Ok(None);
6765 };
6766 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
6767 return Ok(None);
6768 };
6769 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
6770 Ok(Some(fields.clone()))
6771 }
6772
6773 /// What one board item's `Priority` field says, through this instance's mapping.
6774 ///
6775 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
6776 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
6777 /// option the mapping does not name is kept as itself — never read as a level or as
6778 /// `none` — for a read of the task to report by name.
6779 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
6780 let Some(mapping) = &self.priorities else {
6781 return Ok(HeldPriority::Read(Priority::None));
6782 };
6783 // A value of the field that names no option — a text field someone called `Priority` —
6784 // is malformed rather than `none`: reading it as no priority would let the next copy
6785 // clear one a person set.
6786 let Some(option) = field_values
6787 .iter()
6788 .find(|value| {
6789 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
6790 })
6791 .map(|value| required_str(value, "name"))
6792 .transpose()?
6793 else {
6794 return Ok(HeldPriority::Read(Priority::None));
6795 };
6796 Ok(mapping.priority_of(option).map_or_else(
6797 || HeldPriority::Unmapped(option.to_owned()),
6798 HeldPriority::Read,
6799 ))
6800 }
6801
6802 /// What each projected metadata field holds for one board item, by field name — every text
6803 /// value GitHub answers, an empty one included.
6804 fn held_projections(
6805 &self,
6806 field_values: &[Value],
6807 ) -> Result<BTreeMap<String, String>, SourceError> {
6808 let mut held = BTreeMap::new();
6809 for projection in &self.metadata_fields {
6810 if let Some(text) = text_field(field_values, &projection.field)? {
6811 held.insert(projection.field.to_string(), text);
6812 }
6813 }
6814 Ok(held)
6815 }
6816
6817 /// What one board item's status is read from: its `Status` option, whether its issue
6818 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
6819 /// three into the status it reports.
6820 fn status_parts<'a>(
6821 field_values: &'a [Value],
6822 content: &'a Value,
6823 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
6824 let option = field_values
6825 .iter()
6826 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
6827 .map(|value| required_str(value, "name"))
6828 .transpose()?;
6829 let closed = optional_str(content, "state")? == Some("CLOSED");
6830 Ok((option, closed, optional_str(content, "stateReason")?))
6831 }
6832
6833 /// The board Status option this write selects, or the refusal that says why not.
6834 ///
6835 /// The mapped option is required for both open and terminal targets. A terminal write
6836 /// validates it before changing either representation, so it can never fall back to
6837 /// closing an issue whose board cannot display the matching status.
6838 ///
6839 /// Answers the field's id, the option's id, and the option's name as the board spells
6840 /// it — which is the name a read of the item reports once it sits there.
6841 fn column_for(
6842 &self,
6843 fields: &Value,
6844 kind: ItemKind,
6845 category: StatusCategory,
6846 target: &StatusTarget,
6847 ) -> Result<Option<(String, String, String)>, SourceError> {
6848 let Some(wanted) = target.option() else {
6849 return Ok(None);
6850 };
6851 let missing = |detail: &str| SourceError::Refused {
6852 message: format!(
6853 "{} status {} of source {} needs the board Status option {wanted:?}, and \
6854 {detail}; next: add that option to the board, which `onetaskgraph sources \
6855 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
6856 it has",
6857 kind.marker(),
6858 category_name(category),
6859 self.name,
6860 self.name,
6861 category_name(category),
6862 kind.marker()
6863 ),
6864 };
6865 let Some(field) = Board::field(fields, "Status")? else {
6866 return Err(missing("this board has no Status field"));
6867 };
6868 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
6869 return Err(missing(
6870 "this board's Status field is not a single-select field",
6871 ));
6872 }
6873 let option = field
6874 .get("options")
6875 .and_then(Value::as_array)
6876 .and_then(|options| {
6877 options.iter().find(|option| {
6878 option
6879 .get("name")
6880 .and_then(Value::as_str)
6881 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
6882 })
6883 });
6884 match option {
6885 None => Err(missing("this board does not have it")),
6886 Some(option) => Ok(Some((
6887 required_str(field, "id")?.to_owned(),
6888 required_str(option, "id")?.to_owned(),
6889 required_str(option, "name")?.to_owned(),
6890 ))),
6891 }
6892 }
6893
6894 /// The refusal a status that closes an issue is answered with over a board draft.
6895 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
6896 SourceError::Refused {
6897 message: format!(
6898 "status {} of source {} closes the item's issue, and GitHub draft items have \
6899 no open or closed state",
6900 category_name(category),
6901 self.name
6902 ),
6903 }
6904 }
6905
6906 /// What a status write to one item needs of the board: the board's id and the
6907 /// definition of its `Status` field, read off the item when the item says both.
6908 ///
6909 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
6910 /// and its `Status` value carries that field's definition, options and all. An item that
6911 /// does not say — no board id, or no `Status` value to read the field off — takes them
6912 /// from [`Self::board_fields`], which reads no item.
6913 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
6914 if let Some(board) = item.carried_board() {
6915 return Ok(board);
6916 }
6917 if item.defines("Status")
6918 && let Some(board_id) = item.named_board()
6919 {
6920 return Ok(BoardFields {
6921 id: board_id,
6922 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6923 });
6924 }
6925 self.board_fields().await
6926 }
6927
6928 /// Set one task's or one project's status and nothing else; see
6929 /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
6930 async fn set_status(
6931 &self,
6932 id: &NativeId,
6933 kind: ItemKind,
6934 category: StatusCategory,
6935 ) -> Result<Option<Status>, SourceError> {
6936 // Refused before anything is read, in the words a write of the same status is.
6937 let target = self.resolved_target(kind, category)?;
6938 // An item read by issue reference carries only what this write needs, so what it
6939 // becomes is never remembered for a later read to be answered from.
6940 let held = self.take_held(id)?;
6941 let by_reference = held.is_some();
6942 let bound = match held {
6943 Some(item) => Some(item),
6944 None => self.bound_item(id).await?,
6945 };
6946 let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
6947 return Ok(None);
6948 };
6949 let board = self.status_board(&item).await?;
6950 let (field, option, name) = self
6951 .column_for(&board.fields, kind, category, &target)?
6952 .ok_or_else(|| SourceError::Malformed {
6953 message: format!(
6954 "status {} of source {} names no board Status option",
6955 category_name(category),
6956 self.name
6957 ),
6958 })?;
6959 if item.status.category == category && item.option.as_deref() == Some(&name) {
6960 return Ok(Some(item.status));
6961 }
6962 match &target {
6963 StatusTarget::Terminal(_, reason) => {
6964 if item.content_kind == ContentKind::DraftIssue {
6965 return Err(self.closes_a_draft(category));
6966 }
6967 // The option first, so a refused selection leaves an open issue open rather
6968 // than closed behind a card that still reads open.
6969 self.set_item_field(
6970 board.id.as_str(),
6971 &item.item_id,
6972 &field,
6973 json!({"singleSelectOptionId": option}),
6974 )
6975 .await?;
6976 // An issue already closed in a way that reads as this category keeps its state
6977 // and its reason: only its card was out of line, and closing it again would
6978 // overwrite a reason — a duplicate's — that says more than this write does.
6979 let already = item.closed && item.status.category == category;
6980 if !already {
6981 self.update_content(
6982 ContentKind::Issue,
6983 &item.id,
6984 json!({"stateInput": state_input(Some(&target))}),
6985 )
6986 .await?;
6987 }
6988 let reason = if already {
6989 item.reason.clone()
6990 } else {
6991 Some(reason.reason().to_owned())
6992 };
6993 item.closed = true;
6994 item.status = self
6995 .statuses
6996 .status(kind, Some(&name), true, reason.as_deref());
6997 item.option = Some(name);
6998 item.reason = reason;
6999 }
7000 StatusTarget::Column(_) => {
7001 // An option is what an open item's status is, so a closed issue is reopened
7002 // first — sitting closed in the column, it would read back as closed. A draft has
7003 // no state to reopen.
7004 if item.content_kind == ContentKind::Issue && item.closed {
7005 self.update_content(
7006 ContentKind::Issue,
7007 &item.id,
7008 json!({"stateInput": state_input(Some(&target))}),
7009 )
7010 .await?;
7011 item.closed = false;
7012 }
7013 self.set_item_field(
7014 board.id.as_str(),
7015 &item.item_id,
7016 &field,
7017 json!({"singleSelectOptionId": option}),
7018 )
7019 .await?;
7020 item.status = self.statuses.status(kind, Some(&name), false, None);
7021 item.option = Some(name);
7022 }
7023 StatusTarget::Disabled(_) => {
7024 unreachable!("resolved_target refused a disabled status")
7025 }
7026 }
7027 let status = item.status.clone();
7028 if !by_reference {
7029 self.remember_written(item, false)?;
7030 }
7031 Ok(Some(status))
7032 }
7033
7034 /// Replace one task's `delivered_by` and nothing else; see
7035 /// [`TaskSource::set_delivered_by`].
7036 ///
7037 /// One update of the body, which differs from the body GitHub holds only inside the
7038 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
7039 async fn replace_delivered_by(
7040 &self,
7041 id: &NativeId,
7042 delivered_by: &[TaskRef],
7043 ) -> Result<Option<()>, SourceError> {
7044 let entries = TaskRef::listed(
7045 TaskRef::DELIVERED_BY_KEY,
7046 id,
7047 Some(&self.name),
7048 delivered_by.to_vec(),
7049 )
7050 .map_err(|message| SourceError::Refused { message })?;
7051 let Some(mut item) = self
7052 .bound_item(id)
7053 .await?
7054 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7055 else {
7056 return Ok(None);
7057 };
7058 let mut slot = item.slot.clone();
7059 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
7060 self.write_slot(&mut item, &slot).await?;
7061 item.delivered_by = entries;
7062 self.remember_written(item, false)?;
7063 Ok(Some(()))
7064 }
7065
7066 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
7067 /// see [`TaskSource::set_task_metadata`].
7068 ///
7069 /// `None` when this board holds no item by that id, or holds one of another kind. The
7070 /// answer is the item as this source now reads it, so what a caller is told the key
7071 /// holds is what the slot holds.
7072 ///
7073 /// A key already holding the value is answered without a write, compared as JSON rather
7074 /// than as the body's bytes: a slot a person spelled with other whitespace would
7075 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
7076 async fn set_slot_key(
7077 &self,
7078 id: &NativeId,
7079 kind: BoardKind,
7080 key: &MetadataKey,
7081 value: &Value,
7082 ) -> Result<Option<Resolved>, SourceError> {
7083 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
7084 return Ok(None);
7085 };
7086 if item.slot.get(key.as_str()) == Some(value) {
7087 return Ok(Some(item));
7088 }
7089 let mut slot = item.slot.clone();
7090 slot.insert(key.as_str().to_owned(), value.clone());
7091 // A key a board text field projects moves that field first, so the body — the value's
7092 // one authoritative home — is written last, as every write of an existing item is.
7093 if self
7094 .metadata_fields
7095 .iter()
7096 .any(|projection| &*projection.key == key.as_str())
7097 {
7098 let board = self.projection_board(&item).await?;
7099 let (writes, projected) = self.projection_writes(
7100 &board.fields,
7101 Some(&item.projected),
7102 &slot,
7103 Some(&[key.as_str()]),
7104 )?;
7105 let (mut values, mut clears) = (Vec::new(), Vec::new());
7106 for write in &writes {
7107 write.join(&mut values, &mut clears);
7108 }
7109 self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
7110 .await?;
7111 item.projected = projected;
7112 }
7113 self.write_slot(&mut item, &slot).await?;
7114 self.remember_written(item.clone(), false)?;
7115 Ok(Some(item))
7116 }
7117
7118 /// What a projection write to `item` needs of the board, read off the item when it says
7119 /// enough, as [`Self::fields_for`] reads it.
7120 async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
7121 if let Some(board) = item.carried_board() {
7122 return Ok(board);
7123 }
7124 if let Some(board_id) = item.named_board()
7125 && self
7126 .metadata_fields
7127 .iter()
7128 .all(|projection| item.defines(&projection.field))
7129 {
7130 return Ok(BoardFields {
7131 id: board_id,
7132 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7133 });
7134 }
7135 self.board_fields().await
7136 }
7137
7138 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
7139 /// `item` up to what that write left.
7140 ///
7141 /// The body sent differs from the body GitHub holds only inside the slot — see
7142 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
7143 /// the mutation the item's content takes, so a board draft's body is written with
7144 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
7145 async fn write_slot(
7146 &self,
7147 item: &mut Resolved,
7148 slot: &BTreeMap<String, Value>,
7149 ) -> Result<(), SourceError> {
7150 let held = item.raw_body.clone().unwrap_or_default();
7151 let body = with_slot(&held, slot)?;
7152 if body != held {
7153 self.update_content(item.content_kind, &item.id, json!({"body": body}))
7154 .await?;
7155 }
7156 let (visible, slot) = metadata_body(Some(body.clone()))?;
7157 item.body = visible.filter(|value| !value.is_empty());
7158 item.raw_body = Some(body);
7159 item.slot = slot;
7160 Ok(())
7161 }
7162
7163 /// This instance's target for a category written to an item of `kind`, refusing one
7164 /// that kind has no option for — before anything is read or written.
7165 ///
7166 /// Nothing here mutates the board's option set to make room for a status. GitHub
7167 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
7168 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
7169 /// field and every item's status.
7170 fn resolved_target(
7171 &self,
7172 kind: ItemKind,
7173 category: StatusCategory,
7174 ) -> Result<StatusTarget, SourceError> {
7175 let target = self.statuses.target(kind, category).clone();
7176 let StatusTarget::Disabled(why) = target else {
7177 return Ok(target);
7178 };
7179 let refusal = why.refusal(&self.name, category, kind);
7180 // Why there is no shipped default, which is the question a person meeting this
7181 // refusal on a source that never mentioned the category asks.
7182 let shipped_none = match category {
7183 StatusCategory::Draft => Some(
7184 "draft has no shipped default because GitHub draft issues cannot have \
7185 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
7186 ),
7187 StatusCategory::Unknown => Some(
7188 "unknown has no shipped default because this board keeps no open-ended status \
7189 word: every word classified unknown is written to the one board Status option \
7190 status_mapping.unknown names",
7191 ),
7192 _ => None,
7193 };
7194 Err(match (refusal, shipped_none, why) {
7195 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
7196 SourceError::Refused {
7197 message: format!("{message}; {note}"),
7198 }
7199 }
7200 (refusal, _, _) => refusal,
7201 })
7202 }
7203
7204 /// What writing `priority` does to one item's `Priority` field on this board, or the
7205 /// refusal naming what the board lacks.
7206 ///
7207 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
7208 /// none already, or of an item not created yet. Every other priority selects the option
7209 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
7210 /// without that option, is refused rather than given one: reads and writes never create
7211 /// a field or an option.
7212 fn priority_write(
7213 &self,
7214 fields: &Value,
7215 existing: Option<&Resolved>,
7216 priority: Priority,
7217 ) -> Result<Option<PriorityWrite>, SourceError> {
7218 let Some(mapping) = &self.priorities else {
7219 return Err(self.holds_no_priority());
7220 };
7221 let Some(wanted) = mapping.option(priority) else {
7222 if !existing.is_some_and(Resolved::holds_priority) {
7223 return Ok(None);
7224 }
7225 let field =
7226 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
7227 message: format!(
7228 "an item holding a {PRIORITY_FIELD} value was read without that field"
7229 ),
7230 })?;
7231 return Ok(Some(PriorityWrite::Clear {
7232 field: required_str(field, "id")?.to_owned(),
7233 }));
7234 };
7235 let missing = |detail: &str| SourceError::Refused {
7236 message: format!(
7237 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
7238 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
7239 it, or point priority_mapping.{priority} of this source at an option the board \
7240 has",
7241 self.name, self.name
7242 ),
7243 };
7244 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
7245 return Err(missing(&format!(
7246 "this board has no {PRIORITY_FIELD} field"
7247 )));
7248 };
7249 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
7250 return Err(missing(&format!(
7251 "this board's {PRIORITY_FIELD} field is not a single-select field"
7252 )));
7253 }
7254 // An options list that is absent or not a list is an answer this source cannot read,
7255 // not a board lacking the option: `sources fields --apply` is no remedy for it.
7256 let option = field
7257 .get("options")
7258 .and_then(Value::as_array)
7259 .ok_or_else(|| SourceError::Malformed {
7260 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
7261 })?
7262 .iter()
7263 .find(|option| {
7264 option
7265 .get("name")
7266 .and_then(Value::as_str)
7267 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
7268 })
7269 .ok_or_else(|| missing("this board does not have it"))?;
7270 Ok(Some(PriorityWrite::Select {
7271 field: required_str(field, "id")?.to_owned(),
7272 option: required_str(option, "id")?.to_owned(),
7273 }))
7274 }
7275
7276 /// What writing `metadata` does to each projected text field of one item, and what those
7277 /// fields hold after it — or the refusal, before anything is sent.
7278 ///
7279 /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
7280 /// narrows the work to the entries projecting one of those metadata keys, for a write that
7281 /// touches only them; `None` is every entry. A string the field already holds, and nothing
7282 /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
7283 /// and so is a board without the field, or with a field of that name that is not a text
7284 /// field: reads and writes never create a field.
7285 fn projection_writes(
7286 &self,
7287 fields: &Value,
7288 held: Option<&BTreeMap<String, String>>,
7289 metadata: &BTreeMap<String, Value>,
7290 keys: Option<&[&str]>,
7291 ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
7292 let mut projected = held.cloned().unwrap_or_default();
7293 let mut writes = Vec::new();
7294 for projection in self
7295 .metadata_fields
7296 .iter()
7297 .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
7298 {
7299 let wanted = projection.projected(metadata, &self.name)?;
7300 let field = self.projection_field(fields, projection)?;
7301 let holding = projected.get(&*projection.field);
7302 match wanted {
7303 Projected::Text(text) if holding != Some(&text) => {
7304 writes.push(ProjectionWrite::Set {
7305 field,
7306 text: text.clone(),
7307 });
7308 projected.insert(projection.field.to_string(), text);
7309 }
7310 Projected::Nothing if holding.is_some() => {
7311 writes.push(ProjectionWrite::Clear { field });
7312 projected.remove(&*projection.field);
7313 }
7314 Projected::Text(_) | Projected::Nothing => {}
7315 }
7316 }
7317 Ok((writes, projected))
7318 }
7319
7320 /// The id of the board text field one projection writes, or the refusal naming what the
7321 /// board has instead.
7322 fn projection_field(
7323 &self,
7324 fields: &Value,
7325 projection: &MetadataField,
7326 ) -> Result<String, SourceError> {
7327 let refuse = |detail: String| SourceError::Refused {
7328 message: format!(
7329 "source {} projects metadata key {:?} onto the board text field {:?}, and \
7330 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
7331 text field, or name another field in metadata_fields",
7332 self.name, &*projection.key, &*projection.field, self.name
7333 ),
7334 };
7335 let Some(field) = Board::field(fields, &projection.field)? else {
7336 return Err(refuse("this board has no field of that name".to_owned()));
7337 };
7338 let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
7339 let data_type = optional_str(field, "dataType")?;
7340 if typename != "ProjectV2Field" || data_type != Some("TEXT") {
7341 return Err(refuse(format!(
7342 "this board's field of that name is not a text field (it is a {}), so it is \
7343 left as it is; rename or remove that field first",
7344 data_type.unwrap_or(typename)
7345 )));
7346 }
7347 Ok(required_str(field, "id")?.to_owned())
7348 }
7349
7350 /// Apply one priority write to one board item.
7351 async fn write_priority(
7352 &self,
7353 board_id: &str,
7354 item_id: &str,
7355 write: &PriorityWrite,
7356 ) -> Result<(), SourceError> {
7357 match write {
7358 PriorityWrite::Select { field, option } => {
7359 self.set_item_field(
7360 board_id,
7361 item_id,
7362 field,
7363 json!({"singleSelectOptionId": option}),
7364 )
7365 .await
7366 }
7367 PriorityWrite::Clear { field } => {
7368 let data = self
7369 .graphql(
7370 graphql::CLEAR_FIELD,
7371 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
7372 "readPriority":false,"priorityName":PRIORITY_FIELD}),
7373 )
7374 .await?;
7375 let returned = data
7376 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
7377 .ok_or_else(|| SourceError::Malformed {
7378 message: "GitHub field clear returned no project item".into(),
7379 })?;
7380 if required_str(returned, "id")? != item_id {
7381 return Err(SourceError::Malformed {
7382 message: "GitHub field clear returned the wrong project item".into(),
7383 });
7384 }
7385 Ok(())
7386 }
7387 }
7388 }
7389
7390 /// The refusal a priority is answered with by an instance configured with no
7391 /// `priority_mapping`, which holds none.
7392 fn holds_no_priority(&self) -> SourceError {
7393 SourceError::Refused {
7394 message: format!(
7395 "source {} holds no task priority: its configuration sets no priority_mapping; \
7396 next: set priority_mapping on this source, then run `onetaskgraph sources \
7397 fields {} --apply` to set its board up",
7398 self.name, self.name
7399 ),
7400 }
7401 }
7402
7403 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
7404 ///
7405 /// One field write — a select, or a clear for `none` — and no title, body, label, state
7406 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
7407 async fn set_priority(
7408 &self,
7409 id: &NativeId,
7410 priority: Priority,
7411 ) -> Result<Option<Priority>, SourceError> {
7412 if self.priorities.is_none() {
7413 return Err(self.holds_no_priority());
7414 }
7415 let Some(mut item) = self
7416 .bound_item(id)
7417 .await?
7418 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7419 else {
7420 return Ok(None);
7421 };
7422 if priority == Priority::None && !item.holds_priority() {
7423 return Ok(Some(priority));
7424 }
7425 // The item's own read carries the field's definition whenever it holds a value of
7426 // it, which a clear always does; a select onto an item holding none reads the board.
7427 let board = match (item.carried_board(), item.named_board()) {
7428 (Some(board), _) => board,
7429 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
7430 id,
7431 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
7432 },
7433 _ => self.board_fields().await?,
7434 };
7435 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
7436 return Ok(Some(priority));
7437 };
7438 let (document, root, input) = match write {
7439 PriorityWrite::Select { field, option } => (
7440 graphql::UPDATE_FIELD,
7441 "updateProjectV2ItemFieldValue",
7442 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
7443 ),
7444 PriorityWrite::Clear { field } => (
7445 graphql::CLEAR_FIELD,
7446 "clearProjectV2ItemFieldValue",
7447 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
7448 ),
7449 };
7450 let data = self
7451 .graphql(
7452 document,
7453 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
7454 )
7455 .await?;
7456 let returned = data
7457 .get(root)
7458 .and_then(|value| value.get("projectV2Item"))
7459 .ok_or_else(|| SourceError::Malformed {
7460 message: "GitHub priority write returned no project item".into(),
7461 })?;
7462 if required_str(returned, "id")? != item.item_id {
7463 return Err(SourceError::Malformed {
7464 message: "GitHub priority write returned the wrong project item".into(),
7465 });
7466 }
7467 let value = returned
7468 .get("fieldValueByName")
7469 .ok_or_else(|| SourceError::Malformed {
7470 message: "GitHub priority write returned no priority read-back".into(),
7471 })?;
7472 if !value.is_null()
7473 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
7474 {
7475 return Err(SourceError::Malformed {
7476 message: "GitHub priority read-back is not a Priority field value".into(),
7477 });
7478 }
7479 let values = if value.is_null() {
7480 Vec::new()
7481 } else {
7482 vec![value.clone()]
7483 };
7484 item.priority = self.held_priority(&values)?;
7485 let answer = item.task()?.priority;
7486 self.remember_written(item, false)?;
7487 Ok(Some(answer))
7488 }
7489
7490 /// Replace one task's visible body and nothing else; see
7491 /// [`TaskSource::set_task_content`].
7492 ///
7493 /// One update of the body, which differs from the body GitHub holds only outside the
7494 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
7495 /// this source keeps there reads back as it was. A body that would not change is not
7496 /// sent at all.
7497 async fn replace_content(
7498 &self,
7499 id: &NativeId,
7500 content: &str,
7501 ) -> Result<Option<()>, SourceError> {
7502 let Some(mut item) = self
7503 .bound_item(id)
7504 .await?
7505 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7506 else {
7507 return Ok(None);
7508 };
7509 let held = item.raw_body.clone().unwrap_or_default();
7510 let body = with_content(&held, content)?;
7511 // Checked before anything is sent: content ending in what this source reads as its own
7512 // metadata slot would read back as metadata rather than as the content it was.
7513 let (visible, slot) = metadata_body(Some(body.clone()))?;
7514 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
7515 return Err(SourceError::Refused {
7516 message: format!(
7517 "this content ends in what source {} reads as its own metadata slot \
7518 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7519 as content; next: remove that trailing block from the content",
7520 self.name
7521 ),
7522 });
7523 }
7524 if body != held {
7525 self.update_content(item.content_kind, &item.id, json!({"body": body}))
7526 .await?;
7527 }
7528 item.body = visible.filter(|value| !value.is_empty());
7529 item.raw_body = Some(body);
7530 item.slot = slot;
7531 self.remember_written(item, false)?;
7532 Ok(Some(()))
7533 }
7534
7535 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
7536 ///
7537 /// One read of the item — which carries the board's field definitions and the issue's
7538 /// `blockedBy`, so neither is read again — and then only what differs from it: the
7539 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
7540 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
7541 /// the title, the body — visible content and metadata slot together — and a state change.
7542 /// So an update naming any of title, body, metadata, status and priority is one read and
7543 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
7544 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
7545 /// as a whole write does; an open one selects its option and then reopens. The origin
7546 /// field is never written: an update is of an item that already exists, whose origin is
7547 /// what it is.
7548 ///
7549 /// The task answered is the item as those writes left it, built from the read and what was
7550 /// sent rather than read again — the same record a later read in this run answers from.
7551 async fn targeted_update(
7552 &self,
7553 id: &NativeId,
7554 update: &TaskUpdate,
7555 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7556 // Everything this source can refuse without reading the item is refused first, in the
7557 // words a whole write of the same fields is refused with.
7558 update.consistent()?;
7559 if update
7560 .title
7561 .as_deref()
7562 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
7563 {
7564 return Err(SourceError::Refused {
7565 message: format!(
7566 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
7567 spells a document, so it would read back as one rather than as a task; \
7568 retitle it",
7569 self.name
7570 ),
7571 });
7572 }
7573 if let Some(delivers) = &update.delivers {
7574 TaskRef::listed(
7575 TaskRef::DELIVERS_KEY,
7576 id,
7577 Some(&self.name),
7578 delivers.clone(),
7579 )
7580 .map_err(|message| SourceError::Refused { message })?;
7581 }
7582 if self.priorities.is_none()
7583 && update
7584 .priority
7585 .is_some_and(|priority| priority != Priority::None)
7586 {
7587 return Err(self.holds_no_priority());
7588 }
7589 let target = update
7590 .status
7591 .as_ref()
7592 .map(|status| self.resolved_target(ItemKind::Task, status.category))
7593 .transpose()?;
7594 let Some(mut item) = self
7595 .bound_item(id)
7596 .await?
7597 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7598 else {
7599 return Ok(None);
7600 };
7601 let before = item.task()?;
7602
7603 let mut status_move = None;
7604 if let (Some(status), Some(target)) = (&update.status, target) {
7605 let board = self.status_board(&item).await?;
7606 let (field, option, name) = self
7607 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
7608 .ok_or_else(|| SourceError::Malformed {
7609 message: format!(
7610 "status {} of source {} names no board Status option",
7611 category_name(status.category),
7612 self.name
7613 ),
7614 })?;
7615 let terminal = matches!(target, StatusTarget::Terminal(_, _));
7616 if terminal && item.content_kind == ContentKind::DraftIssue {
7617 return Err(self.closes_a_draft(status.category));
7618 }
7619 let landed = match &target {
7620 StatusTarget::Terminal(_, reason) => {
7621 self.statuses
7622 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
7623 }
7624 _ => self
7625 .statuses
7626 .status(ItemKind::Task, Some(&name), false, None),
7627 };
7628 let option_moves = item
7629 .option
7630 .as_deref()
7631 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
7632 let state_moves = item.content_kind == ContentKind::Issue
7633 && (item.closed != terminal || (terminal && item.status != landed));
7634 if let Some(moves) = Moves::of(option_moves, state_moves) {
7635 status_move = Some(StatusMove {
7636 board: board.id,
7637 field,
7638 option,
7639 name,
7640 target,
7641 landed,
7642 moves,
7643 });
7644 }
7645 }
7646
7647 let mut priority_move = None;
7648 if let Some(priority) = update.priority
7649 && self.priorities.is_some()
7650 && item.priority != HeldPriority::Read(priority)
7651 {
7652 let board = match (item.carried_board(), item.named_board()) {
7653 (Some(board), _) => board,
7654 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
7655 id: board,
7656 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7657 },
7658 _ => self.board_fields().await?,
7659 };
7660 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
7661 priority_move = Some((board.id, write, priority));
7662 }
7663 }
7664
7665 // The metadata keys this update names that a board text field projects, and what
7666 // that does to those fields — refused, as a whole write refuses it, before anything
7667 // is sent.
7668 let touched: Vec<&str> = update
7669 .metadata_set
7670 .keys()
7671 .chain(update.metadata_remove.iter())
7672 .map(MetadataKey::as_str)
7673 .collect();
7674 let mut projection_move = None;
7675 if self
7676 .metadata_fields
7677 .iter()
7678 .any(|projection| touched.contains(&&*projection.key))
7679 {
7680 let mut metadata = item.slot.clone();
7681 for (key, value) in &update.metadata_set {
7682 metadata.insert(key.as_str().to_owned(), value.clone());
7683 }
7684 for key in &update.metadata_remove {
7685 metadata.remove(key.as_str());
7686 }
7687 let board = self.projection_board(&item).await?;
7688 let (writes, projected) = self.projection_writes(
7689 &board.fields,
7690 Some(&item.projected),
7691 &metadata,
7692 Some(&touched),
7693 )?;
7694 projection_move = Some((board.id, writes, projected));
7695 }
7696
7697 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
7698 // recorded in the slot, and the slot travels in the one body update below.
7699 let edges = match &update.depends_on {
7700 Some(edges) => Some(
7701 self.partition_edges(
7702 BoardKind::Work(ItemKind::Task),
7703 item.content_kind,
7704 item.blocked_by.as_deref(),
7705 edges,
7706 )
7707 .await?,
7708 ),
7709 None => None,
7710 };
7711
7712 let mut slot = item.slot.clone();
7713 for (key, value) in &update.metadata_set {
7714 slot.insert(key.as_str().to_owned(), value.clone());
7715 }
7716 for key in &update.metadata_remove {
7717 slot.remove(key.as_str());
7718 }
7719 if let Some(delivers) = &update.delivers {
7720 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
7721 }
7722 if let Some((_, recorded)) = &edges {
7723 record_edges(&mut slot, recorded);
7724 }
7725 let held = item.raw_body.clone().unwrap_or_default();
7726 let content = match &update.content {
7727 Some(content) => with_content(&held, content)?,
7728 None => held.clone(),
7729 };
7730 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
7731 // the body's bytes, as a metadata write compares it: a slot a person spelled with
7732 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
7733 let body = if slot == item.slot {
7734 content
7735 } else {
7736 with_slot(&content, &slot)?
7737 };
7738 // Checked before anything is sent, as a content write checks it: content ending in
7739 // what this source reads as its own slot would read back as metadata.
7740 let (visible, read) = metadata_body(Some(body.clone()))?;
7741 let wanted = update.content.as_deref().or(item.body.as_deref());
7742 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
7743 return Err(SourceError::Refused {
7744 message: format!(
7745 "this content ends in what source {} reads as its own metadata slot \
7746 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7747 as content; next: remove that trailing block from the content",
7748 self.name
7749 ),
7750 });
7751 }
7752 let recorded_moves =
7753 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
7754
7755 // One `updateIssue` carries all three, because every mutation spends the secondary
7756 // limiter and the title, body and state are one mutation's inputs.
7757 let mut fields = serde_json::Map::new();
7758 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
7759 fields.insert("title".to_owned(), json!(title));
7760 }
7761 if body != held {
7762 fields.insert("body".to_owned(), json!(body));
7763 }
7764 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
7765 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
7766 }
7767 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
7768 // GitHub runs no two requests as one, and runs one document's mutation fields in order
7769 // without undoing an earlier field when a later one fails — so a body written before a
7770 // board field the board then refused would be left changed. Written after every other
7771 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
7772 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
7773 // first, together in one request — a terminal option selected before the issue
7774 // closes, as a whole write does — then the `blockedBy` difference, then the body.
7775 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
7776 let mut clears: Vec<(&BoardId, &str)> = Vec::new();
7777 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
7778 board_writes.push((
7779 &moving.board,
7780 (
7781 moving.field.clone(),
7782 json!({"singleSelectOptionId": moving.option}),
7783 ),
7784 ));
7785 }
7786 match &priority_move {
7787 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
7788 board,
7789 (field.clone(), json!({"singleSelectOptionId": option})),
7790 )),
7791 Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
7792 None => {}
7793 }
7794 if let Some((board, writes, _)) = &projection_move {
7795 for write in writes {
7796 let (mut values, mut cleared) = (Vec::new(), Vec::new());
7797 write.join(&mut values, &mut cleared);
7798 board_writes.extend(values.into_iter().map(|value| (board, value)));
7799 clears.extend(cleared.into_iter().map(|field| (board, field)));
7800 }
7801 }
7802 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
7803 boards.extend(clears.iter().map(|(board, _)| *board));
7804 boards.dedup_by(|one, other| one.as_str() == other.as_str());
7805 for board in boards {
7806 let writes = board_writes
7807 .iter()
7808 .filter(|(on, _)| on.as_str() == board.as_str())
7809 .map(|(_, write)| write.clone())
7810 .collect::<Vec<_>>();
7811 let cleared = clears
7812 .iter()
7813 .filter(|(on, _)| on.as_str() == board.as_str())
7814 .map(|(_, field)| *field)
7815 .collect::<Vec<_>>();
7816 self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
7817 .await?;
7818 }
7819 let mut blocked_by_moved = false;
7820 if let Some((native, _)) = &edges
7821 && item.content_kind == ContentKind::Issue
7822 {
7823 blocked_by_moved = self
7824 .reconcile_blocked_by(
7825 &item.id,
7826 native,
7827 Issue::Existing(item.blocked_by.as_deref()),
7828 )
7829 .await?;
7830 }
7831 if !fields.is_empty() {
7832 self.update_content(item.content_kind, &item.id, Value::Object(fields))
7833 .await?;
7834 }
7835
7836 // The `blockedBy` this item was read with is what the write just changed, so it is no
7837 // answer any more: a later write in this command would reconcile against it — re-adding
7838 // what it kept and never removing what it dropped — and a forward-edge read would
7839 // answer with it. Left unknown, each asks GitHub instead.
7840 if blocked_by_moved {
7841 item.blocked_by = None;
7842 }
7843 if let Some(title) = &update.title {
7844 item.title.clone_from(title);
7845 }
7846 item.body = visible.filter(|value| !value.is_empty());
7847 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
7848 item.slot = slot;
7849 if let Some(delivers) = &update.delivers {
7850 item.delivers.clone_from(delivers);
7851 }
7852 if let Some(moving) = status_move {
7853 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
7854 && item.content_kind == ContentKind::Issue;
7855 item.status = moving.landed;
7856 item.option = Some(moving.name);
7857 }
7858 if let Some((_, _, priority)) = priority_move {
7859 item.priority = HeldPriority::Read(priority);
7860 }
7861 if let Some((_, _, projected)) = projection_move {
7862 item.projected = projected;
7863 }
7864 let task = item.task()?;
7865 let mut written = update.changed(&before, &task);
7866 if blocked_by_moved || recorded_moves {
7867 written.insert(UpdatedField::DependsOn);
7868 }
7869 self.remember_written(item, false)?;
7870 Ok(Some(TaskUpdateOutcome {
7871 task,
7872 written,
7873 delivers_before: before.delivers,
7874 }))
7875 }
7876
7877 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
7878 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
7879 ///
7880 /// One update of the body: the content outside the slot, and inside it that one entry,
7881 /// every other entry kept as it was. This source keeps no template answers — an issue has
7882 /// no room beside itself that is not its body, and answers written there would duplicate
7883 /// what the content already says and count against GitHub's body limit — so `answers`
7884 /// reaches nothing here. A body that would not change is not sent at all.
7885 async fn replace_rendering(
7886 &self,
7887 id: &NativeId,
7888 kind: BoardKind,
7889 content: &str,
7890 provenance: &Value,
7891 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
7892 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
7893 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
7894 return Ok(None);
7895 };
7896 let held = item.raw_body.clone().unwrap_or_default();
7897 let mut slot = item.slot.clone();
7898 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
7899 let rewritten;
7900 let content = if let Some(assets) = assets {
7901 let uploads = self
7902 .upload_assets(item.own_repository.as_ref(), assets)
7903 .await?;
7904 rewritten =
7905 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
7906 rewritten.as_str()
7907 } else {
7908 content
7909 };
7910 let body = with_slot(&with_content(&held, content)?, &slot)?;
7911 // Checked before anything is sent, as a content write checks it.
7912 let (visible, read) = metadata_body(Some(body.clone()))?;
7913 if visible.as_deref().unwrap_or_default() != content || read != slot {
7914 return Err(SourceError::Refused {
7915 message: format!(
7916 "this content ends in what source {} reads as its own metadata slot \
7917 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7918 as content; next: remove that trailing block from the template",
7919 self.name
7920 ),
7921 });
7922 }
7923 if body != held {
7924 self.update_content(item.content_kind, &item.id, json!({"body": body}))
7925 .await?;
7926 }
7927 item.body = visible.filter(|value| !value.is_empty());
7928 item.raw_body = Some(body);
7929 item.slot = read;
7930 self.remember_written(item, false)?;
7931 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
7932 id: id.clone(),
7933 content: Some(content.to_owned()),
7934 }))
7935 }
7936
7937 async fn set_item_field(
7938 &self,
7939 board_id: &str,
7940 item_id: &str,
7941 field_id: &str,
7942 value: Value,
7943 ) -> Result<(), SourceError> {
7944 let data = self
7945 .graphql(
7946 graphql::UPDATE_FIELD,
7947 json!({"input":{
7948 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
7949 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
7950 )
7951 .await?;
7952 let returned = data
7953 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
7954 .ok_or_else(|| SourceError::Malformed {
7955 message: "GitHub field update returned no project item".into(),
7956 })?;
7957 if required_str(returned, "id")? != item_id {
7958 return Err(SourceError::Malformed {
7959 message: "GitHub field update returned the wrong project item".into(),
7960 });
7961 }
7962 Ok(())
7963 }
7964
7965 /// GitHub accepts one value per field mutation; aliases combine those mutations in
7966 /// one request — writes in the order given, then clears. A lone write is
7967 /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
7968 /// request's slots hold go in further requests of the same document, in order. Every
7969 /// returned item id is checked, including optional aliases.
7970 async fn set_item_fields(
7971 &self,
7972 board: &str,
7973 item: &str,
7974 fields: &[(String, Value)],
7975 clears: &[&str],
7976 ) -> Result<(), SourceError> {
7977 match (fields, clears) {
7978 ([], []) => return Ok(()),
7979 ([(field, value)], []) => {
7980 return self.set_item_field(board, item, field, value.clone()).await;
7981 }
7982 ([], [field]) => {
7983 return self
7984 .write_priority(
7985 board,
7986 item,
7987 &PriorityWrite::Clear {
7988 field: (*field).to_owned(),
7989 },
7990 )
7991 .await;
7992 }
7993 _ => {}
7994 }
7995 // Any field of the request stands in for an input a slot leaves out, which GitHub
7996 // still requires to be well formed although it never runs it.
7997 let filler = fields
7998 .first()
7999 .map_or_else(|| clears[0], |(field, _)| field.as_str());
8000 let mut writes = fields.iter();
8001 let mut cleared = clears.iter();
8002 loop {
8003 let batch: Vec<&(String, Value)> =
8004 writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
8005 let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
8006 if batch.is_empty() && batch_clears.is_empty() {
8007 return Ok(());
8008 }
8009 let mut variables = serde_json::Map::new();
8010 for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
8011 let input = match batch.get(index) {
8012 Some((field, value)) => {
8013 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
8014 }
8015 None => json!({"projectId":board,"itemId":item,"fieldId":filler,
8016 "value":{"text":""}}),
8017 };
8018 variables.insert(slot.variable.to_owned(), input);
8019 variables.insert(slot.include.to_owned(), json!(index < batch.len()));
8020 }
8021 for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
8022 let field = batch_clears.get(index).map_or(filler, |field| **field);
8023 variables.insert(
8024 slot.variable.to_owned(),
8025 json!({"projectId":board,"itemId":item,"fieldId":field}),
8026 );
8027 variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
8028 }
8029 let data = self
8030 .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
8031 .await?;
8032 let sent = FIELD_WRITE_SLOTS[..batch.len()]
8033 .iter()
8034 .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
8035 for slot in sent {
8036 let alias = slot.alias;
8037 let returned = data
8038 .get(alias)
8039 .and_then(|value| value.get("projectV2Item"))
8040 .ok_or_else(|| SourceError::Malformed {
8041 message: format!("GitHub field update {alias} returned no project item"),
8042 })?;
8043 if required_str(returned, "id")? != item {
8044 return Err(SourceError::Malformed {
8045 message: format!(
8046 "GitHub field update {alias} returned the wrong project item"
8047 ),
8048 });
8049 }
8050 }
8051 }
8052 }
8053
8054 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
8055 let mut after: Option<String> = None;
8056 let mut ids = Vec::new();
8057 loop {
8058 let data = self
8059 .graphql(
8060 graphql::ISSUE_DEPENDENCIES,
8061 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
8062 )
8063 .await?;
8064 let connection =
8065 data.pointer("/node/blockedBy")
8066 .ok_or_else(|| SourceError::Malformed {
8067 message: "GitHub dependency response has no blockedBy connection".into(),
8068 })?;
8069 ids.extend(
8070 connection
8071 .get("nodes")
8072 .and_then(Value::as_array)
8073 .ok_or_else(|| SourceError::Malformed {
8074 message: "GitHub dependency response nodes is not an array".into(),
8075 })?
8076 .iter()
8077 .map(|value| required_str(value, "id").map(str::to_owned))
8078 .collect::<Result<Vec<_>, _>>()?,
8079 );
8080 let next = next_cursor(connection)?;
8081 if let Some(next) = &next {
8082 validate_cursor_progress(after.as_deref(), &next.0)?;
8083 }
8084 after = next.map(|cursor| cursor.0);
8085 if after.is_none() {
8086 return Ok(ids);
8087 }
8088 }
8089 }
8090
8091 async fn dependencies(
8092 &self,
8093 id: &NativeId,
8094 near_kind: ItemKind,
8095 direction: Direction,
8096 page: &PageRequest,
8097 ) -> Result<Page<DependencyEdge>, SourceError> {
8098 validate_page(page)?;
8099 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
8100 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
8101 let recorded = recorded_offset(cursor, direction)?;
8102 // What this issue is blocked by, when a read of it by its own id in this command
8103 // already carried the whole connection — a copy reads the item it writes before it
8104 // reads its edges — and the page asked for is the whole of it, or the recorded tail
8105 // after it. Answered from that read, in the shape the dependency read answers in;
8106 // anything else is asked of GitHub.
8107 let carried = match direction {
8108 Direction::DependsOn => self
8109 .resolved_cache()?
8110 .get(id)
8111 .filter(|item| item.content_kind == ContentKind::Issue)
8112 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
8113 Direction::DependedOnBy => None,
8114 }
8115 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
8116 // Asked for even in the recorded phase, whose page reads nothing from the
8117 // connection: `__typename` is what says whether this item has a native
8118 // relationship at all, and that is what decides which far ends the reserved key is
8119 // allowed to hold.
8120 let data = match carried {
8121 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
8122 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
8123 None => {
8124 self.graphql(
8125 graphql::ISSUE_DEPENDENCIES,
8126 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
8127 "after":if recorded.is_some() {None} else {cursor}}),
8128 )
8129 .await?
8130 }
8131 };
8132 let node =
8133 data.get("node")
8134 .filter(|v| !v.is_null())
8135 .ok_or_else(|| SourceError::Refused {
8136 message: format!(
8137 "GitHub item {} was not found or does not support dependencies",
8138 id.0
8139 ),
8140 })?;
8141 let connection_name = match direction {
8142 Direction::DependsOn => "blockedBy",
8143 Direction::DependedOnBy => "blocking",
8144 };
8145 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
8146 // named natively and the reserved key may hold any far end. An issue's connections
8147 // hold issues, and this source reads them at the near item's own level.
8148 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
8149 if let Some(offset) = recorded {
8150 return Ok(recorded_page(
8151 self.recorded_edges(id, near_kind, direction, natively_names, node)
8152 .await?,
8153 offset,
8154 limit,
8155 ));
8156 }
8157 if natively_names.is_none() {
8158 return Ok(recorded_page(
8159 self.recorded_edges(id, near_kind, direction, natively_names, node)
8160 .await?,
8161 0,
8162 limit,
8163 ));
8164 }
8165 let connection = node
8166 .get(connection_name)
8167 .ok_or_else(|| SourceError::Malformed {
8168 message: "GitHub dependency response is missing its connection".into(),
8169 })?;
8170 let nodes = connection
8171 .get("nodes")
8172 .and_then(Value::as_array)
8173 .ok_or_else(|| SourceError::Malformed {
8174 message: "GitHub dependency response nodes is not an array".into(),
8175 })?;
8176 // `from` depends on `to`, always. GitHub spells the same relationship from either
8177 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
8178 // it — so the near item is `from` in one direction and `to` in the other.
8179 let items = nodes
8180 .iter()
8181 .map(|value| {
8182 let related = NativeId(required_str(value, "id")?.into());
8183 let related_kind = related_kind(value)?;
8184 let (from, to) = match direction {
8185 Direction::DependsOn => (
8186 DependencyEndpoint::from_native(id.clone(), near_kind),
8187 DependencyEndpoint::from_native(related, related_kind),
8188 ),
8189 Direction::DependedOnBy => (
8190 DependencyEndpoint::from_native(related, related_kind),
8191 DependencyEndpoint::from_native(id.clone(), near_kind),
8192 ),
8193 };
8194 Ok(DependencyEdge {
8195 from,
8196 to,
8197 kind: DependencyKind::Blocks,
8198 })
8199 })
8200 .collect::<Result<Vec<_>, SourceError>>()?;
8201 let mut next = next_cursor(connection)?;
8202 if let Some(next) = &next {
8203 validate_cursor_progress(cursor, &next.0)?;
8204 }
8205 if next.is_none()
8206 && !self
8207 .recorded_edges(id, near_kind, direction, natively_names, node)
8208 .await?
8209 .is_empty()
8210 {
8211 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
8212 }
8213 Ok(Page { items, next })
8214 }
8215
8216 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
8217 /// a far end in another source has to live: no GitHub issue relationship can name one.
8218 ///
8219 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
8220 /// source never writes one down.
8221 ///
8222 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
8223 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
8224 /// request beyond the read already made, and reading the board for them would be a
8225 /// walk of every item for one field of one. A draft has no body in that answer, because
8226 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
8227 /// listing of the board, which can be behind on the very item asked about.
8228 async fn recorded_edges(
8229 &self,
8230 id: &NativeId,
8231 near_kind: ItemKind,
8232 direction: Direction,
8233 natively_names: Option<ItemKind>,
8234 node: &Value,
8235 ) -> Result<Vec<DependencyEdge>, SourceError> {
8236 if direction != Direction::DependsOn {
8237 return Ok(Vec::new());
8238 }
8239 let slot = match node.get("body") {
8240 Some(body) if natively_names.is_some() => {
8241 metadata_body(body.as_str().map(str::to_owned))?.1
8242 }
8243 _ => {
8244 let Some(item) = self.bound_item(id).await? else {
8245 return Ok(Vec::new());
8246 };
8247 item.slot
8248 }
8249 };
8250 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
8251 .map_err(|message| SourceError::Malformed { message })
8252 }
8253
8254 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
8255 self.repository
8256 .as_ref()
8257 .ok_or_else(|| SourceError::Refused {
8258 message: format!(
8259 "source {} has no repository configured, and a GitHub Projects board has no \
8260 repository of its own to create an issue in; set repository: owner/name on \
8261 this source",
8262 self.name
8263 ),
8264 })
8265 }
8266
8267 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
8268 /// states.
8269 ///
8270 /// The fallback is demanded first, whichever arm answers: a write without a configured
8271 /// repository is refused naming the field exactly as it was before the rule existed,
8272 /// so a source that could not write before cannot write now, rather than writing for
8273 /// the one item whose own field happens to decide it.
8274 ///
8275 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
8276 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
8277 /// entry owned by someone other than the owner of the parent issue's repository —
8278 /// GitHub accepts a sub-issue from another repository of the same owner and from no
8279 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
8280 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
8281 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
8282 /// and is visible to the token is checked where its node id is resolved, still before
8283 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
8284 /// looked up in a listing of the board, which can be minutes behind an issue its own
8285 /// `projectItems` already places on it — and that read answers first from this process's
8286 /// own record, so a project created moments ago in this command answers though GitHub
8287 /// has not caught up.
8288 async fn creation_target(
8289 &self,
8290 incoming: &Incoming<'_>,
8291 ) -> Result<RepositoryTarget, SourceError> {
8292 let fallback = self.configured_repository()?;
8293 let what = |incoming: &Incoming<'_>| {
8294 format!(
8295 "{} {:?}",
8296 incoming.written.kind().describes(),
8297 incoming.title
8298 )
8299 };
8300 let parent = match incoming.parent {
8301 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
8302 SourceError::Refused {
8303 message: format!(
8304 "GitHub project issue {} was not found on the board of source {}, so {} \
8305 cannot be filed under it",
8306 parent.0,
8307 self.name,
8308 what(incoming)
8309 ),
8310 }
8311 })?),
8312 None => None,
8313 };
8314 let parents_repository = parent
8315 .as_ref()
8316 .map(|parent| {
8317 // A draft is on the board and so is found, but it has no repository to
8318 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
8319 // would refuse the task only once `createIssue` had made it.
8320 if parent.content_kind == ContentKind::DraftIssue {
8321 return Err(SourceError::Refused {
8322 message: format!(
8323 "GitHub project item {} on the board of source {} is a draft, \
8324 which cannot have sub-issues, so {} cannot be filed under it",
8325 parent.id.0,
8326 self.name,
8327 what(incoming)
8328 ),
8329 });
8330 }
8331 // An issue's repository is where a sub-issue is placed and whose owner it
8332 // is compared against, so a parent whose repository this source cannot
8333 // spell as `owner/name` — GitHub's login grammar is wider than this
8334 // source's floor — is one nothing can be filed under.
8335 parent
8336 .own_repository
8337 .as_ref()
8338 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
8339 .ok_or_else(|| SourceError::Malformed {
8340 message: format!(
8341 "GitHub project issue {} on the board of source {} is in {}, which \
8342 is not a {}/owner/name repository this source can place {} in",
8343 parent.id.0,
8344 self.name,
8345 parent
8346 .own_repository
8347 .as_ref()
8348 .map_or("no repository", Repository::as_str),
8349 RepositoryTarget::HOST,
8350 what(incoming)
8351 ),
8352 })
8353 })
8354 .transpose()?;
8355 match incoming.repositories {
8356 [named] => {
8357 let target =
8358 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
8359 message: format!(
8360 "{} names repository {}, which is not a {}/owner/name repository \
8361 source {} can create an issue in; name one that is, or name none",
8362 what(incoming),
8363 named.as_str(),
8364 RepositoryTarget::HOST,
8365 self.name
8366 ),
8367 })?;
8368 if let Some(parents) = &parents_repository
8369 && parents.owner != target.owner
8370 {
8371 return Err(SourceError::Refused {
8372 message: format!(
8373 "{} names repository {}, owned by {}, but its project's issue is in \
8374 {}, owned by {}, and GitHub files a sub-issue only in a repository \
8375 of the same owner as its parent issue; name a repository of {}, or \
8376 name none",
8377 what(incoming),
8378 target.slug(),
8379 target.owner,
8380 parents.slug(),
8381 parents.owner,
8382 parents.owner
8383 ),
8384 });
8385 }
8386 Ok(target)
8387 }
8388 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
8389 }
8390 }
8391
8392 /// The node id of the repository `incoming` is being created in, or the refusal naming
8393 /// the item and the repository the token cannot see.
8394 ///
8395 /// Resolved once per command per repository; see [`Self::repository_cache`].
8396 async fn repository_id(
8397 &self,
8398 repository: &RepositoryTarget,
8399 incoming: &Incoming<'_>,
8400 ) -> Result<String, SourceError> {
8401 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
8402 return Ok(id);
8403 }
8404 let data = self
8405 .graphql(
8406 graphql::REPOSITORY,
8407 json!({"owner":repository.owner,"name":repository.name}),
8408 )
8409 .await?;
8410 self.repository_read(&data, repository, incoming)
8411 }
8412
8413 /// The repository's node id out of an answer carrying the `repository` root, held for
8414 /// the rest of this command, or the refusal naming the item that cannot be created in it.
8415 fn repository_read(
8416 &self,
8417 data: &Value,
8418 repository: &RepositoryTarget,
8419 incoming: &Incoming<'_>,
8420 ) -> Result<String, SourceError> {
8421 let node = data
8422 .get("repository")
8423 .filter(|value| !value.is_null())
8424 .ok_or_else(|| SourceError::Refused {
8425 message: format!(
8426 "GitHub repository {} was not found or is not visible to the token, so {} \
8427 {:?} cannot be created in it",
8428 repository.slug(),
8429 incoming.written.kind().describes(),
8430 incoming.title
8431 ),
8432 })?;
8433 let id = required_str(node, "id")?.to_owned();
8434 self.repository_cache()?
8435 .insert(repository.clone(), id.clone());
8436 Ok(id)
8437 }
8438
8439 fn repository_cache(
8440 &self,
8441 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
8442 self.repository_cache
8443 .lock()
8444 .map_err(|_| SourceError::Unavailable {
8445 message: "this source's record of the destination repository was left \
8446 inconsistent by an earlier failure; next: run the command again"
8447 .into(),
8448 })
8449 }
8450
8451 /// Create or update one board item, whichever kind it is.
8452 async fn write_item(
8453 &self,
8454 incoming: &Incoming<'_>,
8455 target: Option<&NativeId>,
8456 depends_on: &[DependencyEdge],
8457 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
8458 // Refused before anything is read or written: a task or a project titled the way
8459 // this board spells a document would land as an issue this same source reads back
8460 // as a document, so the field this destination cannot carry is named rather than
8461 // written and silently reclassified.
8462 if let Written::Work(kind, _) = incoming.written
8463 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
8464 {
8465 return Err(SourceError::Refused {
8466 message: format!(
8467 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
8468 spells a document, so it would read back as one rather than as a {}; \
8469 retitle it, or copy it as a document",
8470 kind.marker(),
8471 self.name,
8472 kind.marker()
8473 ),
8474 });
8475 }
8476 // The destination is read by its own id, and whether this board holds it is decided
8477 // by that read — its own `projectItems` — rather than by whether a listing of the
8478 // board happens to include it yet. See the module documentation.
8479 let existing = match target {
8480 Some(target) => {
8481 Some(
8482 self.bound_item(target)
8483 .await?
8484 .ok_or_else(|| SourceError::Refused {
8485 message: format!("GitHub destination item {} was not found", target.0),
8486 })?,
8487 )
8488 }
8489 None => None,
8490 };
8491 let existing = existing.as_ref();
8492 // An existing issue is never moved; a new one is created where the rule says — and
8493 // knowing where is what lets the board's fields and that repository's id be read
8494 // together, before anything below needs either.
8495 let creation_target = match existing {
8496 Some(_) => None,
8497 None => {
8498 let target = self.creation_target(incoming).await?;
8499 self.creation_context(&target, incoming).await?;
8500 Some(target)
8501 }
8502 };
8503 let board = self
8504 .fields_for(
8505 existing,
8506 incoming.written.status().is_some(),
8507 incoming
8508 .priority
8509 .is_some_and(|priority| priority != Priority::None),
8510 )
8511 .await?;
8512 let status_target = incoming
8513 .written
8514 .work_status()
8515 .map(|(kind, status)| self.resolved_target(kind, status.category))
8516 .transpose()?;
8517 let column = match (incoming.written.work_status(), status_target.as_ref()) {
8518 (Some((kind, status)), Some(target)) => {
8519 self.column_for(&board.fields, kind, status.category, target)?
8520 }
8521 _ => None,
8522 };
8523 // Resolved before anything is created, for the reason the column above is: a
8524 // priority this board has no option for is refused while nothing has been written.
8525 let priority_write = match incoming.priority {
8526 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
8527 None => None,
8528 };
8529 // And for the reason the priority is: a value no projected field can hold, or a board
8530 // that cannot hold the field, is refused while nothing has been written.
8531 let (projections, projected) = self.projection_writes(
8532 &board.fields,
8533 existing.map(|item| &item.projected),
8534 incoming.metadata,
8535 None,
8536 )?;
8537 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
8538 if content_kind == ContentKind::DraftIssue {
8539 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
8540 (status_target.as_ref(), incoming.written.status())
8541 {
8542 return Err(self.closes_a_draft(status.category));
8543 }
8544 if incoming.parent.is_some() {
8545 return Err(SourceError::Refused {
8546 message: "GitHub draft items cannot be a project's sub-issue".into(),
8547 });
8548 }
8549 }
8550 match existing {
8551 Some(item) if content_kind == ContentKind::Issue => {
8552 if item.labels != incoming.labels {
8553 return Err(SourceError::Refused {
8554 message: "GitHub issue labels differ from the labels being written".into(),
8555 });
8556 }
8557 }
8558 _ => {
8559 if !incoming.labels.is_empty() {
8560 return Err(SourceError::Refused {
8561 message: "GitHub items created by this destination carry no labels".into(),
8562 });
8563 }
8564 }
8565 }
8566
8567 // The repository the issue really lives in is what the slot below is written against,
8568 // so a single entry that is where the issue is created travels as no key at all, and
8569 // the read side derives it back from the issue.
8570 let own_repository = match (existing, &creation_target) {
8571 (Some(item), _) => item.own_repository.clone(),
8572 (None, Some(target)) => Some(
8573 Repository::try_from(target.origin())
8574 .map_err(|message| SourceError::Config { message })?,
8575 ),
8576 (None, None) => None,
8577 };
8578 let (native, fallback) = self
8579 .partition_edges(
8580 incoming.written.kind(),
8581 content_kind,
8582 existing.and_then(|item| item.blocked_by.as_deref()),
8583 depends_on,
8584 )
8585 .await?;
8586 let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
8587 let content = match incoming.assets {
8588 Some(assets) => {
8589 let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
8590 let rewritten = onetaskgraph_plugin_api::serve_asset_references(
8591 incoming.content.unwrap_or_default(),
8592 &mut slot,
8593 &uploads,
8594 );
8595 incoming.content.map(|_| rewritten)
8596 }
8597 None => incoming.content.map(str::to_owned),
8598 };
8599 let body = compose_body(content.as_deref(), &slot)?;
8600 // Read before anything is created, for the reason the field below is: a value
8601 // this destination cannot store has to refuse, and refusing after `createIssue`
8602 // would leave an issue behind that nothing asked for. The engine writes a
8603 // qualified id here; a caller handing this key anything else is told so rather
8604 // than having it silently stored as no origin at all.
8605 // 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.
8606 let origin = match incoming.metadata.get(ORIGIN_KEY) {
8607 None => "",
8608 Some(Value::String(origin)) => origin.as_str(),
8609 Some(other) => {
8610 return Err(SourceError::Refused {
8611 message: format!(
8612 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
8613 is {other}"
8614 ),
8615 });
8616 }
8617 };
8618 // Resolved before anything is created: a board that cannot carry the copy origin
8619 // has to refuse the write, and refusing it after `createIssue` would leave an
8620 // issue behind that nothing asked for.
8621 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
8622 Some(field) => {
8623 if required_str(field, "__typename")? != "ProjectV2Field" {
8624 return Err(SourceError::Refused {
8625 message: format!(
8626 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
8627 ),
8628 });
8629 }
8630 Some(required_str(field, "id")?.to_owned())
8631 }
8632 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
8633 return Err(SourceError::Refused {
8634 message: format!(
8635 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
8636 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
8637 the board"
8638 ),
8639 });
8640 }
8641 None => None,
8642 };
8643
8644 let Landed {
8645 content_id,
8646 item_id,
8647 url,
8648 number,
8649 } = match existing {
8650 // Its content is written last, below, once everything else has landed.
8651 Some(item) => Landed {
8652 content_id: item.id.clone(),
8653 item_id: item.item_id.clone(),
8654 url: item.url.clone(),
8655 number: item.number,
8656 },
8657 None => {
8658 let target = creation_target
8659 .as_ref()
8660 .ok_or_else(|| SourceError::Malformed {
8661 message: "a new item was decided without a repository to create it in"
8662 .into(),
8663 })?;
8664 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
8665 .await?
8666 }
8667 };
8668
8669 // A write the store's cache expects sends only what differs from the version it expects:
8670 // a priority the item already holds there is not sent, so a person's edit of it since is
8671 // left alone. Every other write sends it, as its pinned cost says.
8672 let priority_write = priority_write.filter(|_| {
8673 !existing.is_some_and(|item| {
8674 self.expected.is_primed(&item.id)
8675 && incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
8676 })
8677 });
8678 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
8679 let column = column
8680 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
8681 .map(|(field, option, _)| (field, option));
8682 // Creating an item here is several calls — `createIssue`, which files it on the
8683 // board, then its board fields, the parent and the dependencies — and GitHub can fail
8684 // at any of them. Everything this source can refuse *before* the first of those is
8685 // already checked above, so what is left is GitHub itself failing part way. When it
8686 // does over an item this call created, the issue is taken back: a write that
8687 // refused must not leave an item behind that nobody asked for, and one that does
8688 // makes the retry create a second.
8689 // Whether the board-field write carrying a moved origin was answered as landing whole.
8690 // When it was refused, GitHub does not say which of its fields ran before the one that
8691 // failed, so the origin may or may not have moved.
8692 let mut origin_landed = false;
8693 let landed = self
8694 .finish_write(
8695 board.id.as_str(),
8696 incoming,
8697 &content_id,
8698 &item_id,
8699 content_kind,
8700 existing,
8701 origin_field.as_deref(),
8702 origin,
8703 column,
8704 status_target.as_ref(),
8705 priority_write.as_ref(),
8706 &projections,
8707 &native,
8708 &mut origin_landed,
8709 )
8710 .await;
8711 // An existing item's title, body and state go last, in one `updateIssue`, once its board
8712 // fields and its relationships have landed: a refusal of any of those then leaves its
8713 // body — and the metadata slot inside it — exactly as it stood.
8714 let landed = match (landed, existing) {
8715 (Ok(()), Some(item)) => {
8716 self.update_existing(item, incoming, &body, status_target.as_ref())
8717 .await
8718 }
8719 (landed, _) => landed,
8720 };
8721 if let Err(error) = landed {
8722 match existing {
8723 // Best effort, and the write's own failure is what the caller is told: a
8724 // refusal naming the tidy-up would hide why the write failed at all.
8725 None => {
8726 let _ = self.delete_issue(&content_id).await;
8727 }
8728 // The origin field is the one piece of an existing item's metadata written
8729 // before its body, so a write refused after it puts it back as it was. When
8730 // that is refused too, the write's own failure is still what the caller is
8731 // told — with what it left behind added, because the item's metadata is then
8732 // not as it stood and a caller retrying has to know which key moved.
8733 Some(item) => {
8734 let before = item.origin.as_deref().unwrap_or("");
8735 if let Some(field) = origin_field.as_deref()
8736 && before != origin
8737 && let Err(restore) = self
8738 .set_item_field(
8739 board.id.as_str(),
8740 &item.item_id,
8741 field,
8742 json!({"text": before}),
8743 )
8744 .await
8745 {
8746 let left = if origin_landed {
8747 format!(
8748 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
8749 not be put back to {before:?} ({restore}), so item {} still \
8750 holds {origin:?} there",
8751 item.id.0
8752 )
8753 } else {
8754 format!(
8755 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
8756 {origin:?}, GitHub does not say whether that part of it ran, \
8757 and putting it back to {before:?} was refused ({restore}), so \
8758 item {} holds {origin:?} or {before:?} there",
8759 item.id.0
8760 )
8761 };
8762 return Err(noting(
8763 error,
8764 &format!(
8765 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
8766 run the write again"
8767 ),
8768 ));
8769 }
8770 }
8771 }
8772 return Err(error);
8773 }
8774
8775 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
8776 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
8777 self.statuses
8778 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
8779 }
8780 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
8781 self.statuses
8782 .status(kind, written_option.as_deref(), false, None)
8783 }
8784 (Some((_, status)), _) => status.clone(),
8785 (None, _) => Status {
8786 category: StatusCategory::Unknown,
8787 name: "Open".to_owned(),
8788 },
8789 };
8790
8791 // So the rest of this command reads what it just did rather than what the board
8792 // said before it. See `remember_written` for which half takes it.
8793 let remembered = Resolved {
8794 item_id,
8795 id: content_id.clone(),
8796 content_kind,
8797 kind: incoming.written.kind(),
8798 title: incoming.title.to_owned(),
8799 // The visible half of the body this write composed, split back off it the
8800 // way a read splits it — so what this record reports is what a read of the
8801 // same issue reports, rather than the person's text with the metadata slot
8802 // still on the end of it.
8803 body: metadata_body(body.clone())?.0,
8804 raw_body: body.clone(),
8805 // A document has no status of its own; what it reads back as is whatever
8806 // the issue's own state says, which is what a re-read reports.
8807 status: written_status,
8808 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
8809 priority: match incoming.priority {
8810 Some(priority) => HeldPriority::Read(priority),
8811 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
8812 item.priority.clone()
8813 }),
8814 },
8815 // What `state_input` asked for: closed for a terminal target, open for any other
8816 // status, and the issue's own state left as it was by a document write.
8817 closed: content_kind == ContentKind::Issue
8818 && match status_target.as_ref() {
8819 Some(StatusTarget::Terminal(_, _)) => true,
8820 Some(_) => false,
8821 None => existing.is_some_and(|item| item.closed),
8822 },
8823 reason: match status_target.as_ref() {
8824 Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
8825 Some(reason.reason().to_owned())
8826 }
8827 Some(_) => None,
8828 None => existing.and_then(|item| item.reason.clone()),
8829 },
8830 delivers: incoming.delivers.to_vec(),
8831 delivered_by: incoming.delivered_by.to_vec(),
8832 labels: incoming.labels.to_vec(),
8833 parent: incoming.parent.cloned(),
8834 origin: (!origin.is_empty()).then(|| origin.to_owned()),
8835 projected,
8836 number,
8837 // In the update path this is the item's own url, read off `existing` where the
8838 // record above was bound, so one expression serves both halves.
8839 url,
8840 created_at: existing.and_then(|item| item.created_at),
8841 updated_at: existing.and_then(|item| item.updated_at),
8842 // What this write moved is GitHub's to say, so a record of it gives no token until
8843 // it is read again.
8844 item_updated_at: None,
8845 comment_count: None,
8846 relationship_events: None,
8847 served_at: None,
8848 facets: None,
8849 own_repository,
8850 repositories: incoming.repositories.to_vec(),
8851 classification: incoming.classification,
8852 slot,
8853 board_id: Some(board.id.as_str().to_owned()),
8854 fields: board
8855 .fields
8856 .get("nodes")
8857 .and_then(Value::as_array)
8858 .cloned()
8859 .unwrap_or_default(),
8860 board_fields: Some(board.fields.clone()),
8861 // What this write left the relationship holding is known by id alone, and a
8862 // later read of its edges needs each far end's kind, so it reads them again.
8863 blocked_by: None,
8864 };
8865 self.remember_written(remembered, existing.is_none())?;
8866 Ok(onetaskgraph_plugin_api::AssetsWritten {
8867 id: content_id,
8868 content,
8869 })
8870 }
8871
8872 /// Everything a write does after the item exists: its board fields, its parent, and
8873 /// its dependencies.
8874 ///
8875 /// Split out of `write_item` so there is one place a failure past the point of no
8876 /// return is caught, rather than a tidy-up repeated at each `?` above.
8877 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
8878 // so there is one place a failure past the point of no return is caught, and its
8879 // arguments are exactly the values that tail already had in scope. Bundling them into a
8880 // struct would describe no concept — it would be "the arguments of this function" — and
8881 // would put the whole of `write_item`'s locals behind one more indirection.
8882 #[allow(clippy::too_many_arguments)]
8883 async fn finish_write(
8884 &self,
8885 board_id: &str,
8886 incoming: &Incoming<'_>,
8887 content_id: &NativeId,
8888 item_id: &str,
8889 content_kind: ContentKind,
8890 existing: Option<&Resolved>,
8891 origin_field: Option<&str>,
8892 origin: &str,
8893 column: Option<(String, String)>,
8894 status_target: Option<&StatusTarget>,
8895 priority: Option<&PriorityWrite>,
8896 projections: &[ProjectionWrite],
8897 native: &[String],
8898 origin_landed: &mut bool,
8899 ) -> Result<(), SourceError> {
8900 let mut fields = Vec::new();
8901 if let Some(field_id) = origin_field
8902 && existing.map_or(!origin.is_empty(), |item| {
8903 item.origin.as_deref().unwrap_or("") != origin
8904 })
8905 {
8906 fields.push((field_id.to_owned(), json!({"text":origin})));
8907 }
8908 if let Some((field_id, option_id)) = column {
8909 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
8910 }
8911 let mut clears = Vec::new();
8912 match priority {
8913 Some(PriorityWrite::Select { field, option }) => {
8914 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
8915 }
8916 Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
8917 None => {}
8918 }
8919 for projection in projections {
8920 projection.join(&mut fields, &mut clears);
8921 }
8922 self.set_item_fields(board_id, item_id, &fields, &clears)
8923 .await?;
8924 *origin_landed = true;
8925
8926 // An existing issue closes in the `updateIssue` its write ends with; one created just
8927 // now closes here, once its option is selected.
8928 if existing.is_none()
8929 && content_kind == ContentKind::Issue
8930 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
8931 {
8932 self.update_content(
8933 ContentKind::Issue,
8934 content_id,
8935 json!({"stateInput":state_input(status_target)}),
8936 )
8937 .await?;
8938 }
8939
8940 if content_kind == ContentKind::Issue {
8941 self.reparent(
8942 existing.and_then(|item| item.parent.clone()),
8943 content_id,
8944 incoming.parent,
8945 )
8946 .await?;
8947 // A document takes part in no dependency graph, so writing one neither reads
8948 // nor changes the issue's own `blockedBy` relationships. Reconciling them
8949 // against the empty list a document write carries would *delete* whatever
8950 // relationships a person had made on that issue, which is a write nobody
8951 // asked for.
8952 if incoming.written.kind() != BoardKind::Document {
8953 let issue = match existing {
8954 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
8955 None => Issue::Created,
8956 };
8957 self.reconcile_blocked_by(content_id, native, issue).await?;
8958 }
8959 }
8960 Ok(())
8961 }
8962
8963 /// Delete one issue, which takes its board item with it.
8964 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
8965 let data = self
8966 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
8967 .await?;
8968 data.pointer("/deleteIssue/repository")
8969 .filter(|value| !value.is_null())
8970 .ok_or_else(|| SourceError::Malformed {
8971 message: "GitHub issue deletion returned no repository".into(),
8972 })?;
8973 self.forget(id)?;
8974 Ok(())
8975 }
8976
8977 /// Remove one item this copy created, so a copy that could not finish leaves the board
8978 /// as it found it.
8979 ///
8980 /// Deleting the issue takes its board item with it, so there is no second mutation to
8981 /// keep in step. An id the board does not hold is not an error: the item is already
8982 /// gone, which is the state this asks for. Which that is, is decided by reading the item
8983 /// by its own id — a listing of the board can still be missing an item it holds, and
8984 /// reading that as *already gone* would leave behind the very item this was asked to
8985 /// take back.
8986 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
8987 let Some(item) = self.bound_item(id).await? else {
8988 return Ok(());
8989 };
8990 if item.content_kind == ContentKind::DraftIssue {
8991 return Err(SourceError::Refused {
8992 message: format!(
8993 "GitHub item {} is a draft, and this source removes an item by deleting \
8994 its issue; next: remove it from the board by hand",
8995 id.0
8996 ),
8997 });
8998 }
8999 let data = self
9000 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
9001 .await?;
9002 data.pointer("/deleteIssue/repository")
9003 .filter(|value| !value.is_null())
9004 .ok_or_else(|| SourceError::Malformed {
9005 message: "GitHub issue deletion returned no repository".into(),
9006 })?;
9007 self.forget(id)?;
9008 Ok(())
9009 }
9010
9011 /// The issue a comment call on `task` is about, or `None` when this board holds no such
9012 /// task.
9013 ///
9014 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
9015 /// read of the task cannot disagree about which ids name one: a project or a document of
9016 /// this board is not a task here either.
9017 ///
9018 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
9019 /// issues and a draft is not one. It is refused rather than answered with an empty page,
9020 /// which would read as a task nobody has commented on yet.
9021 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
9022 let cached = self.resolved_cache()?.get(task).cloned();
9023 let Some(item) = (match cached {
9024 Some(item) => Some(item),
9025 None => self.item_by_id(task).await?,
9026 })
9027 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
9028 return Ok(None);
9029 };
9030 if item.content_kind == ContentKind::DraftIssue {
9031 return Err(self.draft_has_no_comments(task));
9032 }
9033 Ok(Some(item.id))
9034 }
9035
9036 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
9037 /// issues, and a draft is not one.
9038 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
9039 SourceError::Refused {
9040 message: format!(
9041 "task {} of source {} is a draft item on the board, and GitHub keeps \
9042 comments on issues alone, so a draft has none to read or write; next: \
9043 convert the draft to an issue on the board, then comment on the issue it \
9044 becomes",
9045 task.0, self.name
9046 ),
9047 }
9048 }
9049
9050 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
9051 /// request — or `None` when this board holds no task by that id.
9052 ///
9053 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
9054 /// is answered with the draft and the refusal, at the price of the draft's own read.
9055 async fn issue_detail(
9056 &self,
9057 id: &NativeId,
9058 page: &PageRequest,
9059 ) -> Result<Option<TaskDetailRead>, SourceError> {
9060 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
9061 let asked = self
9062 .graphql(
9063 graphql::ISSUE_DETAIL,
9064 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
9065 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
9066 "duplicates":true}),
9067 )
9068 .await;
9069 let data = match asked {
9070 Ok(data) => data,
9071 Err(error) if unresolvable_node(&error) => return Ok(None),
9072 Err(error) => return Err(error),
9073 };
9074 // `node` is null for an id that names nothing, and absent only from an answer this
9075 // source cannot read — never the same thing.
9076 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
9077 message: format!("GitHub answered the read of {} with no node", id.0),
9078 })?;
9079 self.detail_of(id, node, true, after).await
9080 }
9081
9082 /// Several tasks, each with the first page of its comments when `comments` is set, read
9083 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
9084 /// order.
9085 ///
9086 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
9087 /// one item at a time, so that id is answered as missing and the others as themselves; any
9088 /// other refusal is every id of that batch's answer.
9089 async fn issue_details(
9090 &self,
9091 ids: &[NativeId],
9092 comments: Option<&PageRequest>,
9093 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9094 let mut read = Vec::with_capacity(ids.len());
9095 for batch in ids.chunks(DETAIL_BATCH) {
9096 match self
9097 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
9098 .await
9099 {
9100 Ok(data) => {
9101 for (slot, id) in batch.iter().enumerate() {
9102 // Every alias asked for is answered, null for an id naming nothing;
9103 // one missing is an answer this source cannot read.
9104 let read_one = match data.get(format!("i{slot}")) {
9105 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
9106 None => Err(SourceError::Malformed {
9107 message: format!(
9108 "GitHub answered a batch read with no item for {}",
9109 id.0
9110 ),
9111 }),
9112 };
9113 read.push(read_one);
9114 }
9115 }
9116 Err(error) if unresolvable_node(&error) => {
9117 for id in batch {
9118 read.push(match comments {
9119 Some(page) => self.issue_detail(id, page).await,
9120 None => self.task_read(id).await,
9121 });
9122 }
9123 }
9124 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
9125 }
9126 }
9127 read
9128 }
9129
9130 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
9131 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
9132 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
9133 task,
9134 comments: None,
9135 }))
9136 }
9137
9138 /// What one node a detail read reached says: the task this board holds by `id`, with the
9139 /// page of comments the node carries when `commented` — or `None` for a node that is no
9140 /// task of this board.
9141 ///
9142 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
9143 /// and an item this process created answers from this process's own record, which a node
9144 /// read taken moments after the write can still be behind.
9145 async fn detail_of(
9146 &self,
9147 id: &NativeId,
9148 node: &Value,
9149 commented: bool,
9150 after: Option<&str>,
9151 ) -> Result<Option<TaskDetailRead>, SourceError> {
9152 if node.is_null() {
9153 return Ok(None);
9154 }
9155 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
9156 // An issue answered under one id is that id's, or the answer is not one this source
9157 // can report: reporting another issue's task and comments under the qualified id asked
9158 // for would be the one wrong answer here. A draft's own read checks the same.
9159 if !draft
9160 && optional_str(node, "__typename")? == Some("Issue")
9161 && required_str(node, "id")? != id.0
9162 {
9163 return Err(SourceError::Malformed {
9164 message: format!(
9165 "GitHub answered the read of {} with issue {}",
9166 id.0,
9167 required_str(node, "id")?
9168 ),
9169 });
9170 }
9171 let item = if draft {
9172 self.draft_by_id(id).await?
9173 } else {
9174 self.resolve_issue(node).await?
9175 };
9176 // A read that carried the whole of what the issue is blocked by — a copy's — is kept
9177 // for this command, so the dependency read the copy makes next is answered from it.
9178 if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
9179 self.resolved_cache()?.insert(read.id.clone(), read.clone());
9180 }
9181 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
9182 return Ok(None);
9183 };
9184 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
9185 let task = own.unwrap_or(item).task()?;
9186 let comments = match (commented, draft) {
9187 (false, _) => None,
9188 (true, true) => Some(Err(self.draft_has_no_comments(id))),
9189 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
9190 };
9191 if commented && !draft && after.is_none() {
9192 self.expected.learn_comments(id, node);
9193 }
9194 Ok(Some(TaskDetailRead { task, comments }))
9195 }
9196
9197 /// Whether the comment `comment` is one of `issue`'s own.
9198 ///
9199 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
9200 /// comment's id and nothing else: a comment id given against the wrong task would
9201 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
9202 /// names something that is not an issue comment, is a comment this task does not have —
9203 /// which is what GitHub refusing to resolve it means too.
9204 async fn comment_is_on(
9205 &self,
9206 issue: &NativeId,
9207 comment: &NativeId,
9208 ) -> Result<bool, SourceError> {
9209 Ok(self.comment_on(issue, comment).await?.is_some())
9210 }
9211
9212 /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
9213 /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
9214 async fn comment_on(
9215 &self,
9216 issue: &NativeId,
9217 comment: &NativeId,
9218 ) -> Result<Option<String>, SourceError> {
9219 // A write the store's cache expects knows which comments its issue held, and their
9220 // bodies, at the version it expects: what this read would answer.
9221 if let Some(primed) = self.expected.primed_comment(issue, comment) {
9222 return Ok(primed);
9223 }
9224 let asked = self
9225 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
9226 .await;
9227 let data = match asked {
9228 Ok(data) => data,
9229 Err(error) if unresolvable_node(&error) => return Ok(None),
9230 Err(error) => return Err(error),
9231 };
9232 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
9233 return Ok(None);
9234 };
9235 if optional_str(node, "__typename")? != Some("IssueComment") {
9236 return Ok(None);
9237 }
9238 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
9239 message: format!("GitHub issue comment {} names no issue", comment.0),
9240 })?;
9241 if required_str(on, "id")? != issue.0 {
9242 return Ok(None);
9243 }
9244 Ok(Some(required_str(node, "body")?.to_owned()))
9245 }
9246
9247 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
9248 async fn partition_edges(
9249 &self,
9250 near_kind: BoardKind,
9251 near_content: ContentKind,
9252 carried: Option<&[Value]>,
9253 depends_on: &[DependencyEdge],
9254 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
9255 let mut native = Vec::new();
9256 let mut fallback = Vec::new();
9257 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
9258 .iter()
9259 .map(|edge| {
9260 let same_source = edge
9261 .to
9262 .source()
9263 .is_none_or(|source| source == self.name.as_str());
9264 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
9265 // `DependencyEndpoint::source` both read it that way — and a native id may hold
9266 // colons of its own, so the far end is everything after that one separator.
9267 // Splitting at the last would truncate `work:urn:task:7` to `7`.
9268 let far_id = if edge.to.is_qualified() {
9269 edge.to
9270 .id()
9271 .split_once(':')
9272 .map_or(edge.to.id(), |(_, native)| native)
9273 } else {
9274 edge.to.id()
9275 };
9276 // One that already blocks the near issue was answered by that issue's own
9277 // read, which carried each of its blockers' kinds — an issue every one — so it
9278 // is not read again.
9279 let blocking = carried.and_then(|nodes| {
9280 nodes
9281 .iter()
9282 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
9283 });
9284 (edge, far_id, same_source, blocking)
9285 })
9286 .collect();
9287 // Every other same-source far end is read by its own id, exactly as the item it is a
9288 // far end of is: whether this board holds it is that read's answer, never a listing's.
9289 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
9290 let mut unread: Vec<NativeId> = Vec::new();
9291 for (_, far_id, same_source, blocking) in &far_ends {
9292 let id = NativeId((*far_id).to_owned());
9293 if *same_source && blocking.is_none() && !unread.contains(&id) {
9294 unread.push(id);
9295 }
9296 }
9297 let read: BTreeMap<NativeId, Option<Resolved>> = unread
9298 .iter()
9299 .cloned()
9300 .zip(self.items_by_ids(&unread).await?)
9301 .collect();
9302 for (edge, far_id, same_source, blocking) in far_ends {
9303 let far = match (same_source, blocking) {
9304 (false, _) => None,
9305 (true, Some(node)) => Some(FarEnd {
9306 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9307 BoardKind::Document
9308 } else {
9309 BoardKind::Work(related_kind(node)?)
9310 },
9311 content_kind: ContentKind::Issue,
9312 }),
9313 (true, None) => {
9314 let read = read
9315 .get(&NativeId(far_id.to_owned()))
9316 .cloned()
9317 .flatten()
9318 .ok_or_else(|| SourceError::Refused {
9319 message: format!("GitHub dependency item {far_id} was not found"),
9320 })?;
9321 Some(FarEnd {
9322 kind: read.kind,
9323 content_kind: read.content_kind,
9324 })
9325 }
9326 };
9327 let far = far.as_ref();
9328 // The caller says which kind the far end is, and this board holds the far end
9329 // itself, so a disagreement is settled here rather than stored: recorded, the
9330 // wrong kind would read back as a cross-level edge that never existed; written
9331 // natively, it would name a relationship of a different level than the caller
9332 // asked for.
9333 //
9334 // A far end this board holds as a *document* fails the same comparison and is
9335 // refused by the same sentence: `ItemKind` has no document variant because
9336 // nothing may point at one, so no caller can name it correctly and the refusal
9337 // is the only honest answer.
9338 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
9339 return Err(SourceError::Refused {
9340 message: format!(
9341 "GitHub dependency item {far_id} is a {} of this board, and this item \
9342 names it as a {}; record the kind it is",
9343 disagreeing.kind.describes(),
9344 edge.to.kind.marker()
9345 ),
9346 });
9347 }
9348 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
9349 // however the far end is spelled — and one classified native here would be
9350 // written nowhere at all, because a draft's native reconciliation never runs.
9351 let native_here = near_content == ContentKind::Issue
9352 && far.is_some_and(|far| {
9353 far.content_kind == ContentKind::Issue
9354 && BoardKind::Work(edge.to.kind) == near_kind
9355 });
9356 if native_here {
9357 native.push(far_id.to_owned());
9358 } else {
9359 fallback.push(edge.clone());
9360 }
9361 }
9362 Ok((native, fallback))
9363 }
9364
9365 async fn update_existing(
9366 &self,
9367 item: &Resolved,
9368 incoming: &Incoming<'_>,
9369 body: &Option<String>,
9370 status_target: Option<&StatusTarget>,
9371 ) -> Result<(), SourceError> {
9372 let title = incoming.written_title();
9373 // A terminal status closes the issue here, in the same mutation as its body: its board
9374 // option was selected before this, so a close never lands on an item whose board cannot
9375 // show it.
9376 let mut fields = match item.content_kind {
9377 ContentKind::DraftIssue => json!({"title":title,"body":body}),
9378 ContentKind::Issue => json!({"title":title,"body":body,
9379 "stateInput":state_input(status_target)}),
9380 };
9381 // A write the store's cache expects sends only the members whose value differs from the
9382 // version it expects, and nothing at all when none does.
9383 if self.expected.is_primed(&item.id)
9384 && let Some(members) = fields.as_object_mut()
9385 {
9386 if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
9387 members.remove("title");
9388 }
9389 if members.get("body").and_then(Value::as_str)
9390 == Some(item.raw_body.as_deref().unwrap_or_default())
9391 {
9392 members.remove("body");
9393 }
9394 let terminal = matches!(status_target, Some(StatusTarget::Terminal(_, _)));
9395 if members.contains_key("stateInput") && terminal == item.closed {
9396 members.remove("stateInput");
9397 }
9398 if members.is_empty() {
9399 return Ok(());
9400 }
9401 }
9402 self.update_content(item.content_kind, &item.id, fields)
9403 .await
9404 }
9405
9406 /// Update one board item's content with exactly `fields` beside its id, through the
9407 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
9408 /// a draft.
9409 ///
9410 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
9411 /// is what lets a narrow write carry the one thing it changes and nothing else.
9412 async fn update_content(
9413 &self,
9414 kind: ContentKind,
9415 id: &NativeId,
9416 fields: Value,
9417 ) -> Result<(), SourceError> {
9418 let (operation, id_key, pointer) = match kind {
9419 ContentKind::DraftIssue => (
9420 graphql::UPDATE_DRAFT,
9421 "draftIssueId",
9422 "/updateProjectV2DraftIssue/draftIssue",
9423 ),
9424 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
9425 };
9426 let mut input = fields;
9427 input[id_key] = json!(id.0);
9428 let data = self.graphql(operation, json!({"input":input})).await?;
9429 let returned = data
9430 .pointer(pointer)
9431 .ok_or_else(|| SourceError::Malformed {
9432 message: "GitHub item update returned no item".into(),
9433 })?;
9434 if required_str(returned, "id")? != id.0 {
9435 return Err(SourceError::Malformed {
9436 message: "GitHub item update returned the wrong item".into(),
9437 });
9438 }
9439 Ok(())
9440 }
9441
9442 /// Creates one issue, files it on the board, and reports what a read of it would say:
9443 /// its content id, its board item id, and the web address GitHub gave it.
9444 ///
9445 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
9446 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
9447 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
9448 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
9449 /// "Content already exists in this project". A terminal status is not written here:
9450 /// `finish_write` selects its option first and closes the issue after, so a close never
9451 /// lands on an item whose board cannot show it.
9452 ///
9453 /// The address and the number come back here because this is the only place either is
9454 /// known before GitHub's own board read catches up — an item this run created answers
9455 /// the reads that follow it out of the record below, and one remembered without them
9456 /// would report no location and no key for the rest of the run.
9457 async fn create_and_file_issue(
9458 &self,
9459 board_id: &str,
9460 repository: &RepositoryTarget,
9461 incoming: &Incoming<'_>,
9462 body: &Option<String>,
9463 ) -> Result<Landed, SourceError> {
9464 let repository_id = self.repository_id(repository, incoming).await?;
9465 let data = self
9466 .graphql(
9467 graphql::CREATE_ISSUE,
9468 json!({"input":{
9469 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
9470 }}),
9471 )
9472 .await?;
9473 let created = data
9474 .pointer("/createIssue/issue")
9475 .filter(|value| !value.is_null())
9476 .ok_or_else(|| SourceError::Malformed {
9477 message: "GitHub issue creation returned no issue".into(),
9478 })?;
9479 let content_id = NativeId(required_str(created, "id")?.to_owned());
9480 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
9481 // a response without it is not worth failing a landed write over — the item simply
9482 // reports no location until the board read catches up, which is what it did before.
9483 let url = optional_str(created, "url")?.map(str::to_owned);
9484 // The issue exists from here on, so an unreadable number and a refused board
9485 // filing below each try, best effort, to take it back: an issue in the repository
9486 // that is on no board is an item nobody asked for and nothing here would find again.
9487 //
9488 // Its number is optional on the same terms its address is — a landed write is not
9489 // worth failing over a member that came back missing, and such an item reports no
9490 // handle until a board read catches up. A number that is *present* and is not an
9491 // unsigned integer is still a response this source cannot read.
9492 let number = match created_issue_number(created) {
9493 Ok(number) => number,
9494 Err(error) => {
9495 let _ = self.delete_issue(&content_id).await;
9496 return Err(error);
9497 }
9498 };
9499 let added = match self
9500 .graphql(
9501 graphql::ADD_TO_BOARD,
9502 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
9503 )
9504 .await
9505 {
9506 Ok(added) => added,
9507 Err(error) => {
9508 let _ = self.delete_issue(&content_id).await;
9509 return Err(error);
9510 }
9511 };
9512 let item = added
9513 .pointer("/addProjectV2ItemById/item")
9514 .filter(|value| !value.is_null())
9515 .ok_or_else(|| SourceError::Malformed {
9516 message: "GitHub board addition returned no project item".into(),
9517 })?;
9518 Ok(Landed {
9519 content_id,
9520 item_id: required_str(item, "id")?.to_owned(),
9521 url,
9522 number,
9523 })
9524 }
9525
9526 /// Move one issue under the project it now belongs to, or out of the one it left.
9527 async fn reparent(
9528 &self,
9529 held: Option<NativeId>,
9530 child: &NativeId,
9531 wanted: Option<&NativeId>,
9532 ) -> Result<(), SourceError> {
9533 if held.as_ref() == wanted {
9534 return Ok(());
9535 }
9536 if let Some(held) = &held {
9537 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
9538 .await?;
9539 }
9540 if let Some(wanted) = wanted {
9541 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
9542 .await?;
9543 }
9544 Ok(())
9545 }
9546
9547 async fn sub_issue(
9548 &self,
9549 operation: &str,
9550 parent: &NativeId,
9551 child: &NativeId,
9552 root: &str,
9553 ) -> Result<(), SourceError> {
9554 let data = self
9555 .graphql(
9556 operation,
9557 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
9558 )
9559 .await?;
9560 let issue =
9561 data.pointer(&format!("/{root}/issue"))
9562 .ok_or_else(|| SourceError::Malformed {
9563 message: "GitHub sub-issue update returned no issue".into(),
9564 })?;
9565 let sub =
9566 data.pointer(&format!("/{root}/subIssue"))
9567 .ok_or_else(|| SourceError::Malformed {
9568 message: "GitHub sub-issue update returned no sub-issue".into(),
9569 })?;
9570 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
9571 return Err(SourceError::Malformed {
9572 message: "GitHub sub-issue update returned the wrong issues".into(),
9573 });
9574 }
9575 Ok(())
9576 }
9577
9578 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
9579 /// whether there was one.
9580 ///
9581 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
9582 /// relationships are not read: there is nothing a read of them could find.
9583 async fn reconcile_blocked_by(
9584 &self,
9585 content_id: &NativeId,
9586 native: &[String],
9587 issue: Issue<'_>,
9588 ) -> Result<bool, SourceError> {
9589 let current = match issue {
9590 Issue::Created => Vec::new(),
9591 Issue::Existing(Some(held)) => held
9592 .iter()
9593 .map(|far| required_str(far, "id").map(str::to_owned))
9594 .collect::<Result<Vec<_>, _>>()?,
9595 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
9596 };
9597 let mut changed = false;
9598 for (operation, far_id) in current
9599 .iter()
9600 .filter(|id| !native.contains(id))
9601 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
9602 .chain(
9603 native
9604 .iter()
9605 .filter(|id| !current.contains(id))
9606 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
9607 )
9608 {
9609 let data = self
9610 .graphql(
9611 operation,
9612 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
9613 )
9614 .await?;
9615 let root = if operation == graphql::ADD_BLOCKED_BY {
9616 "addBlockedBy"
9617 } else {
9618 "removeBlockedBy"
9619 };
9620 let issue =
9621 data.pointer(&format!("/{root}/issue"))
9622 .ok_or_else(|| SourceError::Malformed {
9623 message: "GitHub dependency update returned no issue".into(),
9624 })?;
9625 let blocker = data
9626 .pointer(&format!("/{root}/blockingIssue"))
9627 .ok_or_else(|| SourceError::Malformed {
9628 message: "GitHub dependency update returned no blocking issue".into(),
9629 })?;
9630 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
9631 {
9632 return Err(SourceError::Malformed {
9633 message: "GitHub dependency update returned the wrong issues".into(),
9634 });
9635 }
9636 changed = true;
9637 }
9638 Ok(changed)
9639 }
9640}
9641
9642/// What a write needs to know of one far end it names: which kind of item it is, and whether
9643/// it is an issue a native relationship can name.
9644struct FarEnd {
9645 kind: BoardKind,
9646 content_kind: ContentKind,
9647}
9648
9649/// Whether the issue one write reconciles was created by that write or was already there.
9650#[derive(Clone, Copy, PartialEq, Eq)]
9651enum Issue<'a> {
9652 /// Created by this write, so it holds no relationships yet.
9653 Created,
9654 /// On the board before this write, holding whatever relationships it holds — the far
9655 /// ends of its whole `blockedBy`, when the read that reached it carried them.
9656 Existing(Option<&'a [Value]>),
9657}
9658
9659/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
9660enum Reached {
9661 /// An issue this board holds, resolved into everything this source reports about it.
9662 Held(Box<Resolved>),
9663 /// Nothing this board holds: no such node, or a node on some other board.
9664 Nothing,
9665 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
9666 /// again by [`GitHubProjectsSource::draft_by_id`].
9667 Draft,
9668}
9669
9670/// What GitHub says when a string is not a node id it can resolve.
9671///
9672/// Matched because it is the ordinary answer to a project selector naming a project by its
9673/// *name*, and reporting that as a failure would make naming one impossible. It is read
9674/// off the refusal GitHub sent, never guessed from the shape of the string: this source
9675/// does not define the syntax of a GitHub node id and would be wrong about it.
9676const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
9677
9678/// `error` with `note` added to the end of what it says, its kind and every other member
9679/// unchanged — so a caller still branches on the failure that happened, and reads beside it
9680/// what that failure left behind.
9681fn noting(error: SourceError, note: &str) -> SourceError {
9682 match error {
9683 SourceError::Config { message } => SourceError::Config {
9684 message: message + note,
9685 },
9686 SourceError::Auth { message } => SourceError::Auth {
9687 message: message + note,
9688 },
9689 SourceError::Refused { message } => SourceError::Refused {
9690 message: message + note,
9691 },
9692 SourceError::RateLimited {
9693 retry_after_seconds,
9694 message,
9695 } => SourceError::RateLimited {
9696 retry_after_seconds,
9697 message: Some(message.unwrap_or_default() + note),
9698 },
9699 SourceError::Unavailable { message } => SourceError::Unavailable {
9700 message: message + note,
9701 },
9702 SourceError::Malformed { message } => SourceError::Malformed {
9703 message: message + note,
9704 },
9705 }
9706}
9707
9708/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
9709/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
9710fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest) -> Value {
9711 let mut variables = serde_json::Map::new();
9712 for slot in 0..COPY_DETAIL_BATCH {
9713 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9714 variables.insert(format!("id{slot}"), json!(id));
9715 }
9716 variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
9717 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9718 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9719 variables.insert("duplicates".to_owned(), json!(true));
9720 Value::Object(variables)
9721}
9722
9723/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
9724/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
9725/// for them.
9726///
9727/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
9728/// is read again at no added price.
9729fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
9730 let mut variables = serde_json::Map::new();
9731 for slot in 0..DETAIL_BATCH {
9732 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9733 variables.insert(format!("id{slot}"), json!(id));
9734 }
9735 variables.insert(
9736 "first".to_owned(),
9737 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
9738 );
9739 variables.insert("comments".to_owned(), json!(comments.is_some()));
9740 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9741 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9742 variables.insert("duplicates".to_owned(), json!(true));
9743 Value::Object(variables)
9744}
9745
9746/// Whether this refusal is GitHub saying the id names no node at all.
9747fn unresolvable_node(error: &SourceError) -> bool {
9748 matches!(error, SourceError::Refused { message }
9749 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
9750}
9751
9752/// One project name, as a search qualifier which filters on it at the server.
9753///
9754/// Quoted so the whole title is one phrase rather than a bag of words, with the two
9755/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
9756/// the way it documents. A title matched here is still compared for equality afterwards:
9757/// the qualifier narrows what the server sends, and this source decides what it names.
9758fn title_qualifier(name: &str) -> String {
9759 format!("in:title {}", quoted(name))
9760}
9761
9762/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
9763/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
9764/// it documents — so a value holding a qualifier's spelling is searched for rather than
9765/// obeyed.
9766fn quoted(value: &str) -> String {
9767 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
9768 format!("\"{escaped}\"")
9769}
9770
9771/// The search qualifier for the issues updated at or after `since`.
9772///
9773/// Written to the second, rounded down, which can only widen what the search returns.
9774fn updated_qualifier(since: DateTime<Utc>) -> String {
9775 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
9776}
9777
9778/// The search terms that narrow a board-scoped issue search to a task query's text and
9779/// metadata predicates, or `None` when it carries neither.
9780///
9781/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
9782/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
9783/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
9784/// matches each in any field the `in:` qualifier names, so a query naming a title search and
9785/// a metadata value searches both fields for both — wider than asked, never narrower, and
9786/// every candidate is confirmed in process afterwards.
9787///
9788/// **This narrows a text search, and that is this source's declared semantics.** GitHub
9789/// matches whole tokens where a substring rule would match inside a word, so an item holding
9790/// the text only inside a longer word is not returned. A text of nothing but whitespace
9791/// matches every item, so it narrows nothing and is not sent.
9792fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
9793 let text = query
9794 .text
9795 .as_ref()
9796 .filter(|text| !text.terms.trim().is_empty());
9797 if text.is_none() && query.metadata.is_empty() {
9798 return None;
9799 }
9800 let (title, body) = match text.map(|text| text.fields) {
9801 None => (false, true),
9802 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
9803 Some(TextFields::Content) => (false, true),
9804 Some(TextFields::TitleOrContent) => (true, true),
9805 };
9806 let fields = match (title, body) {
9807 (true, true) => "in:title,body",
9808 (true, false) => "in:title",
9809 _ => "in:body",
9810 };
9811 let phrases = text
9812 .map(|text| text.terms.clone())
9813 .into_iter()
9814 .chain(
9815 query
9816 .metadata
9817 .iter()
9818 .map(|wanted| as_stored(wanted.value())),
9819 )
9820 .map(|phrase| quoted(&phrase))
9821 .collect::<Vec<_>>();
9822 Some(format!("{fields} {}", phrases.join(" ")))
9823}
9824
9825/// The search terms that narrow a board-scoped issue search to a project or document query's
9826/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
9827/// carrying that text alone is sent as by [`narrowing_qualifiers`].
9828fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
9829 narrowing_qualifiers(&TaskQuery {
9830 text: text.cloned(),
9831 ..TaskQuery::default()
9832 })
9833}
9834
9835/// Refuses a project or document query's text GitHub's issue search cannot find, before
9836/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
9837/// query's.
9838fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
9839 refuse_unsearchable(&TaskQuery {
9840 text: text.cloned(),
9841 ..TaskQuery::default()
9842 })
9843}
9844
9845/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
9846/// before anything is asked of GitHub.
9847///
9848/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
9849/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
9850/// left out, the search is every issue of the board. So this source says it cannot answer
9851/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
9852/// nothing GitHub could search for, and keeps the board read it always had.
9853fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
9854 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
9855 letter or digit with a bounded query";
9856 if let Some(text) = &query.text
9857 && !text.terms.trim().is_empty()
9858 && !has_words(&text.terms)
9859 {
9860 return Err(SourceError::Refused {
9861 message: format!(
9862 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
9863 text.terms
9864 ),
9865 });
9866 }
9867 if let Some(wanted) = query
9868 .metadata
9869 .iter()
9870 .find(|wanted| !has_words(wanted.value()))
9871 {
9872 return Err(SourceError::Refused {
9873 message: format!(
9874 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
9875 wanted.value(),
9876 std::iter::once(wanted.key())
9877 .chain(wanted.path().iter().map(String::as_str))
9878 .collect::<Vec<_>>()
9879 .join("/"),
9880 ),
9881 });
9882 }
9883 Ok(())
9884}
9885
9886/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
9887fn has_words(phrase: &str) -> bool {
9888 phrase.chars().any(char::is_alphanumeric)
9889}
9890
9891/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
9892///
9893/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
9894/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
9895/// which GitHub's word match would read as different words.
9896fn as_stored(value: &str) -> String {
9897 let encoded = Value::String(value.to_owned()).to_string();
9898 encoded[1..encoded.len() - 1].to_owned()
9899}
9900
9901/// The one narrower question a task query carrying a text, metadata or origin predicate is
9902/// sent as.
9903enum Narrowing {
9904 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
9905 Origin(String),
9906 /// The board-scoped issue search narrowed by these qualifiers.
9907 Search(String),
9908}
9909
9910impl Narrowing {
9911 /// What this question is remembered under for the length of one command.
9912 fn key(&self) -> String {
9913 match self {
9914 Self::Origin(origin) => format!("origin {origin}"),
9915 Self::Search(also) => format!("search {also}"),
9916 }
9917 }
9918}
9919
9920/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
9921enum Resumed {
9922 /// It reported another page, which starts after this cursor.
9923 More(String),
9924 /// It has ended. Sending this cursor again — the page's own end when it had one, and
9925 /// otherwise the cursor it was reached from — answers an empty page, so the one document
9926 /// can go on walking the other connection.
9927 Ended(Option<String>),
9928}
9929
9930impl Resumed {
9931 /// Whether the connection has another page.
9932 const fn has_more(&self) -> bool {
9933 matches!(self, Self::More(_))
9934 }
9935
9936 /// The cursor to send this connection next.
9937 fn cursor(self) -> Option<String> {
9938 match self {
9939 Self::More(next) => Some(next),
9940 Self::Ended(last) => last,
9941 }
9942 }
9943}
9944
9945/// Where `connection`, reached from `after`, resumes — refused when it reports another page
9946/// with no cursor to it, or from a cursor that does not advance.
9947fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
9948 let info = connection
9949 .get("pageInfo")
9950 .ok_or_else(|| SourceError::Malformed {
9951 message: "GitHub connection has no pageInfo".into(),
9952 })?;
9953 let end = optional_str(info, "endCursor")?;
9954 if required_bool(info, "hasNextPage")? {
9955 let next = end.ok_or_else(|| SourceError::Malformed {
9956 message: "GitHub connection reports another page and no endCursor".into(),
9957 })?;
9958 validate_cursor_progress(after, next)?;
9959 return Ok(Resumed::More(next.to_owned()));
9960 }
9961 Ok(Resumed::Ended(
9962 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
9963 ))
9964}
9965
9966/// The board, and every item on it this source reports.
9967#[derive(Clone)]
9968struct Board {
9969 id: String,
9970 fields: Value,
9971 items: Vec<Resolved>,
9972}
9973
9974/// What a write needs of the board and nothing more: its node id and its field
9975/// definitions, in the shape a read of the board's own `fields` gives them.
9976///
9977/// Deliberately no items. A write decides which item it writes, which parent it files
9978/// under and which far ends it names by reading each of them by its own id; this is the
9979/// half of the board those reads cannot carry, and holding no item is what keeps it from
9980/// ever being asked whether an item is there.
9981#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9982struct BoardFields {
9983 id: BoardId,
9984 fields: Value,
9985}
9986
9987/// A board's node id: what a field write and `addProjectV2ItemById` address.
9988///
9989/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
9990/// refused where it is read, and one an item names blank is read as not named at all.
9991#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9992#[serde(try_from = "String")]
9993struct BoardId(String);
9994
9995impl TryFrom<String> for BoardId {
9996 type Error = String;
9997
9998 /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
9999 fn try_from(id: String) -> Result<Self, Self::Error> {
10000 Self::parse(&id).map_err(|error| error.to_string())
10001 }
10002}
10003
10004/// Where one write left its item, for the record the rest of the command reads it out of.
10005///
10006/// A named record rather than a tuple because the update arm and the create arm each fill
10007/// all four, and two `Option`s of different meaning side by side in a tuple are two
10008/// positions a reader has to count.
10009struct Landed {
10010 /// The issue's own node id, which is the [`NativeId`] this source reports.
10011 content_id: NativeId,
10012 /// The board item's id, which is what a field write addresses.
10013 // 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.
10014 item_id: String,
10015 /// The web address GitHub gave the issue, when it gave one.
10016 // 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.
10017 url: Option<String>,
10018 /// The issue's number on its repository, when GitHub reported one.
10019 number: Option<u64>,
10020}
10021
10022impl BoardId {
10023 fn parse(id: &str) -> Result<Self, SourceError> {
10024 if id.trim().is_empty() {
10025 return Err(SourceError::Malformed {
10026 message: "GitHub named a board with a blank node id".into(),
10027 });
10028 }
10029 Ok(Self(id.to_owned()))
10030 }
10031
10032 fn as_str(&self) -> &str {
10033 &self.0
10034 }
10035}
10036
10037impl Board {
10038 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
10039 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
10040 let nodes = fields
10041 .get("nodes")
10042 .and_then(Value::as_array)
10043 .ok_or_else(|| SourceError::Malformed {
10044 message: "GitHub project fields.nodes is not an array".into(),
10045 })?;
10046 Ok(nodes
10047 .iter()
10048 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
10049 }
10050}
10051
10052/// One board item, resolved into everything this source reports about it.
10053///
10054/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
10055/// [`TaskSource::prime_write`] takes back — see [`expected`].
10056#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
10057struct Resolved {
10058 item_id: String,
10059 id: NativeId,
10060 content_kind: ContentKind,
10061 kind: BoardKind,
10062 title: String,
10063 body: Option<String>,
10064 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
10065 /// that changes the slot alone has to keep byte for byte outside it.
10066 raw_body: Option<String>,
10067 status: Status,
10068 /// The name of the board `Status` option this item sits in, as the board spells it.
10069 option: Option<String>,
10070 /// What its `Priority` field says, read through this instance's mapping.
10071 priority: HeldPriority,
10072 /// Whether this item's issue is closed. A draft has no such state and is never closed.
10073 closed: bool,
10074 /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
10075 /// issue, a draft, and an issue closed before GitHub recorded reasons.
10076 #[serde(default)]
10077 reason: Option<String>,
10078 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
10079 delivers: Vec<TaskRef>,
10080 /// Every task that delivers this one, read out of its slot. Empty for anything not a
10081 /// task.
10082 delivered_by: Vec<TaskRef>,
10083 labels: Vec<Label>,
10084 parent: Option<NativeId>,
10085 // 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.
10086 origin: Option<String>,
10087 /// What each board text field this instance projects metadata onto holds for this item,
10088 /// by field name — only the fields holding a value, an empty text included. What a write compares the
10089 /// item's metadata against, so a field already holding its value is not written again;
10090 /// never read back as metadata, which the slot alone reports.
10091 projected: BTreeMap<String, String>,
10092 /// The issue's own number on its repository, as GitHub reports it.
10093 ///
10094 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
10095 /// declares none, and a draft is not filed in a repository to be numbered by one — and
10096 /// an issue this run created whose creating mutation answered without one, which is a
10097 /// response GitHub's own schema says cannot happen and which a landed write is not
10098 /// worth failing over. An `Issue` read off the board always has one.
10099 number: Option<u64>,
10100 url: Option<String>,
10101 created_at: Option<DateTime<Utc>>,
10102 updated_at: Option<DateTime<Utc>>,
10103 /// When the board item itself last changed — what a board field write moves — when the
10104 /// read that reached it said.
10105 item_updated_at: Option<DateTime<Utc>>,
10106 /// How many comments the issue holds, when the read that reached it said.
10107 comment_count: Option<u64>,
10108 /// How many relationship events the issue's timeline holds — every dependency, as
10109 /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
10110 /// removed — when the read that reached it said.
10111 relationship_events: Option<u64>,
10112 /// The server's clock on the response that read it — its `Date` header — when the
10113 /// response carried one.
10114 served_at: Option<DateTime<Utc>>,
10115 /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
10116 /// its labels — when the read that reached it carried all three whole.
10117 #[serde(default)]
10118 facets: Option<change_token::FacetsDigest>,
10119 own_repository: Option<Repository>,
10120 repositories: Vec<Repository>,
10121 classification: Classification,
10122 slot: BTreeMap<String, Value>,
10123 /// The node id of the board this item sits on, when the read that reached it said.
10124 board_id: Option<String>,
10125 /// The definition of every board field this item holds a value of, in the shape a read
10126 /// of the board's own `fields` gives one.
10127 ///
10128 /// Only the fields this item has a value in: a field it holds nothing of is not here,
10129 /// which says nothing about whether the board has it.
10130 fields: Vec<Value>,
10131 /// Every field the board this item sits on defines, as its own read of the board's
10132 /// `fields` gives them — when the read that reached the item carried them, which a read
10133 /// of it by its own id does. What a write of it needs of the board, then, needs no read
10134 /// of the board.
10135 board_fields: Option<Value>,
10136 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
10137 /// selects one — when the read that reached it carried the connection to its end, which a
10138 /// read of it by its own id does for any issue blocked by no more than a page. What a
10139 /// write reconciles that relationship against, and what a read of its forward edges in
10140 /// the same command answers with.
10141 blocked_by: Option<Vec<Value>>,
10142}
10143
10144impl Resolved {
10145 /// The board this item's own read names it on, when that read named one this source can
10146 /// address.
10147 fn named_board(&self) -> Option<BoardId> {
10148 self.board_id
10149 .as_deref()
10150 .and_then(|id| BoardId::parse(id).ok())
10151 }
10152
10153 /// The board's id and every field it defines, when the read that reached this item
10154 /// carried both — which a read of it by its own id does.
10155 fn carried_board(&self) -> Option<BoardFields> {
10156 Some(BoardFields {
10157 id: self.named_board()?,
10158 fields: self.board_fields.clone()?,
10159 })
10160 }
10161
10162 /// Whether this item holds a value of the board field called `name`, and so carries
10163 /// that field's definition. `false` says nothing about whether the board has the field.
10164 fn defines(&self, name: &str) -> bool {
10165 self.fields
10166 .iter()
10167 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
10168 }
10169
10170 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
10171 /// in a field of its own, and none of the five keys that are only an encoding.
10172 ///
10173 /// The two delivery keys are left out for every kind, not only for a task: they are
10174 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
10175 /// document carrying one holds nothing a caller's own metadata could mean by it.
10176 fn metadata(&self) -> BTreeMap<String, Value> {
10177 let mut metadata = self.slot.clone();
10178 metadata.remove(Repository::METADATA_KEY);
10179 metadata.remove(DependencyEdge::RECORDED_KEY);
10180 metadata.remove(ItemKind::METADATA_KEY);
10181 metadata.remove(TaskRef::DELIVERS_KEY);
10182 metadata.remove(TaskRef::DELIVERED_BY_KEY);
10183 metadata.remove(Classification::METADATA_KEY);
10184 // The board field is the origin, and the body's copy of it is only a mirror for the
10185 // issue search to find: an item whose field holds none has none, whatever its body
10186 // says, so no reader ever sees two answers.
10187 metadata.remove(ORIGIN_KEY);
10188 if let Some(origin) = &self.origin {
10189 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
10190 }
10191 metadata
10192 }
10193
10194 /// Where this item is, as a link a reader can open.
10195 ///
10196 /// A board is a hosted place and every issue on it has a web address, so that address
10197 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
10198 /// of place it is, so a reader knows to open it rather than to read a file out. It
10199 /// does not replace or derive from `url`: the field goes on reporting exactly what it
10200 /// reported before, and this says what that address *is*.
10201 ///
10202 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
10203 /// rather than a third variant, which is the contract's "the source did not say". An
10204 /// issue this run created is not one of those: its address comes back from the
10205 /// creating mutation, so it is somewhere a reader can open from the moment it exists
10206 /// rather than from whenever the board read catches up.
10207 fn location(&self) -> Option<Location> {
10208 self.url.clone().map(Location::Url)
10209 }
10210
10211 /// The short handle this board's backend shows people for a task: the issue's number
10212 /// alone, as a decimal string.
10213 ///
10214 /// The number alone rather than `owner/repo#1043`, because that is the contract's
10215 /// value for this backend. A draft has no number and so no handle, which is the
10216 /// contract's *absent* rather than a handle of some other shape — and the native
10217 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
10218 /// derives from.
10219 fn key(&self) -> Option<String> {
10220 self.number.map(|number| number.to_string())
10221 }
10222
10223 /// This item's change token — see [`change_token`] — or `None` when the read that
10224 /// reached it did not carry all six of its parts, which a draft never does, did not say
10225 /// when GitHub served it, or found it changed too recently to have settled.
10226 fn version(&self) -> Option<String> {
10227 match self.content_kind {
10228 ContentKind::Issue => {
10229 let (issue, item) = (self.updated_at?, self.item_updated_at?);
10230 let parts = change_token::Parts {
10231 issue,
10232 item,
10233 comments: self.comment_count?,
10234 relationships: self.relationship_events?,
10235 facets: self.facets.clone()?,
10236 repository: self.own_repository.clone()?,
10237 };
10238 change_token::settled(issue, item, self.served_at?)
10239 .then(|| change_token::spell(&parts))
10240 }
10241 ContentKind::DraftIssue => None,
10242 }
10243 }
10244
10245 /// Whether its `Priority` field holds a value at all, mapped or not.
10246 fn holds_priority(&self) -> bool {
10247 self.priority != HeldPriority::Read(Priority::None)
10248 }
10249
10250 /// The task this item is.
10251 ///
10252 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
10253 /// reading that as a level would be a guess, and reading it as `none` would let the next
10254 /// copy clear a priority a person set.
10255 fn task(&self) -> Result<Task, SourceError> {
10256 let priority = match &self.priority {
10257 HeldPriority::Read(priority) => *priority,
10258 HeldPriority::Unmapped(option) => {
10259 return Err(SourceError::Malformed {
10260 message: format!(
10261 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
10262 this source's priority_mapping does not name, so its priority cannot be \
10263 read; next: name {option:?} under priority_mapping, or move the item to \
10264 a mapped option",
10265 self.id,
10266 self.number
10267 .map(|number| format!(" (#{number})"))
10268 .unwrap_or_default()
10269 ),
10270 });
10271 }
10272 };
10273 Ok(Task {
10274 version: self.version(),
10275 id: self.id.clone(),
10276 key: self.key(),
10277 title: self.title.clone(),
10278 content: self.body.clone(),
10279 status: self.status.clone(),
10280 priority,
10281 labels: self.labels.clone(),
10282 project: self.parent.clone(),
10283 url: self.url.clone(),
10284 location: self.location(),
10285 created_at: self.created_at,
10286 updated_at: self.updated_at,
10287 metadata: self.metadata(),
10288 repositories: self.repositories.clone(),
10289 delivers: self.delivers.clone(),
10290 delivered_by: self.delivered_by.clone(),
10291 classification: self.classification,
10292 })
10293 }
10294
10295 fn project(&self) -> Project {
10296 Project {
10297 version: self.version(),
10298 id: self.id.clone(),
10299 title: self.title.clone(),
10300 content: self.body.clone(),
10301 status: self.status.clone(),
10302 labels: self.labels.clone(),
10303 url: self.url.clone(),
10304 location: self.location(),
10305 created_at: self.created_at,
10306 updated_at: self.updated_at,
10307 metadata: self.metadata(),
10308 repositories: self.repositories.clone(),
10309 classification: self.classification,
10310 }
10311 }
10312
10313 /// The same issue as a document: the project it is filed under, and no status and no
10314 /// dependencies, because a document is not work.
10315 fn document(&self) -> Document {
10316 Document {
10317 version: self.version(),
10318 id: self.id.clone(),
10319 title: self.title.clone(),
10320 content: self.body.clone(),
10321 project: self.parent.clone(),
10322 labels: self.labels.clone(),
10323 url: self.url.clone(),
10324 location: self.location(),
10325 created_at: self.created_at,
10326 updated_at: self.updated_at,
10327 metadata: self.metadata(),
10328 repositories: self.repositories.clone(),
10329 classification: self.classification,
10330 }
10331 }
10332}
10333
10334/// Where one targeted update moves an item's status, and which of its two halves move.
10335struct StatusMove {
10336 /// The board the item's `Status` field is on.
10337 board: BoardId,
10338 /// The `Status` field's id.
10339 field: String,
10340 /// The option's id.
10341 option: String,
10342 /// The option's name, as the board spells it.
10343 name: String,
10344 /// What the status asks of the issue's state.
10345 target: StatusTarget,
10346 /// The status the item reads as once it is there.
10347 landed: Status,
10348 /// Which of the status's two halves differ from what the item holds.
10349 moves: Moves,
10350}
10351
10352/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
10353/// closed state of its issue, or both. A status neither half of which differs is no move at all,
10354/// and is not a value of this type.
10355#[derive(Clone, Copy, PartialEq, Eq)]
10356enum Moves {
10357 /// The option alone.
10358 Option,
10359 /// The issue's state alone: open, closed, or closed with another reason.
10360 State,
10361 /// Both.
10362 Both,
10363}
10364
10365impl Moves {
10366 /// What differs, or `None` when nothing does.
10367 const fn of(option: bool, state: bool) -> Option<Self> {
10368 match (option, state) {
10369 (true, true) => Some(Self::Both),
10370 (true, false) => Some(Self::Option),
10371 (false, true) => Some(Self::State),
10372 (false, false) => None,
10373 }
10374 }
10375
10376 /// Whether the option moves.
10377 const fn option(self) -> bool {
10378 matches!(self, Self::Option | Self::Both)
10379 }
10380
10381 /// Whether the issue's state moves.
10382 const fn state(self) -> bool {
10383 matches!(self, Self::State | Self::Both)
10384 }
10385}
10386
10387/// What one write is, and the status that comes with being it.
10388///
10389/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
10390/// status and a task or a project always has one, so "a document carrying a status" and
10391/// "a task carrying none" are states a write cannot be in rather than states every use
10392/// site below has to defend against.
10393enum Written<'a> {
10394 /// A document, which is not work and so has no status at all.
10395 Document,
10396 /// A task or a project, and the status it is being written with.
10397 Work(ItemKind, &'a Status),
10398}
10399
10400impl Written<'_> {
10401 /// Which of the board's three kinds this write is.
10402 const fn kind(&self) -> BoardKind {
10403 match self {
10404 Self::Document => BoardKind::Document,
10405 Self::Work(kind, _) => BoardKind::Work(*kind),
10406 }
10407 }
10408
10409 /// The status this write carries. A document carries none, so a write of one says
10410 /// nothing about the issue's open or closed state and selects no board `Status`
10411 /// option.
10412 const fn status(&self) -> Option<&Status> {
10413 match self {
10414 Self::Document => None,
10415 Self::Work(_, status) => Some(status),
10416 }
10417 }
10418
10419 /// The status this write carries with the kind whose half of `status_mapping` it is
10420 /// written through.
10421 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
10422 match self {
10423 Self::Document => None,
10424 Self::Work(kind, status) => Some((*kind, status)),
10425 }
10426 }
10427}
10428
10429/// The item being written, in the one shape all three write methods reach.
10430struct Incoming<'a> {
10431 written: Written<'a>,
10432 /// The title a person wrote. A document's goes onto the issue with
10433 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
10434 title: &'a str,
10435 content: Option<&'a str>,
10436 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
10437 labels: &'a [Label],
10438 metadata: &'a BTreeMap<String, Value>,
10439 repositories: &'a [Repository],
10440 /// Recorded in the slot while private, and nowhere while public.
10441 classification: Classification,
10442 parent: Option<&'a NativeId>,
10443 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
10444 /// what keeps either key out of their slot.
10445 delivers: &'a [TaskRef],
10446 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
10447 delivered_by: &'a [TaskRef],
10448 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
10449 /// project, a document, and every write to an instance with no `priority_mapping` —
10450 /// which is what keeps such a write's requests exactly what they were before.
10451 priority: Option<Priority>,
10452}
10453
10454/// What one write does to one projected metadata text field, by the field's id.
10455#[derive(Debug, Clone)]
10456enum ProjectionWrite {
10457 /// Write this text.
10458 Set {
10459 field: String, // llmlint: ignore[invalid_states_unrepresentable] A private opaque GraphQL field id passed straight back to GitHub, as `PriorityWrite`'s are; GitHub publishes no grammar for it.
10460 text: String,
10461 },
10462 /// Clear the field's value.
10463 Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
10464}
10465
10466impl ProjectionWrite {
10467 /// Add this write to a field write's list of values and list of clears.
10468 fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
10469 match self {
10470 Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
10471 Self::Clear { field } => clears.push(field),
10472 }
10473 }
10474}
10475
10476/// What one write does to an item's `Priority` field.
10477enum PriorityWrite {
10478 /// Select this option of this field.
10479 Select {
10480 /// The `Priority` field's id.
10481 field: String,
10482 /// The mapped option's id.
10483 option: String,
10484 },
10485 /// Clear the field's value, which is what `none` is.
10486 Clear {
10487 /// The `Priority` field's id.
10488 field: String,
10489 },
10490}
10491
10492impl Incoming<'_> {
10493 /// The title this write puts on the issue.
10494 fn written_title(&self) -> String {
10495 match self.written {
10496 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
10497 Written::Work(..) => self.title.to_owned(),
10498 }
10499 }
10500}
10501
10502#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10503enum ContentKind {
10504 DraftIssue,
10505 Issue,
10506}
10507
10508/// What one board issue is: a document, or the work an [`ItemKind`] names.
10509///
10510/// A type of this source's own rather than an `ItemKind` with a third variant, because
10511/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
10512/// document — the contract keeps a document out of that enum deliberately. Holding the
10513/// board's three answers in one value is what makes every place that asks "which is this?"
10514/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
10515/// two thirds of the board.
10516#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10517enum BoardKind {
10518 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
10519 Document,
10520 /// Every other issue, and every draft.
10521 Work(ItemKind),
10522}
10523
10524impl BoardKind {
10525 /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
10526 /// sub-issues and carrying the kind marker `marked` in its slot.
10527 ///
10528 /// The design prefix is read *first*, before either of the two rules that separate a
10529 /// project from a task. A document is not work whatever sub-issues it has and whatever
10530 /// marker it carries, and reading the prefix later would make a design issue with none of
10531 /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
10532 /// overrides it: an issue filed under a project is that project's task even when it has
10533 /// sub-issues of its own.
10534 fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
10535 if title.starts_with(DESIGN_TITLE_PREFIX) {
10536 Self::Document
10537 } else if filed {
10538 Self::Work(ItemKind::Task)
10539 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
10540 Self::Work(ItemKind::Project)
10541 } else {
10542 Self::Work(ItemKind::Task)
10543 }
10544 }
10545
10546 /// What the change token's facets call this kind.
10547 const fn facet(self) -> change_token::FacetKind {
10548 match self {
10549 Self::Document => change_token::FacetKind::Document,
10550 Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
10551 Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
10552 }
10553 }
10554
10555 /// Whose half of `status_mapping` an item of this kind reads its status through. A
10556 /// document has no status of its own, so the task half stands in for whatever the issue
10557 /// holds; nothing reports it.
10558 const fn status_kind(self) -> ItemKind {
10559 match self {
10560 Self::Document => ItemKind::Task,
10561 Self::Work(kind) => kind,
10562 }
10563 }
10564
10565 /// How a refusal names this kind to the person reading it.
10566 const fn describes(self) -> &'static str {
10567 match self {
10568 Self::Document => "document",
10569 Self::Work(kind) => kind.marker(),
10570 }
10571 }
10572}
10573
10574/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
10575///
10576/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
10577/// the shared cross-source journeys assert one answer to one question, so two sources
10578/// that disagree about what "carries the label bug" means fail them.
10579fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
10580 let holds = |name: &String| {
10581 labels
10582 .iter()
10583 .any(|label| label.name.eq_ignore_ascii_case(name))
10584 };
10585 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
10586 && filter.all_of.iter().all(holds)
10587 && !filter.none_of.iter().any(holds)
10588}
10589
10590/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
10591/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
10592fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
10593 statuses.is_empty() || statuses.contains(&category)
10594}
10595
10596/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
10597///
10598/// `content` is the item's own prose — the body with this source's trailing metadata
10599/// comment already taken off — so a search never matches an encoding the author of the
10600/// issue never wrote.
10601fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
10602 let terms = query.terms.to_lowercase();
10603 let in_title = title.to_lowercase().contains(&terms);
10604 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
10605 match query.fields {
10606 TextFields::Title => in_title,
10607 TextFields::Content => in_content,
10608 TextFields::TitleOrContent => in_title || in_content,
10609 }
10610}
10611
10612/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
10613///
10614/// The project predicate is passed separately because a read narrowed to one project has
10615/// already answered it by asking *that project* for its own items — and re-applying it
10616/// there would compare the caller's selector, which may be a project's **name**, against
10617/// the id of the project that name resolved to, and keep nothing. Every other read passes
10618/// `query.project` and applies it here, which is what keeps `projects` a predicate this
10619/// source really does apply.
10620fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
10621 labels_match(&task.labels, &query.labels)
10622 && status_matches(task.status.category, &query.statuses)
10623 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
10624 && match project {
10625 ProjectFilter::Any => true,
10626 ProjectFilter::Orphans => task.project.is_none(),
10627 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
10628 }
10629 && query
10630 .text
10631 .as_ref()
10632 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
10633 // Against the parsed metadata slot, and against the origin field, which is where
10634 // `Resolved::metadata` reads each of them from.
10635 && query.metadata_matches(&task.metadata)
10636 && query.origin_matches(&task.metadata)
10637}
10638
10639fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
10640 labels_match(&project.labels, &query.labels)
10641 && status_matches(project.status.category, &query.statuses)
10642 && query
10643 .text
10644 .as_ref()
10645 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
10646}
10647
10648/// The same three predicates a task query carries, minus the status filter.
10649///
10650/// A document is not work, so it has no status for one to compare against and the query
10651/// type carries none. The project predicate is the same one — a design issue filed under a
10652/// project issue is in that project, and one filed under nothing is in none — so it is
10653/// spelled the same way here rather than answered differently.
10654fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
10655 labels_match(&document.labels, &query.labels)
10656 && match project {
10657 ProjectFilter::Any => true,
10658 ProjectFilter::Orphans => document.project.is_none(),
10659 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
10660 }
10661 && query
10662 .text
10663 .as_ref()
10664 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
10665}
10666
10667/// Why a GitHub item's change token does not vouch for its forward edges.
10668///
10669/// A forward edge names its far end and that far end's kind, and the near item's token moves
10670/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
10671/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
10672/// nothing the near token reads, so an edge kept at the near version could name a far end that
10673/// is no longer what it said.
10674const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
10675 whether it became a document, or its deletion, so its forward edges are always read from \
10676 github";
10677
10678/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
10679/// categories an open issue reads as narrows a board-scoped search with.
10680const OPEN_QUALIFIER: &str = "is:open";
10681
10682/// The board filter `task status reconcile` reads the board through: closed items alone.
10683const CLOSED_FILTER: &str = "is:closed";
10684
10685/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
10686/// one reference, and a slot the batch has none for names its last one again.
10687fn reference_batch(batch: &[IssueRef]) -> Value {
10688 let mut variables = serde_json::Map::new();
10689 for slot in 0..REFERENCE_BATCH {
10690 let issue = batch.get(slot).or(batch.last());
10691 variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
10692 variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
10693 variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
10694 }
10695 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
10696 variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
10697 variables.insert("duplicates".to_owned(), json!(true));
10698 Value::Object(variables)
10699}
10700
10701/// One issue an issue read by reference answered, in the shape every other read of an issue
10702/// hands its resolver: each board membership's `Status` value, read by name, as the one field
10703/// value that membership carries.
10704///
10705/// A membership's `status` is `null` when its card sits at no option — or on a board with no
10706/// field of that name — and otherwise an option value naming its option; anything else is not
10707/// an answer to this read, and is refused rather than read as a card at no option.
10708fn held_issue(issue: &Value) -> Result<Value, SourceError> {
10709 let malformed = |what: &str| SourceError::Malformed {
10710 message: format!("GitHub answered an issue read by reference with {what}"),
10711 };
10712 let mut issue = issue.clone();
10713 let nodes = issue
10714 .pointer_mut("/projectItems/nodes")
10715 .and_then(Value::as_array_mut)
10716 .ok_or_else(|| malformed("no list of board memberships"))?;
10717 for node in nodes {
10718 let node = node
10719 .as_object_mut()
10720 .ok_or_else(|| malformed("a board membership that is not an object"))?;
10721 let status = match node.get("status") {
10722 None => return Err(malformed("a board membership with no status member")),
10723 Some(Value::Null) => None,
10724 Some(status) if status.get("name").is_some_and(Value::is_string) => {
10725 Some(status.clone())
10726 }
10727 // A single-select value of another field type answers `{}`; only an option value
10728 // carries a name.
10729 Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
10730 Some(_) => return Err(malformed("a membership status that names no option")),
10731 };
10732 node.insert(
10733 "fieldValues".to_owned(),
10734 json!({"nodes": status.into_iter().collect::<Vec<_>>(),
10735 "pageInfo": {"hasNextPage": false}}),
10736 );
10737 }
10738 Ok(issue)
10739}
10740
10741/// The issue one slot of a read by reference answered, `None` when GitHub answered that
10742/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
10743/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
10744/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
10745/// than a reference nothing holds.
10746fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
10747 let alias = format!("r{slot}");
10748 let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
10749 message: format!("GitHub answered a read by reference with no {alias}"),
10750 })?;
10751 if repository.is_null() {
10752 return Ok(None);
10753 }
10754 let issue = repository
10755 .get("issue")
10756 .ok_or_else(|| SourceError::Malformed {
10757 message: format!("GitHub answered {alias} of a read by reference with no issue"),
10758 })?;
10759 if issue.is_null() {
10760 return Ok(None);
10761 }
10762 // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
10763 if optional_str(issue, "__typename")? != Some("Issue") {
10764 return Err(SourceError::Malformed {
10765 message: format!(
10766 "GitHub answered {alias} of a read by reference with an issue whose __typename \
10767 is not Issue"
10768 ),
10769 });
10770 }
10771 Ok(Some(issue))
10772}
10773
10774/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
10775fn spoken_reason(reason: &str) -> String {
10776 reason.to_ascii_lowercase().replace('_', " ")
10777}
10778
10779impl GitHubProjectsSource {
10780 /// Every task and project of this board whose card contradicts its issue's closure, each
10781 /// judged against its own kind's half of `status_mapping`, read through the board's own
10782 /// `is:closed` filter, page by page — never the board's unfiltered `items`.
10783 ///
10784 /// Each item the filter answers is confirmed from its own node: an open one, one whose
10785 /// closure reads `unknown`, and one whose card is already in line are left out whatever
10786 /// the filter let through. The board's fields ride along and are held for the option
10787 /// writes that follow, so those need no read of the board.
10788 async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
10789 let mut after: Option<String> = None;
10790 let mut found = Vec::new();
10791 loop {
10792 let data = self
10793 .graphql(
10794 graphql::CLOSED_ITEMS,
10795 json!({"owner":self.owner,"number":self.project_number,
10796 "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
10797 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
10798 )
10799 .await?;
10800 let board = data
10801 .pointer("/closedItems/projectV2")
10802 .filter(|value| !value.is_null())
10803 .ok_or_else(|| SourceError::Refused {
10804 message: format!(
10805 "GitHub project {}/{} was not found or is not visible to the token",
10806 self.owner, self.project_number
10807 ),
10808 })?;
10809 *self.fields_cache()? = Some(BoardFields {
10810 id: BoardId::parse(required_str(board, "id")?)?,
10811 fields: board.get("fields").cloned().unwrap_or(Value::Null),
10812 });
10813 let items = board.get("items").ok_or_else(|| SourceError::Malformed {
10814 message: "GitHub project closed items are missing".into(),
10815 })?;
10816 for item in items
10817 .get("nodes")
10818 .and_then(Value::as_array)
10819 .ok_or_else(|| SourceError::Malformed {
10820 message: "GitHub project items.nodes is not an array".into(),
10821 })?
10822 {
10823 let Some(resolved) = self.resolve(item)? else {
10824 continue;
10825 };
10826 let BoardKind::Work(kind) = resolved.kind else {
10827 continue;
10828 };
10829 let Some((closed_as, name)) = self.statuses.contradicted(
10830 kind,
10831 resolved.option.as_deref(),
10832 resolved.closed,
10833 resolved.reason.as_deref(),
10834 ) else {
10835 continue;
10836 };
10837 found.push(StatusContradiction {
10838 closed_as,
10839 to: name.to_owned(),
10840 option: resolved.option.clone(),
10841 item: match kind {
10842 ItemKind::Task => ContradictedItem::Task(resolved.task()?),
10843 ItemKind::Project => ContradictedItem::Project(resolved.project()),
10844 },
10845 });
10846 }
10847 let info = items
10848 .get("pageInfo")
10849 .ok_or_else(|| SourceError::Malformed {
10850 message: "GitHub project items have no pageInfo".into(),
10851 })?;
10852 if !required_bool(info, "hasNextPage")? {
10853 return Ok(found);
10854 }
10855 let next = required_str(info, "endCursor")?;
10856 validate_cursor_progress(after.as_deref(), next)?;
10857 after = Some(next.to_owned());
10858 }
10859 }
10860
10861 /// What this board holds for each issue in `issues`, once each in the order given, read
10862 /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
10863 ///
10864 /// An issue GitHub cannot find for this source's credential, and one with no item on this
10865 /// board, are both absent. A closure that already says the work will not be done here —
10866 /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
10867 /// completed whose card sits at its kind's `done` option is unchanged, and every other item
10868 /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
10869 /// tasks as sub-issues — is held under exactly these rules, judged against the project
10870 /// kind's half of `status_mapping`.
10871 async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
10872 let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
10873 for issue in issues {
10874 if !distinct.contains(issue) {
10875 distinct.push(issue.clone());
10876 }
10877 }
10878 let done = |kind: ItemKind| {
10879 self.statuses
10880 .target(kind, StatusCategory::Done)
10881 .option()
10882 .unwrap_or("Done")
10883 .to_owned()
10884 };
10885 // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
10886 // its range names no issue any repository can hold: it is answered absent and never sent,
10887 // where binding it would have GitHub refuse the whole batch.
10888 let readable: Vec<IssueRef> = distinct
10889 .iter()
10890 .filter(|issue| i32::try_from(issue.number).is_ok())
10891 .cloned()
10892 .collect();
10893 let mut holdings = Vec::with_capacity(readable.len());
10894 for batch in readable.chunks(REFERENCE_BATCH) {
10895 let data = self
10896 .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
10897 .await?;
10898 for (slot, issue) in batch.iter().enumerate() {
10899 let node = referenced_issue(&data, slot)?;
10900 let resolved = match node {
10901 Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
10902 None => None,
10903 };
10904 // A task and a project are both held; a document is not work, so nothing is.
10905 let Some((item, kind)) = resolved.and_then(|item| match item.kind {
10906 BoardKind::Work(kind) => Some((item, kind)),
10907 BoardKind::Document => None,
10908 }) else {
10909 holdings.push(IssueHolding {
10910 issue: issue.clone(),
10911 completion: Completion::Absent,
10912 });
10913 continue;
10914 };
10915 let task = match kind {
10916 ItemKind::Task => {
10917 let held = item.task()?;
10918 HeldTask {
10919 id: held.id,
10920 kind,
10921 option: item.option.clone(),
10922 classification: held.classification,
10923 repositories: held.repositories,
10924 delivers: held.delivers,
10925 }
10926 }
10927 // A project delivers no task, so what its write keeps in step is nothing.
10928 ItemKind::Project => {
10929 let held = item.project();
10930 HeldTask {
10931 id: held.id,
10932 kind,
10933 option: item.option.clone(),
10934 classification: held.classification,
10935 repositories: held.repositories,
10936 delivers: Vec::new(),
10937 }
10938 }
10939 };
10940 let done = done(kind);
10941 let completion = match (item.closed, item.reason.as_deref()) {
10942 (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
10943 task,
10944 reason: format!("closed as {}", spoken_reason(reason)),
10945 },
10946 (true, None | Some("COMPLETED"))
10947 if item
10948 .option
10949 .as_deref()
10950 .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
10951 {
10952 Completion::Unchanged { task }
10953 }
10954 _ => {
10955 let kept = item.clone();
10956 self.held_cache
10957 .lock()
10958 .map_err(|_| SourceError::Unavailable {
10959 message: "items read by issue reference were left \
10960 inconsistent; run the command again"
10961 .into(),
10962 })?
10963 .insert(kept.id.clone(), kept);
10964 Completion::Due {
10965 task,
10966 to: done.clone(),
10967 }
10968 }
10969 };
10970 holdings.push(IssueHolding {
10971 issue: issue.clone(),
10972 completion,
10973 });
10974 }
10975 }
10976 let mut read = holdings.into_iter();
10977 let mut answered = Vec::with_capacity(distinct.len());
10978 for issue in distinct {
10979 if i32::try_from(issue.number).is_ok() {
10980 answered.push(read.next().ok_or_else(|| SourceError::Malformed {
10981 message: format!("no holding was read for {issue}"),
10982 })?);
10983 } else {
10984 answered.push(IssueHolding {
10985 issue,
10986 completion: Completion::Absent,
10987 });
10988 }
10989 }
10990 Ok(answered)
10991 }
10992}
10993
10994#[async_trait::async_trait]
10995impl TaskSource for GitHubProjectsSource {
10996 fn kind(&self) -> &'static str {
10997 KIND
10998 }
10999 fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
11000 change_token::order(a, b)
11001 }
11002 /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
11003 /// member, the change each part of the token moves for. Not its forward edges — see
11004 /// [`FAR_ENDS_UNCOVERED`].
11005 fn version_coverage(&self) -> VersionCoverage {
11006 VersionCoverage {
11007 forward_edges: Coverage::Uncovered {
11008 reason: FAR_ENDS_UNCOVERED.to_owned(),
11009 },
11010 ..VersionCoverage::whole()
11011 }
11012 }
11013 /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
11014 /// and for a far end deleted, so the token sees every change to a task's edges but one — a
11015 /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
11016 /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
11017 /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
11018 // llmlint: ignore[names_match_behavior] The manager's ruling (c-d7838f2c) keeps 0.6.0's GitHub skip and has this plugin state that bounded claim through the coverage it reports, with the bound documented here, in §4.13d and by the copy_skip journey; a separate bounded representation would be a contract change beyond a defaulted addition, which the ruling reserves for the contract's owner, and a follow-up is drafted for a far-end-aware skip.
11019 fn copy_version_coverage(&self) -> VersionCoverage {
11020 VersionCoverage::whole()
11021 }
11022 /// A project's fields, and not its forward edges, for the same reason a task's are not.
11023 fn project_version_coverage(&self) -> ProjectVersionCoverage {
11024 ProjectVersionCoverage {
11025 fields: Coverage::Covered,
11026 forward_edges: Coverage::Uncovered {
11027 reason: FAR_ENDS_UNCOVERED.to_owned(),
11028 },
11029 }
11030 }
11031 /// A document's fields and the manifest of its image assets, which its metadata slot
11032 /// records: both are the issue's body, whose every edit moves the token's issue time.
11033 fn document_version_coverage(&self) -> DocumentVersionCoverage {
11034 DocumentVersionCoverage::whole()
11035 }
11036 fn write_context(&self, id: &NativeId) -> Option<Value> {
11037 self.context_of(id)
11038 }
11039 fn board_context(&self) -> Option<Value> {
11040 self.board_context_held()
11041 }
11042 fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
11043 self.prime(id, context, board)
11044 }
11045 fn unprime(&self, id: &NativeId) {
11046 self.unprime_held(id);
11047 }
11048 fn begin_journal(&self) {
11049 self.expected.begin();
11050 }
11051 fn end_journal(&self) -> Option<WriteJournal> {
11052 Some(self.expected.end())
11053 }
11054 fn capabilities(&self) -> Capabilities {
11055 Capabilities {
11056 projects: Support::Native,
11057 documents: Support::Native,
11058 comments: Support::Native,
11059 assets: Support::Native,
11060 priority: if self.priorities.is_some() {
11061 Support::Native
11062 } else {
11063 Support::Unsupported
11064 },
11065 filter_by_priority: Support::Native,
11066 filter_by_comment_activity: Support::Native,
11067 filter_by_metadata: Support::Native,
11068 filter_by_origin: Support::Native,
11069 reconcile_status: Support::Native,
11070 complete_by_issue: Support::Native,
11071 orphan_tasks: Support::Native,
11072 filter_by_label: Support::Native,
11073 filter_by_status: Support::Native,
11074 search_title: Support::Native,
11075 search_content: Support::Native,
11076 task_dependencies: DependencySupport::BothDirections,
11077 project_dependencies: DependencySupport::BothDirections,
11078 max_page_size: MAX_PAGE_SIZE,
11079 }
11080 }
11081 async fn visibility(
11082 &self,
11083 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
11084 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
11085 self.write_visibility(target).await
11086 }
11087 async fn health(&self) -> Result<Health, SourceError> {
11088 let board = self.board_page(None, 1).await?;
11089 Ok(Health {
11090 reachable: true,
11091 detail: Some(format!(
11092 "reading GitHub project {}/{} ({})",
11093 self.owner,
11094 self.project_number,
11095 required_str(&board, "title")?
11096 )),
11097 })
11098 }
11099 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
11100 self.item_by_id(id)
11101 .await?
11102 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
11103 .map(|item| item.task())
11104 .transpose()
11105 }
11106 async fn task_assets(
11107 &self,
11108 id: &NativeId,
11109 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
11110 self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
11111 }
11112 async fn task_asset(
11113 &self,
11114 id: &NativeId,
11115 name: &onetaskgraph_plugin_api::AssetName,
11116 ) -> Result<Option<Vec<u8>>, SourceError> {
11117 self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
11118 .await
11119 }
11120 async fn set_task_rendering_with_assets(
11121 &self,
11122 id: &NativeId,
11123 content: &str,
11124 provenance: &Value,
11125 _answers: &BTreeMap<String, Value>,
11126 assets: &onetaskgraph_plugin_api::AssetWrite,
11127 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
11128 self.replace_rendering(
11129 id,
11130 BoardKind::Work(ItemKind::Task),
11131 content,
11132 provenance,
11133 Some(assets),
11134 )
11135 .await
11136 }
11137 async fn document_assets(
11138 &self,
11139 id: &NativeId,
11140 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
11141 self.held_assets(id, BoardKind::Document).await
11142 }
11143 async fn document_asset(
11144 &self,
11145 id: &NativeId,
11146 name: &onetaskgraph_plugin_api::AssetName,
11147 ) -> Result<Option<Vec<u8>>, SourceError> {
11148 self.held_asset(id, BoardKind::Document, name).await
11149 }
11150 async fn set_document_rendering_with_assets(
11151 &self,
11152 id: &NativeId,
11153 content: &str,
11154 provenance: &Value,
11155 _answers: &BTreeMap<String, Value>,
11156 assets: &onetaskgraph_plugin_api::AssetWrite,
11157 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
11158 self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
11159 .await
11160 }
11161 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
11162 Ok(self
11163 .item_by_id(id)
11164 .await?
11165 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
11166 .map(|item| item.project()))
11167 }
11168 async fn query_tasks(
11169 &self,
11170 query: &TaskQuery,
11171 page: &PageRequest,
11172 ) -> Result<Page<Task>, SourceError> {
11173 validate_page(page)?;
11174 refuse_unsearchable(query)?;
11175 // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
11176 // category — `unknown`; every other category reads only off an open issue or a draft. So
11177 // a status filter naming none of those three asks every issue search it sends for open
11178 // issues alone; each candidate's category is still confirmed from its own item below.
11179 let open_only = !query.statuses.is_empty()
11180 && query.statuses.iter().all(|category| {
11181 !matches!(
11182 category,
11183 StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
11184 )
11185 });
11186 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
11187 let mut qualifiers: Vec<String> = [
11188 query.commented_since.map(updated_qualifier),
11189 narrowing_qualifiers(query),
11190 ]
11191 .into_iter()
11192 .flatten()
11193 .collect();
11194 // A narrowed search never answers a draft, which is no issue, so narrowing it to
11195 // open issues too leaves out nothing such a search could have answered.
11196 if !qualifiers.is_empty() {
11197 if open_only {
11198 qualifiers.push(OPEN_QUALIFIER.to_owned());
11199 }
11200 return self.search_tasks(query, page, &qualifiers.join(" ")).await;
11201 }
11202 }
11203
11204 // A read narrowed to one project asks that project for its own tasks, so nothing
11205 // about it costs what the rest of the board holds. A read carrying a text, metadata
11206 // or origin predicate asks GitHub the narrower question those predicates are, and a
11207 // read narrowed to comment activity alone asks the board's own issue search for the
11208 // issues updated since, which is every issue a comment could have been written or
11209 // edited on since. Every other task read is a question about the whole board and is
11210 // answered by reading it.
11211 let (held, membership) = match (&query.project, query.commented_since) {
11212 (ProjectFilter::Is(project), _) => (
11213 self.project_children(project).await?,
11214 // Answered by where these items came from; see `task_matches`.
11215 &ProjectFilter::Any,
11216 ),
11217 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
11218 match (self.narrowed(query).await?, since) {
11219 (Some(narrowed), _) => (narrowed, &query.project),
11220 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
11221 // The board's own items list every draft; only the search half, which
11222 // completes them with issues they lag behind on, is narrowed — a closed
11223 // issue that half leaves out is at no category the filter keeps.
11224 (None, None) if open_only => (
11225 self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
11226 &query.project,
11227 ),
11228 (None, None) => (self.board().await?.items, &query.project),
11229 }
11230 }
11231 };
11232 // Filtered before paged: a page of a filtered result is a page of the survivors,
11233 // never the survivors of a page.
11234 let mut tasks = Vec::new();
11235 for item in held
11236 .iter()
11237 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
11238 {
11239 let task = item.task()?;
11240 if task_matches(&task, query, membership)
11241 && self.commented_since(item, query.commented_since).await?
11242 {
11243 tasks.push(task);
11244 }
11245 }
11246 Ok(offset_page(
11247 tasks,
11248 numeric_cursor(page.cursor.as_ref())?,
11249 page.limit.min(MAX_PAGE_SIZE) as usize,
11250 ))
11251 }
11252 async fn query_projects(
11253 &self,
11254 query: &ProjectQuery,
11255 page: &PageRequest,
11256 ) -> Result<Page<Project>, SourceError> {
11257 validate_page(page)?;
11258 refuse_unsearchable_text(query.text.as_ref())?;
11259 // The projects a board holds are found by an issue search scoped to that board,
11260 // never by walking the board's own item connection: what tells a project from a
11261 // task is the `parent` each issue carries, which costs nothing to read. A query
11262 // carrying a text asks that search for the text too, so it reads the issues that
11263 // hold it rather than every issue of the board.
11264 let held = match self.text_searched(query.text.as_ref()).await? {
11265 Some(searched) => searched,
11266 None => self.board_issues().await?,
11267 };
11268 let projects = held
11269 .iter()
11270 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
11271 .map(Resolved::project)
11272 .filter(|project| project_matches(project, query))
11273 .collect();
11274 Ok(offset_page(
11275 projects,
11276 numeric_cursor(page.cursor.as_ref())?,
11277 page.limit.min(MAX_PAGE_SIZE) as usize,
11278 ))
11279 }
11280 async fn query_projects_by_origin(
11281 &self,
11282 origin: &str,
11283 page: &PageRequest,
11284 ) -> Result<Page<Project>, SourceError> {
11285 validate_page(page)?;
11286 let projects = self
11287 .copied_from(origin, BoardKind::Work(ItemKind::Project))
11288 .await?
11289 .iter()
11290 .map(Resolved::project)
11291 .collect();
11292 Ok(offset_page(
11293 projects,
11294 numeric_cursor(page.cursor.as_ref())?,
11295 page.limit.min(MAX_PAGE_SIZE) as usize,
11296 ))
11297 }
11298 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
11299 Ok(self
11300 .item_by_id(id)
11301 .await?
11302 .filter(|item| item.kind == BoardKind::Document)
11303 .map(|item| item.document()))
11304 }
11305 async fn query_documents_by_origin(
11306 &self,
11307 origin: &str,
11308 page: &PageRequest,
11309 ) -> Result<Page<Document>, SourceError> {
11310 validate_page(page)?;
11311 let documents = self
11312 .copied_from(origin, BoardKind::Document)
11313 .await?
11314 .iter()
11315 .map(Resolved::document)
11316 .collect();
11317 Ok(offset_page(
11318 documents,
11319 numeric_cursor(page.cursor.as_ref())?,
11320 page.limit.min(MAX_PAGE_SIZE) as usize,
11321 ))
11322 }
11323 async fn query_documents(
11324 &self,
11325 query: &DocumentQuery,
11326 page: &PageRequest,
11327 ) -> Result<Page<Document>, SourceError> {
11328 validate_page(page)?;
11329 // Narrowed to one project, this is the same sub-issue read a task list scoped to
11330 // that project makes — a document filed under a project is a sub-issue of it too,
11331 // and which of them come back is the kind this caller asked for. Unscoped, a query
11332 // carrying a text asks the board-scoped issue search for it, as a task query does,
11333 // and only one carrying none reads the board.
11334 let (held, membership) = match &query.project {
11335 ProjectFilter::Is(project) => (
11336 self.project_children(project).await?,
11337 // Answered by where these items came from; see `task_matches`.
11338 &ProjectFilter::Any,
11339 ),
11340 ProjectFilter::Any | ProjectFilter::Orphans => {
11341 refuse_unsearchable_text(query.text.as_ref())?;
11342 match self.text_searched(query.text.as_ref()).await? {
11343 Some(searched) => (searched, &query.project),
11344 None => (self.board().await?.items, &query.project),
11345 }
11346 }
11347 };
11348 // Filtered before paged, exactly as a task read is: a page of a filtered result is
11349 // a page of the survivors, never the survivors of a page.
11350 let documents = held
11351 .iter()
11352 .filter(|item| item.kind == BoardKind::Document)
11353 .map(Resolved::document)
11354 .filter(|document| document_matches(document, query, membership))
11355 .collect();
11356 Ok(offset_page(
11357 documents,
11358 numeric_cursor(page.cursor.as_ref())?,
11359 page.limit.min(MAX_PAGE_SIZE) as usize,
11360 ))
11361 }
11362 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
11363 validate_page(page)?;
11364 let offset = numeric_cursor(page.cursor.as_ref())?;
11365 let mut labels = self
11366 .board()
11367 .await?
11368 .items
11369 .into_iter()
11370 .flat_map(|item| item.labels)
11371 .fold(Vec::new(), |mut all, label| {
11372 if !all.iter().any(|x: &Label| x.id == label.id) {
11373 all.push(label);
11374 }
11375 all
11376 });
11377 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
11378 Ok(offset_page(
11379 labels,
11380 offset,
11381 page.limit.min(MAX_PAGE_SIZE) as usize,
11382 ))
11383 }
11384 async fn task_dependencies(
11385 &self,
11386 id: &NativeId,
11387 direction: Direction,
11388 page: &PageRequest,
11389 ) -> Result<Page<DependencyEdge>, SourceError> {
11390 self.dependencies(id, ItemKind::Task, direction, page).await
11391 }
11392 async fn project_dependencies(
11393 &self,
11394 id: &NativeId,
11395 direction: Direction,
11396 page: &PageRequest,
11397 ) -> Result<Page<DependencyEdge>, SourceError> {
11398 self.dependencies(id, ItemKind::Project, direction, page)
11399 .await
11400 }
11401
11402 fn writes(&self) -> WriteSupport {
11403 WriteSupport::Supported
11404 }
11405
11406 /// Create or update one task.
11407 ///
11408 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
11409 /// neither may name the task itself or name one task twice — and land in the body's
11410 /// metadata slot under their reserved keys, in place of any caller metadata of those
11411 /// names.
11412 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
11413 self.write_task_assets(write, None)
11414 .await
11415 .map(|written| written.id)
11416 }
11417
11418 async fn write_task_with_assets(
11419 &self,
11420 write: &ItemWrite<Task>,
11421 _answers: Option<&BTreeMap<String, Value>>,
11422 assets: &onetaskgraph_plugin_api::AssetWrite,
11423 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11424 self.write_task_assets(write, Some(assets)).await
11425 }
11426
11427 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
11428 self.write_item(
11429 &Incoming {
11430 written: Written::Work(ItemKind::Project, &write.item.status),
11431 title: &write.item.title,
11432 content: write.item.content.as_deref(),
11433 assets: None,
11434 labels: &write.item.labels,
11435 metadata: &write.item.metadata,
11436 repositories: &write.item.repositories,
11437 classification: write.item.classification,
11438 parent: None,
11439 delivers: &[],
11440 delivered_by: &[],
11441 priority: None,
11442 },
11443 write.target.as_ref(),
11444 &write.depends_on,
11445 )
11446 .await
11447 .map(|written| written.id)
11448 }
11449
11450 /// Create or update one document, which is one issue titled the way this board spells
11451 /// a document.
11452 ///
11453 /// Everything else is exactly a task write: caller metadata goes to the same canonical
11454 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
11455 /// or a field this board cannot carry is refused by name rather than dropped, a target
11456 /// naming an issue this board does not hold is refused rather than created, and an
11457 /// issue this call created is taken back when the rest of the write fails.
11458 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
11459 self.write_document_assets(write, None)
11460 .await
11461 .map(|written| written.id)
11462 }
11463
11464 async fn write_document_with_assets(
11465 &self,
11466 write: &ItemWrite<Document>,
11467 _answers: Option<&BTreeMap<String, Value>>,
11468 assets: &onetaskgraph_plugin_api::AssetWrite,
11469 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11470 self.write_document_assets(write, Some(assets)).await
11471 }
11472
11473 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
11474 /// which reads nothing; then the board's `Status` option. Over an existing item that is
11475 /// read off the item, as the write reads it, and the item is held among this command's
11476 /// resolved records so the write that follows reuses that read rather than repeating it;
11477 /// an item that does not carry the field takes the board's fields, which are held once
11478 /// read. A create is checked against the board's fields only when this command already
11479 /// holds them, because a create reads them together with its repository, in one request,
11480 /// and refuses a missing option before it writes anything.
11481 async fn check_status_write(
11482 &self,
11483 kind: ItemKind,
11484 category: StatusCategory,
11485 target: Option<&NativeId>,
11486 ) -> Result<(), SourceError> {
11487 let status = self.resolved_target(kind, category)?;
11488 if status.option().is_none() {
11489 return Ok(());
11490 }
11491 let fields = match target {
11492 Some(target) => {
11493 // A target this board does not hold is the write's own refusal to make.
11494 let Some(item) = self.bound_item(target).await? else {
11495 return Ok(());
11496 };
11497 self.resolved_cache()?.insert(target.clone(), item.clone());
11498 self.fields_for(Some(&item), true, false).await?.fields
11499 }
11500 None => {
11501 let held = self
11502 .board_cache()?
11503 .as_ref()
11504 .map(|board| board.fields.clone());
11505 match held.or_else(|| {
11506 self.fields_cache()
11507 .ok()
11508 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
11509 }) {
11510 Some(fields) => fields,
11511 None => return Ok(()),
11512 }
11513 }
11514 };
11515 self.column_for(&fields, kind, category, &status)
11516 .map(|_| ())
11517 }
11518
11519 /// Set one task's status alone.
11520 ///
11521 /// An open target reopens a closed issue with an `updateIssue` carrying only its
11522 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
11523 /// terminal target selects its mapped option, then closes with its fixed reason. No
11524 /// request carries a title, a body or a label. The status
11525 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
11526 /// what a re-read reports.
11527 async fn set_task_status(
11528 &self,
11529 id: &NativeId,
11530 category: StatusCategory,
11531 ) -> Result<Option<Status>, SourceError> {
11532 self.set_status(id, ItemKind::Task, category).await
11533 }
11534
11535 /// Set one project's status alone, by exactly the paired write a task's status takes, for
11536 /// the project kind: a terminal category selects the project kind's mapped option and then
11537 /// closes the issue with the category's fixed reason — an issue already closed with a
11538 /// reason that reads that category is left as it is — and an open category reopens a closed
11539 /// issue and then selects its option. A board draft is refused a terminal category as a
11540 /// task's is.
11541 async fn set_project_status(
11542 &self,
11543 id: &NativeId,
11544 category: StatusCategory,
11545 ) -> Result<Option<Status>, SourceError> {
11546 self.set_status(id, ItemKind::Project, category).await
11547 }
11548
11549 /// Every task and project whose card contradicts its issue's closure, read through the
11550 /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
11551 async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
11552 self.contradictions().await
11553 }
11554
11555 /// What this board holds for each issue reference, one request per fifty of them; see
11556 /// [`TaskSource::issue_holdings`].
11557 async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
11558 self.holdings(issues).await
11559 }
11560
11561 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
11562 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
11563 /// for `none`. Refused by an instance with no `priority_mapping`.
11564 async fn set_task_priority(
11565 &self,
11566 id: &NativeId,
11567 priority: Priority,
11568 ) -> Result<Option<Priority>, SourceError> {
11569 self.set_priority(id, priority).await
11570 }
11571
11572 /// Replace one task's content with a single body update that keeps the metadata slot
11573 /// byte for byte.
11574 async fn set_task_content(
11575 &self,
11576 id: &NativeId,
11577 content: &str,
11578 ) -> Result<Option<()>, SourceError> {
11579 self.replace_content(id, content).await
11580 }
11581
11582 /// Replace one task issue's content and its provenance slot entry with a single body
11583 /// update. The answers are not kept: see `replace_rendering`.
11584 async fn set_task_rendering(
11585 &self,
11586 id: &NativeId,
11587 content: &str,
11588 provenance: &Value,
11589 _answers: &BTreeMap<String, Value>,
11590 ) -> Result<Option<()>, SourceError> {
11591 self.replace_rendering(
11592 id,
11593 BoardKind::Work(ItemKind::Task),
11594 content,
11595 provenance,
11596 None,
11597 )
11598 .await
11599 .map(|written| written.map(|_| ()))
11600 }
11601
11602 /// Replace one design-document issue's content and its provenance slot entry, on exactly
11603 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
11604 async fn set_document_rendering(
11605 &self,
11606 id: &NativeId,
11607 content: &str,
11608 provenance: &Value,
11609 _answers: &BTreeMap<String, Value>,
11610 ) -> Result<Option<()>, SourceError> {
11611 self.replace_rendering(id, BoardKind::Document, content, provenance, None)
11612 .await
11613 .map(|written| written.map(|_| ()))
11614 }
11615
11616 /// Replace one project issue's content and its provenance slot entry, on exactly the
11617 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
11618 async fn set_project_rendering(
11619 &self,
11620 id: &NativeId,
11621 content: &str,
11622 provenance: &Value,
11623 _answers: &BTreeMap<String, Value>,
11624 ) -> Result<Option<()>, SourceError> {
11625 self.replace_rendering(
11626 id,
11627 BoardKind::Work(ItemKind::Project),
11628 content,
11629 provenance,
11630 None,
11631 )
11632 .await
11633 .map(|written| written.map(|_| ()))
11634 }
11635
11636 /// Apply a targeted update with one read of the item and a write only for what differs:
11637 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
11638 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
11639 async fn update_task(
11640 &self,
11641 id: &NativeId,
11642 update: &TaskUpdate,
11643 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
11644 self.targeted_update(id, update).await
11645 }
11646
11647 /// Replace one task's `delivered_by` with a single body update that changes the
11648 /// metadata slot and nothing outside it.
11649 async fn set_delivered_by(
11650 &self,
11651 id: &NativeId,
11652 delivered_by: &[TaskRef],
11653 ) -> Result<Option<()>, SourceError> {
11654 self.replace_delivered_by(id, delivered_by).await
11655 }
11656
11657 /// Set one key of one task issue's metadata with a single body update that changes the
11658 /// metadata slot and nothing outside it — no title, label, state or board field request —
11659 /// and sends nothing when the task already holds that value under the key.
11660 async fn set_task_metadata(
11661 &self,
11662 id: &NativeId,
11663 key: &MetadataKey,
11664 value: &Value,
11665 ) -> Result<Option<Task>, SourceError> {
11666 Ok(self
11667 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
11668 .await?
11669 .map(|item| item.task())
11670 .transpose()?)
11671 }
11672
11673 /// Set one key of one project issue's metadata, on exactly the terms of
11674 /// [`set_task_metadata`](TaskSource::set_task_metadata).
11675 async fn set_project_metadata(
11676 &self,
11677 id: &NativeId,
11678 key: &MetadataKey,
11679 value: &Value,
11680 ) -> Result<Option<Project>, SourceError> {
11681 Ok(self
11682 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
11683 .await?
11684 .map(|item| item.project()))
11685 }
11686
11687 /// Set one key of one design-document issue's metadata, on exactly the terms of
11688 /// [`set_task_metadata`](TaskSource::set_task_metadata).
11689 async fn set_document_metadata(
11690 &self,
11691 id: &NativeId,
11692 key: &MetadataKey,
11693 value: &Value,
11694 ) -> Result<Option<Document>, SourceError> {
11695 Ok(self
11696 .set_slot_key(id, BoardKind::Document, key, value)
11697 .await?
11698 .map(|item| item.document()))
11699 }
11700
11701 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
11702 self.delete_item(id).await
11703 }
11704
11705 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
11706 self.delete_item(id).await
11707 }
11708
11709 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
11710 self.delete_item(id).await
11711 }
11712
11713 /// One page of the task issue's own comments, walked by GitHub's own cursor.
11714 ///
11715 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
11716 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
11717 ///
11718 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
11719 /// board is the read of its comments. A draft this process already resolved is refused
11720 /// without one.
11721 async fn task_comments(
11722 &self,
11723 task: &NativeId,
11724 page: &PageRequest,
11725 ) -> Result<Option<Page<Comment>>, SourceError> {
11726 validate_page(page)?;
11727 let cached = self.resolved_cache()?.get(task).cloned();
11728 if let Some(item) = cached {
11729 if item.kind != BoardKind::Work(ItemKind::Task) {
11730 return Ok(None);
11731 }
11732 if item.content_kind == ContentKind::DraftIssue {
11733 return Err(self.draft_has_no_comments(task));
11734 }
11735 }
11736 match self.issue_detail(task, page).await? {
11737 Some(TaskDetailRead {
11738 comments: Some(comments),
11739 ..
11740 }) => comments,
11741 _ => Ok(None),
11742 }
11743 }
11744
11745 /// Every id's task, with the first page of its comments when `comments` names it:
11746 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
11747 /// comments in one [`graphql::ISSUE_DETAIL`] request.
11748 async fn get_task_details(
11749 &self,
11750 ids: &[NativeId],
11751 comments: Option<&PageRequest>,
11752 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11753 if let Some(page) = comments
11754 && let Err(error) = validate_page(page)
11755 {
11756 return ids.iter().map(|_| Err(error.clone())).collect();
11757 }
11758 match (ids, comments) {
11759 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
11760 ([id], None) => vec![self.task_read(id).await],
11761 _ => self.issue_details(ids, comments).await,
11762 }
11763 }
11764
11765 /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
11766 /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
11767 /// request, so the dependency read a copy makes of each next is answered from this one.
11768 async fn get_task_details_for_copy(
11769 &self,
11770 ids: &[NativeId],
11771 comments: &PageRequest,
11772 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
11773 // A page past the first is a page of one task's comments, which the detail read of it
11774 // alone answers.
11775 if comments.cursor.is_some() {
11776 return self.get_task_details(ids, Some(comments)).await;
11777 }
11778 if let Err(error) = validate_page(comments) {
11779 return ids.iter().map(|_| Err(error.clone())).collect();
11780 }
11781 let mut read = Vec::with_capacity(ids.len());
11782 for batch in ids.chunks(COPY_DETAIL_BATCH) {
11783 match self
11784 .graphql(
11785 graphql::ISSUE_COPY_DETAILS,
11786 copy_detail_batch(batch, comments),
11787 )
11788 .await
11789 {
11790 Ok(data) => {
11791 for (slot, id) in batch.iter().enumerate() {
11792 let read_one = match data.get(format!("i{slot}")) {
11793 Some(node) => self.detail_of(id, node, true, None).await,
11794 None => Err(SourceError::Malformed {
11795 message: format!(
11796 "GitHub answered a batch read with no item for {}",
11797 id.0
11798 ),
11799 }),
11800 };
11801 read.push(read_one);
11802 }
11803 }
11804 Err(error) if unresolvable_node(&error) => {
11805 for id in batch {
11806 read.push(self.issue_detail(id, comments).await);
11807 }
11808 }
11809 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
11810 }
11811 }
11812 read
11813 }
11814
11815 /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
11816 /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
11817 /// and held for the next read of each by id; see `read_ahead`.
11818 ///
11819 /// What this run wrote is never read: its own record answers, as it does for a read by id.
11820 /// Nor is an item a listing of its project carried whole earlier in this command — see
11821 /// `carried` — which is held in place of reading it.
11822 /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
11823 /// so each of its ids is read on its own and answered as that read answers; a draft, and an
11824 /// answer naming another issue than the one asked for, are left to their own reads too.
11825 async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
11826 let mut unread: Vec<NativeId> = Vec::new();
11827 // Read whole by a listing of its project in this command, which a mutation naming it
11828 // would have dropped: held as a batch would have held it, once every batch is answered.
11829 let mut listed: Vec<NativeId> = Vec::new();
11830 {
11831 let created = self.created()?;
11832 let held = self.read_ahead()?;
11833 let carried = self.carried()?;
11834 for id in ids {
11835 if unread.contains(id)
11836 || listed.contains(id)
11837 || held.contains_key(id)
11838 || created.iter().any(|own| own.id == *id)
11839 {
11840 continue;
11841 }
11842 if carried.contains_key(id) {
11843 listed.push(id.clone());
11844 } else {
11845 unread.push(id.clone());
11846 }
11847 }
11848 }
11849 // Nothing of an item's comments is wanted here: the smallest page keeps the read to
11850 // what the item itself costs.
11851 let comments = PageRequest {
11852 cursor: None,
11853 limit: 1,
11854 };
11855 // Held only once every batch has been answered, so a read-ahead that fails part way
11856 // holds nothing — what a listing carried included, which stays carried — and every
11857 // item is read on its own, as the method promises.
11858 let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
11859 for batch in unread.chunks(COPY_DETAIL_BATCH) {
11860 let data = match self
11861 .graphql(
11862 graphql::ISSUE_COPY_DETAILS,
11863 copy_detail_batch(batch, &comments),
11864 )
11865 .await
11866 {
11867 Ok(data) => data,
11868 Err(error) if unresolvable_node(&error) => continue,
11869 Err(error) => return Err(error),
11870 };
11871 for (slot, id) in batch.iter().enumerate() {
11872 let Some(node) = data.get(format!("i{slot}")) else {
11873 continue;
11874 };
11875 let held = if node.is_null() {
11876 None
11877 } else {
11878 match optional_str(node, "__typename")? {
11879 Some("DraftIssue") => continue,
11880 Some("Issue") if required_str(node, "id")? != id.0 => continue,
11881 _ => {}
11882 }
11883 // Remembered below with the rest, so a later batch refused leaves no
11884 // dependency read answered from this one either.
11885 self.resolve_issue_as(node, false).await?
11886 };
11887 read.push((id.clone(), held));
11888 }
11889 }
11890 let mut resolved = self.resolved_cache()?;
11891 let mut ahead = self.read_ahead()?;
11892 let mut carried = self.carried()?;
11893 for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
11894 resolved.insert(item.id.clone(), item.clone());
11895 }
11896 // Taken only if still carried: a mutation naming it while the batches ran dropped it,
11897 // and its next read is then its own.
11898 for id in &listed {
11899 let Some(item) = carried.remove(id) else {
11900 continue;
11901 };
11902 // The whole of what it is blocked by came with it, so the dependency read a copy
11903 // makes next is answered from this one, as a copy's detail read is.
11904 if item.blocked_by.is_some() {
11905 resolved.insert(item.id.clone(), item.clone());
11906 }
11907 read.push((id.clone(), Some(item)));
11908 }
11909 for (id, held) in read {
11910 ahead.insert(id, held);
11911 }
11912 Ok(())
11913 }
11914
11915 /// Add one comment to the task's issue, as the account the token belongs to.
11916 ///
11917 /// The author is refused before anything is sent — not even the task is read — because
11918 /// no answer GitHub could give would make posting under another name than the one asked
11919 /// for the right outcome.
11920 async fn add_comment(
11921 &self,
11922 task: &NativeId,
11923 comment: &NewComment,
11924 ) -> Result<Option<Comment>, SourceError> {
11925 if let Some(author) = &comment.author {
11926 return Err(SourceError::Refused {
11927 message: format!(
11928 "source {} cannot post a comment as {author:?}: GitHub records the account \
11929 the token signs in as the author of every comment; next: leave --author \
11930 out, and the comment is posted as that account",
11931 self.name
11932 ),
11933 });
11934 }
11935 let Some(issue) = self.commented_issue(task).await? else {
11936 return Ok(None);
11937 };
11938 let data = self
11939 .graphql(
11940 graphql::ADD_COMMENT,
11941 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
11942 )
11943 .await?;
11944 let subject = data
11945 .pointer("/addComment/subject")
11946 .filter(|value| !value.is_null())
11947 .ok_or_else(|| SourceError::Malformed {
11948 message: "GitHub comment addition returned no subject".into(),
11949 })?;
11950 if required_str(subject, "id")? != issue.0 {
11951 return Err(SourceError::Malformed {
11952 message: "GitHub comment addition answered about another issue".into(),
11953 });
11954 }
11955 let added = data
11956 .pointer("/addComment/commentEdge/node")
11957 .filter(|value| !value.is_null())
11958 .ok_or_else(|| SourceError::Malformed {
11959 message: "GitHub comment addition returned no comment".into(),
11960 })?;
11961 let added = comment_from(added)?;
11962 self.remember_commented(&issue)?;
11963 Ok(Some(added))
11964 }
11965
11966 async fn edit_comment(
11967 &self,
11968 task: &NativeId,
11969 comment: &NativeId,
11970 body: &CommentBody,
11971 ) -> Result<Option<Comment>, SourceError> {
11972 let Some(issue) = self.commented_issue(task).await? else {
11973 return Ok(None);
11974 };
11975 let Some(held) = self.comment_on(&issue, comment).await? else {
11976 return Ok(None);
11977 };
11978 // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
11979 let body = CommentOrigin::kept(&held, body)?;
11980 let data = self
11981 .graphql(
11982 graphql::UPDATE_COMMENT,
11983 json!({"input":{"id":comment.0,"body":body.as_str()}}),
11984 )
11985 .await?;
11986 let edited = data
11987 .pointer("/updateIssueComment/issueComment")
11988 .filter(|value| !value.is_null())
11989 .ok_or_else(|| SourceError::Malformed {
11990 message: "GitHub comment update returned no comment".into(),
11991 })?;
11992 let edited = comment_from(edited)?;
11993 if edited.id != *comment {
11994 return Err(SourceError::Malformed {
11995 message: "GitHub comment update returned the wrong comment".into(),
11996 });
11997 }
11998 self.remember_commented(&issue)?;
11999 Ok(Some(edited))
12000 }
12001
12002 async fn delete_comment(
12003 &self,
12004 task: &NativeId,
12005 comment: &NativeId,
12006 ) -> Result<Option<NativeId>, SourceError> {
12007 let Some(issue) = self.commented_issue(task).await? else {
12008 return Ok(None);
12009 };
12010 if !self.comment_is_on(&issue, comment).await? {
12011 return Ok(None);
12012 }
12013 let data = self
12014 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
12015 .await?;
12016 // The payload says nothing about the comment it removed, so what is checked is that
12017 // GitHub answered the mutation at all rather than leaving it unanswered.
12018 data.get("deleteIssueComment")
12019 .filter(|value| !value.is_null())
12020 .ok_or_else(|| SourceError::Malformed {
12021 message: "GitHub comment deletion returned no payload".into(),
12022 })?;
12023 Ok(Some(comment.clone()))
12024 }
12025
12026 /// Every request this source has recorded, and what each of GitHub's two budgets was
12027 /// attributed — read off the same accounting the session report is rendered from, so
12028 /// the two cannot count one request two ways.
12029 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
12030 Ok(Some(self.ledger.snapshot().metering()))
12031 }
12032
12033 /// Drop every item, search answer and board read this source holds, so the next command
12034 /// reads the board as a person has since left it.
12035 ///
12036 /// Every one of those is held on the assumption that nothing but this source writes the
12037 /// board while a command runs, which stops being true the moment the command is over: a
12038 /// body a person edited would be overwritten from the record held here, and a card they
12039 /// moved would be read as still where this source left it. The board's own field
12040 /// definitions go too, because a person can add or delete a `Status` option and a write
12041 /// resolved against the held list would not re-read on a miss. What stays is what stays
12042 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
12043 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
12044 /// accounting [`metering`](TaskSource::metering) answers from.
12045 ///
12046 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
12047 /// refused, because clearing it is what puts it right.
12048 async fn end_command(&self) -> Result<(), SourceError> {
12049 fn clear<T: Default>(held: &Mutex<T>) {
12050 *held
12051 .lock()
12052 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
12053 held.clear_poison();
12054 }
12055 clear(&self.created);
12056 clear(&self.updated);
12057 clear(&self.commented);
12058 clear(&self.board_cache);
12059 clear(&self.search_cache);
12060 clear(&self.narrowed_cache);
12061 clear(&self.search_next);
12062 clear(&self.resolved_cache);
12063 clear(&self.read_ahead);
12064 clear(&self.carried);
12065 clear(&self.held_cache);
12066 clear(&self.children_cache);
12067 clear(&self.fields_cache);
12068 self.expected.end_command();
12069 Ok(())
12070 }
12071}
12072
12073/// Each project's sub-issues as one command read them, keyed by the selector they were asked
12074/// for under, beside the project that selector named; see `children_cache`.
12075type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
12076
12077/// One issue comment as the contract carries it.
12078///
12079/// `author` is absent both when GitHub answers `null` for an account that no longer exists
12080/// and when it answers an actor with no login, because either way the source did not say who
12081/// wrote it — which is what an absent author means, rather than an author called nothing.
12082///
12083/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
12084/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
12085/// and is read back with the marker taken out and reported as its origin.
12086fn comment_from(value: &Value) -> Result<Comment, SourceError> {
12087 Ok(Comment {
12088 origin: None,
12089 id: NativeId(required_str(value, "id")?.to_owned()),
12090 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
12091 .map(str::to_owned),
12092 created_at: optional_time(value, "createdAt")?,
12093 updated_at: optional_time(value, "updatedAt")?,
12094 body: required_str(value, "body")?.to_owned(),
12095 url: optional_str(value, "url")?.map(str::to_owned),
12096 }
12097 .unmarked())
12098}
12099
12100/// The page of comments one issue node carries, resumed from `after`.
12101fn comment_page(
12102 node: &Value,
12103 issue: &str,
12104 after: Option<&str>,
12105) -> Result<Page<Comment>, SourceError> {
12106 let connection = node
12107 .get("comments")
12108 .filter(|value| !value.is_null())
12109 .ok_or_else(|| SourceError::Malformed {
12110 message: format!("GitHub issue {issue} answered with no comments connection"),
12111 })?;
12112 let items = optional_nodes(Some(connection), "issue comments")?
12113 .into_iter()
12114 .flatten()
12115 .map(comment_from)
12116 .collect::<Result<Vec<_>, _>>()?;
12117 let next = next_cursor(connection)?;
12118 if let Some(next) = &next {
12119 validate_cursor_progress(after, &next.0)?;
12120 }
12121 Ok(Page { items, next })
12122}
12123
12124/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
12125/// end — `None` when it carried none, or a page with more past it.
12126fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
12127 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
12128 return Ok(None);
12129 };
12130 if next_cursor(connection)?.is_some() {
12131 return Ok(None);
12132 }
12133 Ok(Some(
12134 optional_nodes(Some(connection), "blocked-by issues")?
12135 .into_iter()
12136 .flatten()
12137 .cloned()
12138 .collect(),
12139 ))
12140}
12141
12142/// Where the recorded tail of a dependency walk resumes; see
12143/// [`GitHubProjectsSource::recorded_edges`].
12144const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
12145
12146/// The board text field this source keeps a copy's origin in.
12147///
12148/// Named after the key it holds, and held to that name by the guard below rather than by
12149/// a reader noticing.
12150const ORIGIN_FIELD: &str = "onetaskgraph.origin";
12151
12152/// The metadata key that field holds.
12153///
12154/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
12155/// constructs or interprets the qualified id it carries. This source names it only to
12156/// route it — a short, typed value belongs in a typed field rather than in the body slot
12157/// a caller's own prose shares.
12158///
12159/// Restated rather than imported, because no plugin crate may depend on the engine. What
12160/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
12161/// target in `check`: it reads the engine's own literal and fails naming the file and the
12162/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
12163/// that creates a second item every run instead of finding the one it wrote — and that is
12164/// too late to learn it.
12165const ORIGIN_KEY: &str = "onetaskgraph.origin";
12166
12167/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
12168///
12169/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
12170/// is derived from the far end, never written down on the near item — so only a forward
12171/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
12172/// it did not come from, and it is told so rather than answered with an empty page that
12173/// reads as a walk which ended.
12174fn recorded_offset(
12175 cursor: Option<&str>,
12176 direction: Direction,
12177) -> Result<Option<usize>, SourceError> {
12178 cursor
12179 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
12180 .map(|offset| {
12181 if direction != Direction::DependsOn {
12182 return Err(SourceError::Config {
12183 message: format!(
12184 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
12185 reverse dependency read never issues; resume it in the direction \
12186 that reported it"
12187 ),
12188 });
12189 }
12190 offset.parse().map_err(|_| SourceError::Config {
12191 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
12192 })
12193 })
12194 .transpose()
12195}
12196
12197fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
12198 let mut page = offset_page(edges, offset, limit.max(1));
12199 page.next = page
12200 .next
12201 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
12202 page
12203}
12204
12205/// The kind of one issue reached through a dependency connection.
12206///
12207/// The same questions the board scan asks, over the fields the dependency document
12208/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
12209/// then anything with sub-issues or the marker is a project.
12210///
12211/// # Errors
12212///
12213/// A far end this board holds as a document is refused rather than reported. The two
12214/// answers that are not refusals would both be wrong: reporting it as a task names an id
12215/// no task read of this source can find, and reporting it as a project names one no
12216/// project read can. There is no third value to return — `ItemKind` has no document
12217/// variant, because nothing may point at a document — so the relationship itself is what
12218/// the person is told about.
12219fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
12220 let id = required_str(value, "id")?;
12221 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
12222 return Err(SourceError::Refused {
12223 message: format!(
12224 "GitHub issue {id} is a document of this board — its title begins \
12225 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
12226 on by one; next: remove that issue's blocking relationship on this board"
12227 ),
12228 });
12229 }
12230 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
12231 if parent.is_some() {
12232 return Ok(ItemKind::Task);
12233 }
12234 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
12235 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
12236 message: format!("GitHub issue {id}: {message}"),
12237 })?;
12238 let sub_issues = sub_issue_total(value)?;
12239 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
12240 ItemKind::Project
12241 } else {
12242 ItemKind::Task
12243 })
12244}
12245
12246/// The `IssueStateUpdateInput` one status target asks for.
12247///
12248/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
12249/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
12250/// a currently-closed issue: without that the item would read back `Unknown` and a copy
12251/// would report a change forever. A document has no status at all, and asks for neither.
12252fn state_input(target: Option<&StatusTarget>) -> Value {
12253 match target {
12254 Some(StatusTarget::Terminal(_, reason)) => {
12255 json!({"value":"CLOSED","stateReason":reason.reason()})
12256 }
12257 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
12258 // A document has no status, so a write of one says nothing about the issue's open
12259 // or closed state rather than forcing it open: `stateInput` is what carries that
12260 // instruction, and an explicit null asks for no change to it.
12261 None => Value::Null,
12262 }
12263}
12264
12265/// The metadata one write stores in the item's body slot.
12266///
12267/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
12268/// rather than carried: the kind marker so an empty project stays readable, the
12269/// repository list only when it is not exactly the issue's own repository, and the far
12270/// ends no relationship here can name.
12271///
12272/// The copy origin is the one typed field that is also mirrored here, and only as a
12273/// mirror: it lands in the board's origin field as well, which stays the one every reader
12274/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
12275/// and catches up with a write in seconds rather than minutes — can find the item by it.
12276/// A reader of the release before this one drops the slot's copy and reads the field, so an
12277/// item written here still reads with exactly one origin there.
12278fn slot_metadata(
12279 incoming: &Incoming<'_>,
12280 own_repository: Option<&Repository>,
12281 fallback: &[DependencyEdge],
12282) -> BTreeMap<String, Value> {
12283 let mut metadata = incoming.metadata.clone();
12284 match metadata.remove(ORIGIN_KEY) {
12285 Some(Value::String(origin)) if !origin.is_empty() => {
12286 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
12287 }
12288 _ => {}
12289 }
12290 match incoming.written.kind() {
12291 BoardKind::Work(kind) => metadata.insert(
12292 ItemKind::METADATA_KEY.to_owned(),
12293 Value::String(kind.marker().to_owned()),
12294 ),
12295 // A document is told by its title, so it carries no kind marker: that key names
12296 // what a dependency endpoint points at, and nothing may point at a document.
12297 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
12298 };
12299 incoming.classification.record(&mut metadata);
12300 let derivable = own_repository
12301 .map(|own| incoming.repositories == [own.clone()])
12302 .unwrap_or(incoming.repositories.is_empty());
12303 if derivable {
12304 metadata.remove(Repository::METADATA_KEY);
12305 } else {
12306 metadata.insert(
12307 Repository::METADATA_KEY.to_owned(),
12308 Value::Array(
12309 incoming
12310 .repositories
12311 .iter()
12312 .map(|repository| Value::String(repository.as_str().to_owned()))
12313 .collect(),
12314 ),
12315 );
12316 }
12317 // The typed lists are what land, whatever the caller's own metadata held under their
12318 // keys: a key of either name travelling beside the field would otherwise be a second
12319 // answer to the same question, and the field is the one the contract names.
12320 for (key, entries) in [
12321 (TaskRef::DELIVERS_KEY, incoming.delivers),
12322 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
12323 ] {
12324 set_task_list(&mut metadata, key, entries);
12325 }
12326 record_edges(&mut metadata, fallback);
12327 metadata
12328}
12329
12330/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
12331/// one slot's metadata, or no such key when there are none.
12332fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
12333 if fallback.is_empty() {
12334 metadata.remove(DependencyEdge::RECORDED_KEY);
12335 } else {
12336 metadata.insert(
12337 DependencyEdge::RECORDED_KEY.to_owned(),
12338 Value::Array(
12339 fallback
12340 .iter()
12341 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
12342 .collect(),
12343 ),
12344 );
12345 }
12346}
12347
12348/// Every label one item carries, from its content's own connection and nowhere else.
12349///
12350/// There is no second place to read one from: no document this source sends selects the
12351/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
12352/// cannot carry one at all. The module documentation records the three schema facts that
12353/// settle it.
12354fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
12355 optional_nodes(content.get("labels"), "content labels")?
12356 .into_iter()
12357 .flatten()
12358 .map(|v| {
12359 Ok(Label {
12360 id: NativeId(required_str(v, "id")?.to_owned()),
12361 name: required_str(v, "name")?.to_owned(),
12362 color: optional_str(v, "color")?.map(str::to_owned),
12363 })
12364 })
12365 .collect()
12366}
12367
12368/// Each board field value one item holds, as the change token's facets name it: the field's
12369/// name, with the single-select option's name or the text. A value of a type no read here
12370/// selects, and one naming no field, says nothing a person can rename and is left out.
12371fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
12372 field_values
12373 .iter()
12374 .filter_map(|value| {
12375 let field = value.pointer("/field/name")?.as_str()?;
12376 let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
12377 Some((field.to_owned(), held.to_owned()))
12378 })
12379 .collect()
12380}
12381
12382/// The digest of the facets of the issue `content`, whose board item's values are
12383/// `field_values` — or `None` when the response did not carry, whole, every member they are read
12384/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
12385/// label, and every field value.
12386///
12387/// What a write's own response spells its token from, by exactly the rules a read takes them by.
12388fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
12389 let ended = |connection: &Value| {
12390 connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
12391 };
12392 let labels = content.get("labels").filter(|labels| ended(labels))?;
12393 if !ended(field_values) {
12394 return None;
12395 }
12396 let parent = content.get("parent")?;
12397 let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
12398 let kind = BoardKind::of(
12399 content.get("title")?.as_str()?,
12400 !parent.is_null(),
12401 sub_issue_total(content).ok()?,
12402 ItemKind::from_metadata(&slot).ok()?,
12403 );
12404 let labels = self::labels(&json!({"labels": labels})).ok()?;
12405 let nodes = field_values.get("nodes")?.as_array()?;
12406 Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
12407}
12408
12409/// The definition of each board field one item's values are values of, in the shape a read
12410/// of the board's own `fields` gives one.
12411///
12412/// A value names its field through a fragment on that field's own type, so the type is
12413/// known from which kind of value it is: a single-select value's field is a
12414/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
12415/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
12416fn field_definitions(field_values: &[Value]) -> Vec<Value> {
12417 field_values
12418 .iter()
12419 .filter_map(|value| {
12420 let field = value.get("field")?.as_object()?;
12421 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
12422 let typename = if value.get("text").is_some() {
12423 "ProjectV2Field"
12424 } else if value.get("name").is_some() {
12425 "ProjectV2SingleSelectField"
12426 } else {
12427 return None;
12428 };
12429 let mut defined = field.clone();
12430 defined.insert("__typename".to_owned(), json!(typename));
12431 // Only a text field holds a text value, so the value says the field's type too.
12432 if typename == "ProjectV2Field" {
12433 defined.insert("dataType".to_owned(), json!("TEXT"));
12434 }
12435 Some(Value::Object(defined))
12436 })
12437 .collect()
12438}
12439
12440fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
12441 let Some(node) = field_values
12442 .iter()
12443 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
12444 else {
12445 return Ok(None);
12446 };
12447 Ok(optional_str(node, "text")?.map(str::to_owned))
12448}
12449
12450fn valid_github_owner(owner: &str) -> bool {
12451 !owner.is_empty()
12452 && owner.len() <= 39
12453 && !owner.starts_with('-')
12454 && !owner.ends_with('-')
12455 && !owner.contains("--")
12456 && owner
12457 .bytes()
12458 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
12459}
12460
12461/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
12462/// neither of the two names a path segment already means.
12463fn valid_github_repository_name(name: &str) -> bool {
12464 !name.is_empty()
12465 && name.len() <= 100
12466 && name != "."
12467 && name != ".."
12468 && name
12469 .bytes()
12470 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
12471}
12472
12473fn valid_environment_name(name: &str) -> bool {
12474 let mut bytes = name.bytes();
12475 bytes
12476 .next()
12477 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
12478 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
12479}
12480
12481/// How many sub-issues one issue has.
12482///
12483/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
12484/// absent or non-integer one is a response this source cannot read — and reading it as
12485/// zero would classify a project as a task, which is exactly the mistake the marker
12486/// exists to keep from happening quietly.
12487fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
12488 let summary = issue
12489 .get("subIssuesSummary")
12490 .ok_or_else(|| SourceError::Malformed {
12491 message: "GitHub issue is missing subIssuesSummary".into(),
12492 })?;
12493 summary
12494 .get("total")
12495 .and_then(Value::as_u64)
12496 .ok_or_else(|| SourceError::Malformed {
12497 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
12498 })
12499}
12500
12501/// One issue's own `number`.
12502///
12503/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
12504/// an issue in this module asks for it. So a read of one that comes back without it, or
12505/// with something that is not an unsigned integer, is a response this source cannot read —
12506/// absence here is **not** "this issue has no number". A draft is the content that has
12507/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
12508/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
12509fn issue_number(issue: &Value) -> Result<u64, SourceError> {
12510 issue
12511 .get("number")
12512 .and_then(Value::as_u64)
12513 .ok_or_else(|| SourceError::Malformed {
12514 message: "GitHub issue number is missing or is not an unsigned integer".into(),
12515 })
12516}
12517
12518/// The `number` a creating mutation answered with, and `None` when it answered without one;
12519/// why a missing one is tolerated is at the call in `create_and_file_issue`.
12520fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
12521 match created.get("number") {
12522 None | Some(Value::Null) => Ok(None),
12523 Some(value) => value
12524 .as_u64()
12525 .map(Some)
12526 .ok_or_else(|| SourceError::Malformed {
12527 message: "GitHub created issue number is not an unsigned integer".into(),
12528 }),
12529 }
12530}
12531
12532fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
12533 value
12534 .get(field)
12535 .and_then(Value::as_str)
12536 .ok_or_else(|| SourceError::Malformed {
12537 message: format!("GitHub response is missing string field {field}"),
12538 })
12539}
12540
12541fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
12542 let found = required_str(value, field)?;
12543 if found.trim().is_empty() {
12544 return Err(SourceError::Malformed {
12545 message: format!("GitHub response has blank string field {field}"),
12546 });
12547 }
12548 Ok(found)
12549}
12550
12551/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
12552/// needs one — Linear spells them too, in its own description field.
12553///
12554/// Restated rather than shared, because a plugin crate depends on the contract crate and
12555/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
12556/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
12557/// source round-trips its own writes perfectly well under its own spelling.
12558const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
12559const METADATA_CLOSE: &str = "\n-->";
12560
12561/// What the composer puts between a non-empty visible body and the slot, and the one thing
12562/// the parser takes off the visible body when it takes the slot off — exactly once, so every
12563/// other trailing byte of the body comes back as it was written.
12564// 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.
12565const METADATA_SEPARATOR: &str = "\n\n";
12566
12567/// The visible body and the metadata slot at the end of it.
12568///
12569/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
12570/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
12571/// own content and is left alone. The visible body is everything before the slot less the
12572/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
12573fn metadata_body(
12574 body: Option<String>,
12575) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
12576 let Some(body) = body else {
12577 return Ok((None, BTreeMap::new()));
12578 };
12579 let Some(slot) = slot_span(&body)? else {
12580 return Ok((Some(body), BTreeMap::new()));
12581 };
12582 let metadata =
12583 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
12584 SourceError::Malformed {
12585 message: format!(
12586 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
12587 ),
12588 }
12589 })?;
12590 let before = &body[..slot.start];
12591 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
12592 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
12593}
12594
12595/// Where the metadata slot sits in one body, as byte offsets into it.
12596struct SlotSpan {
12597 /// Where [`METADATA_OPEN`] begins.
12598 start: usize,
12599 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
12600 encoded_start: usize,
12601 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
12602 encoded_end: usize,
12603 /// Just past [`METADATA_CLOSE`].
12604 end: usize,
12605}
12606
12607/// The slot at the very end of `body`, or `None` when it has none.
12608///
12609/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
12610/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
12611/// slot.
12612fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
12613 let Some(start) = body.rfind(METADATA_OPEN) else {
12614 return Ok(None);
12615 };
12616 let encoded_start = start + METADATA_OPEN.len();
12617 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
12618 return Err(SourceError::Malformed {
12619 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
12620 });
12621 };
12622 let encoded_end = encoded_start + relative_end;
12623 let end = encoded_end + METADATA_CLOSE.len();
12624 if !body[end..].trim().is_empty() {
12625 return Ok(None);
12626 }
12627 Ok(Some(SlotSpan {
12628 start,
12629 encoded_start,
12630 encoded_end,
12631 end,
12632 }))
12633}
12634
12635/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
12636/// slot as it was.
12637///
12638/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
12639/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
12640/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
12641/// or alone in an empty body — and a body with no slot that is given no metadata is
12642/// returned as it is.
12643fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
12644 let encoded = if metadata.is_empty() {
12645 None
12646 } else {
12647 Some(
12648 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
12649 message: error.to_string(),
12650 })?,
12651 )
12652 };
12653 Ok(match (slot_span(body)?, encoded) {
12654 (Some(slot), Some(encoded)) => format!(
12655 "{}{encoded}{}",
12656 &body[..slot.encoded_start],
12657 &body[slot.encoded_end..]
12658 ),
12659 (Some(slot), None) => {
12660 let before = &body[..slot.start];
12661 format!(
12662 "{}{}",
12663 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
12664 &body[slot.end..]
12665 )
12666 }
12667 (None, None) => body.to_owned(),
12668 (None, Some(encoded)) if body.is_empty() => {
12669 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12670 }
12671 (None, Some(encoded)) => {
12672 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12673 }
12674 })
12675}
12676
12677/// `body` with everything before its metadata slot replaced by `content`, and the slot
12678/// itself kept byte for byte.
12679///
12680/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
12681/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
12682/// `content` is empty — so a read of the result reports `content` as the visible body and
12683/// the slot's metadata exactly as it was.
12684fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
12685 let Some(slot) = slot_span(body)? else {
12686 return Ok(content.to_owned());
12687 };
12688 let kept = &body[slot.start..];
12689 Ok(if content.is_empty() {
12690 kept.to_owned()
12691 } else {
12692 format!("{content}{METADATA_SEPARATOR}{kept}")
12693 })
12694}
12695
12696/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
12697fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
12698 if entries.is_empty() {
12699 metadata.remove(key);
12700 } else {
12701 metadata.insert(
12702 key.to_owned(),
12703 Value::Array(
12704 entries
12705 .iter()
12706 .map(|entry| Value::String(entry.as_str().to_owned()))
12707 .collect(),
12708 ),
12709 );
12710 }
12711}
12712
12713fn compose_body(
12714 content: Option<&str>,
12715 metadata: &BTreeMap<String, Value>,
12716) -> Result<Option<String>, SourceError> {
12717 let visible = content.unwrap_or_default();
12718 if metadata.is_empty() {
12719 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
12720 }
12721 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
12722 message: error.to_string(),
12723 })?;
12724 Ok(Some(if visible.is_empty() {
12725 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12726 } else {
12727 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
12728 }))
12729}
12730
12731fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
12732 value
12733 .get(field)
12734 .and_then(Value::as_bool)
12735 .ok_or_else(|| SourceError::Malformed {
12736 message: format!("GitHub response is missing boolean field {field}"),
12737 })
12738}
12739fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
12740 match value.get(field) {
12741 None | Some(Value::Null) => Ok(None),
12742 Some(value) => value
12743 .as_str()
12744 .map(Some)
12745 .ok_or_else(|| SourceError::Malformed {
12746 message: format!("GitHub response field {field} is not a string or null"),
12747 }),
12748 }
12749}
12750fn optional_nodes<'a>(
12751 connection: Option<&'a Value>,
12752 name: &str,
12753) -> Result<Option<&'a Vec<Value>>, SourceError> {
12754 match connection {
12755 None | Some(Value::Null) => Ok(None),
12756 Some(value) => value
12757 .get("nodes")
12758 .and_then(Value::as_array)
12759 .map(Some)
12760 .ok_or_else(|| SourceError::Malformed {
12761 message: format!("GitHub {name}.nodes is not an array"),
12762 }),
12763 }
12764}
12765fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
12766 let page_info = connection
12767 .get("pageInfo")
12768 .ok_or_else(|| SourceError::Malformed {
12769 message: format!("GitHub {name} has no pageInfo"),
12770 })?;
12771 if required_bool(page_info, "hasNextPage")? {
12772 return Err(SourceError::Malformed {
12773 message: format!(
12774 "GitHub {name} exceeds the supported nested connection size of {size}"
12775 ),
12776 });
12777 }
12778 Ok(())
12779}
12780fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
12781 optional_str(value, field)?
12782 .map(|timestamp| {
12783 timestamp.parse().map_err(|error| SourceError::Malformed {
12784 message: format!("GitHub response field {field} is not a timestamp: {error}"),
12785 })
12786 })
12787 .transpose()
12788}
12789fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
12790 if page.limit == 0 {
12791 Err(SourceError::Config {
12792 message: "page limit must be at least 1".into(),
12793 })
12794 } else {
12795 Ok(())
12796 }
12797}
12798fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
12799 let page = connection
12800 .get("pageInfo")
12801 .filter(|value| value.is_object())
12802 .ok_or_else(|| SourceError::Malformed {
12803 message: "GitHub connection is missing pageInfo".into(),
12804 })?;
12805 if required_bool(page, "hasNextPage")? {
12806 let cursor = required_str(page, "endCursor")?;
12807 validate_cursor_progress(None, cursor)?;
12808 Ok(Some(Cursor(cursor.into())))
12809 } else {
12810 Ok(None)
12811 }
12812}
12813fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
12814 if next.is_empty() || previous == Some(next) {
12815 Err(SourceError::Malformed {
12816 message: "GitHub pagination cursor is empty or did not advance".into(),
12817 })
12818 } else {
12819 Ok(())
12820 }
12821}
12822/// The version of this plugin's opaque narrowing-search cursor.
12823pub const SEARCH_CURSOR_VERSION: u32 = 4;
12824
12825#[derive(Serialize, Deserialize)]
12826#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
12827enum SearchConnection {
12828 Initial {},
12829 Continuing { after: Cursor },
12830 Exhausted {},
12831}
12832impl SearchConnection {
12833 fn after(&self) -> Option<&str> {
12834 match self {
12835 Self::Continuing { after } => Some(&after.0),
12836 _ => None,
12837 }
12838 }
12839 fn exhausted(&self) -> bool {
12840 matches!(self, Self::Exhausted { .. })
12841 }
12842 /// Whether a cursor naming this position, `offset` rows into its page, is one this
12843 /// plugin could have handed out: a page is resumed only part of the way through it — an
12844 /// offset of a whole page or more would skip rows nobody was given — an initial page
12845 /// only once some of it was handed out, and an exhausted connection has no page to be
12846 /// part of the way through.
12847 fn valid_resume(&self, offset: usize) -> bool {
12848 let within = offset < SEARCH_PAGE_SIZE as usize;
12849 match self {
12850 Self::Initial { .. } => offset > 0 && within,
12851 Self::Continuing { after } => !after.0.is_empty() && within,
12852 Self::Exhausted { .. } => offset == 0,
12853 }
12854 }
12855}
12856
12857/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
12858#[derive(Serialize, Deserialize)]
12859#[serde(deny_unknown_fields)]
12860struct SearchPosition {
12861 version: u32,
12862 connection: SearchConnection,
12863 /// How many rows of the page `connection` starts were already handed out.
12864 #[serde(default, skip_serializing_if = "is_zero")]
12865 offset: usize,
12866 #[serde(default, skip_serializing_if = "Vec::is_empty")]
12867 seen: Vec<NativeId>,
12868 #[serde(default, skip_serializing_if = "Vec::is_empty")]
12869 own: Vec<NativeId>,
12870}
12871impl Default for SearchPosition {
12872 fn default() -> Self {
12873 Self {
12874 version: SEARCH_CURSOR_VERSION,
12875 connection: SearchConnection::Initial {},
12876 offset: 0,
12877 seen: Vec::new(),
12878 own: Vec::new(),
12879 }
12880 }
12881}
12882
12883fn is_zero(offset: &usize) -> bool {
12884 *offset == 0
12885}
12886
12887fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
12888 cursor.map_or(Ok(0), |c| {
12889 c.0.parse().map_err(|_| SourceError::Config {
12890 message: "page cursor is invalid".into(),
12891 })
12892 })
12893}
12894fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
12895 if offset > items.len() {
12896 return Page::last(vec![]);
12897 }
12898 let tail = items.split_off(offset);
12899 let mut selected = tail;
12900 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
12901 selected.truncate(limit);
12902 Page {
12903 items: selected,
12904 next,
12905 }
12906}
12907
12908impl GitHubProjectsSource {
12909 async fn write_task_assets(
12910 &self,
12911 write: &ItemWrite<Task>,
12912 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
12913 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
12914 let near = write.target.as_ref().unwrap_or(&write.item.id);
12915 for (key, entries) in [
12916 (TaskRef::DELIVERS_KEY, &write.item.delivers),
12917 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
12918 ] {
12919 TaskRef::listed(key, near, Some(&self.name), entries.clone())
12920 .map_err(|message| SourceError::Refused { message })?;
12921 }
12922 if self.priorities.is_none() && write.item.priority != Priority::None {
12923 return Err(self.holds_no_priority());
12924 }
12925 self.write_item(
12926 &Incoming {
12927 written: Written::Work(ItemKind::Task, &write.item.status),
12928 title: &write.item.title,
12929 content: write.item.content.as_deref(),
12930 assets,
12931 labels: &write.item.labels,
12932 metadata: &write.item.metadata,
12933 repositories: &write.item.repositories,
12934 classification: write.item.classification,
12935 parent: write.item.project.as_ref(),
12936 delivers: &write.item.delivers,
12937 delivered_by: &write.item.delivered_by,
12938 priority: self.priorities.as_ref().map(|_| write.item.priority),
12939 },
12940 write.target.as_ref(),
12941 &write.depends_on,
12942 )
12943 .await
12944 }
12945 async fn write_document_assets(
12946 &self,
12947 write: &ItemWrite<Document>,
12948 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
12949 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
12950 // A document takes part in no dependency graph, so there is no far end to write
12951 // natively and none to record: a caller naming one is told so rather than having it
12952 // stored under the reserved key, where a later read would report an edge the
12953 // contract says cannot exist.
12954 if !write.depends_on.is_empty() {
12955 return Err(SourceError::Refused {
12956 message: format!(
12957 "this write names {} dependencies for a document, and a document takes \
12958 part in no dependency graph; next: put the dependency on the task or \
12959 project the document is about",
12960 write.depends_on.len()
12961 ),
12962 });
12963 }
12964 self.write_item(
12965 &Incoming {
12966 written: Written::Document,
12967 title: &write.item.title,
12968 content: write.item.content.as_deref(),
12969 assets,
12970 labels: &write.item.labels,
12971 metadata: &write.item.metadata,
12972 repositories: &write.item.repositories,
12973 classification: write.item.classification,
12974 parent: write.item.project.as_ref(),
12975 delivers: &[],
12976 delivered_by: &[],
12977 priority: None,
12978 },
12979 write.target.as_ref(),
12980 &[],
12981 )
12982 .await
12983 }
12984}
12985
12986/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
12987/// itself, for the two things no journey can observe.
12988///
12989/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
12990/// with and without the call, that a settlement, a board listing and a metadata search each
12991/// read afresh after it — the resolved records, the written-item overlay, the board and its
12992/// search, and the narrowed searches. What they cannot reach is the held field definitions,
12993/// because a status write naming an option a person deleted is refused the same whether or
12994/// not the list is held, and a poisoned lock, because nothing outside the source can panic
12995/// while one of its locks is held. So these assert those directly, and every other holder
12996/// beside them so a holder added later without a clear in the call fails here.
12997#[cfg(test)]
12998mod end_command_tests {
12999 use super::*;
13000
13001 struct Token;
13002
13003 impl SecretResolver for Token {
13004 fn get(&self, var: &str) -> Option<SecretString> {
13005 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
13006 }
13007 }
13008
13009 fn source() -> GitHubProjectsSource {
13010 let config = serde_json::from_value(json!({
13011 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
13012 // Nothing here is sent: the source is only built and its state inspected.
13013 "endpoint": "http://127.0.0.1:9/graphql",
13014 }))
13015 .expect("a usable configuration");
13016 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
13017 .expect("the source builds")
13018 }
13019
13020 /// One issue as a board read answers it.
13021 fn resolved(source: &GitHubProjectsSource) -> Resolved {
13022 source
13023 .resolve(&json!({
13024 "id": "ITEM-1",
13025 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
13026 "body": "what a person may since have edited", "state": "OPEN",
13027 "stateReason": null, "url": null, "number": 1,
13028 "subIssuesSummary": {"total": 0},
13029 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
13030 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
13031 }))
13032 .expect("the item reads")
13033 .expect("an issue")
13034 }
13035
13036 /// Hold something in every holder the call clears, and the repository id it keeps.
13037 fn fill(source: &GitHubProjectsSource) {
13038 let item = resolved(source);
13039 source.created.lock().unwrap().push(item.clone());
13040 source.updated.lock().unwrap().push(item.clone());
13041 *source.board_cache.lock().unwrap() = Some(Board {
13042 id: "PVT-board".into(),
13043 fields: json!({"nodes": []}),
13044 items: vec![item.clone()],
13045 });
13046 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
13047 source
13048 .narrowed_cache
13049 .lock()
13050 .unwrap()
13051 .insert("status:todo".into(), vec![item.clone()]);
13052 source.children_cache.lock().unwrap().insert(
13053 NativeId("P-1".into()),
13054 (NativeId("P-1".into()), vec![item.clone()]),
13055 );
13056 source
13057 .search_next
13058 .lock()
13059 .unwrap()
13060 .insert("status:todo".into(), Some("cursor".into()));
13061 source
13062 .resolved_cache
13063 .lock()
13064 .unwrap()
13065 .insert(item.id.clone(), item);
13066 *source.fields_cache.lock().unwrap() = Some(BoardFields {
13067 id: BoardId::parse("PVT-board").unwrap(),
13068 fields: json!({"nodes": []}),
13069 });
13070 source
13071 .repository_cache
13072 .lock()
13073 .unwrap()
13074 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
13075 }
13076
13077 fn assert_dropped(source: &GitHubProjectsSource) {
13078 assert!(source.created().unwrap().is_empty(), "created");
13079 assert!(source.updated().unwrap().is_empty(), "updated");
13080 assert!(source.board_cache().unwrap().is_none(), "board");
13081 assert!(source.search_cache.lock().unwrap().is_none(), "search");
13082 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
13083 assert!(
13084 source.children_cache.lock().unwrap().is_empty(),
13085 "project children"
13086 );
13087 assert!(
13088 source.search_next.lock().unwrap().is_empty(),
13089 "search paging"
13090 );
13091 assert!(
13092 source.resolved_cache().unwrap().is_empty(),
13093 "resolved records"
13094 );
13095 assert!(source.fields_cache().unwrap().is_none(), "board fields");
13096 assert_eq!(
13097 source.repository_cache().unwrap().len(),
13098 1,
13099 "a repository's node id stays valid and is kept"
13100 );
13101 }
13102
13103 fn end(source: &GitHubProjectsSource) {
13104 tokio::runtime::Builder::new_current_thread()
13105 .build()
13106 .unwrap()
13107 .block_on(source.end_command())
13108 .expect("the command ends");
13109 }
13110
13111 #[test]
13112 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
13113 let source = source();
13114 fill(&source);
13115 end(&source);
13116 assert_dropped(&source);
13117 }
13118
13119 #[test]
13120 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
13121 fn poison<T: Send>(held: &Mutex<T>) {
13122 std::thread::scope(|scope| {
13123 let _ = scope
13124 .spawn(|| {
13125 let _guard = held.lock().unwrap();
13126 panic!("a failure while the lock is held");
13127 })
13128 .join();
13129 });
13130 assert!(held.is_poisoned());
13131 }
13132 let source = source();
13133 fill(&source);
13134 poison(&source.created);
13135 poison(&source.updated);
13136 poison(&source.board_cache);
13137 poison(&source.search_cache);
13138 poison(&source.narrowed_cache);
13139 poison(&source.search_next);
13140 poison(&source.resolved_cache);
13141 poison(&source.fields_cache);
13142 assert!(
13143 source.resolved_cache().is_err(),
13144 "a poisoned lock is refused before the call"
13145 );
13146 end(&source);
13147 assert_dropped(&source);
13148 }
13149}
13150
13151#[cfg(test)]
13152mod unprojectable_fields_tests {
13153 use super::*;
13154
13155 /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
13156 fn documented() -> std::collections::BTreeSet<String> {
13157 let document = std::fs::read_to_string(
13158 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
13159 )
13160 .expect("docs/metadata.md is readable");
13161 let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
13162 let lead = "a `field` equal, ignoring case, to ";
13163 let start = document.find(lead).expect("the refused-field sentence") + lead.len();
13164 let sentence = &document[start..];
13165 let sentence = &sentence[..sentence
13166 .find("; and two entries naming one field")
13167 .expect("the refused-field sentence ends at the duplicate rule")];
13168 let (own, github) = sentence
13169 .split_once(" or a field GitHub owns on every board (")
13170 .expect("the sentence names GitHub's own fields");
13171 own.split('`')
13172 .skip(1)
13173 .step_by(2)
13174 .chain(
13175 github
13176 .strip_suffix(')')
13177 .expect("GitHub's own fields close the sentence")
13178 .split(", "),
13179 )
13180 .map(str::to_owned)
13181 .collect()
13182 }
13183
13184 #[test]
13185 fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
13186 let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
13187 .iter()
13188 .map(|&name| name.to_owned())
13189 .collect();
13190 assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
13191 let instance = SourceName::new("work").unwrap();
13192 for name in UNPROJECTABLE_FIELDS {
13193 for spelled in [name.to_owned(), name.to_uppercase()] {
13194 let entry = MetadataFieldConfig {
13195 field: spelled.clone(),
13196 key: "team.machine".to_owned(),
13197 path: Vec::new(),
13198 };
13199 let refused = MetadataField::resolve(vec![entry], &instance)
13200 .expect_err("an unprojectable field is refused");
13201 assert!(
13202 refused.to_string().contains(&format!("{name:?}")),
13203 "{spelled}: {refused}"
13204 );
13205 }
13206 }
13207 }
13208}