onetaskgraph_github_projects/lib.rs
1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A write held to a version** (`--if-version`) is checked immediately before its first
4//! mutation and not as one step with it: GitHub takes no conditional write, so the engine reads
5//! the task through this source just before writing it, and a change landing between that read
6//! and the mutation — the time one request takes — is GitHub's residual race.
7//!
8//! **A board is a container of projects, not a project.** Its own `title`,
9//! `shortDescription` and `readme` are never read as an item's fields and are never
10//! written: nothing in this source can rename the board a user configured.
11//!
12//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
13//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
14//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
15//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
16//! naming the field — but that repository is the *fallback*, not the home of every item.
17//!
18//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
19//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
20//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
21//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
22//! change to the rule. -->
23//! **Which repository an issue is created in is decided by the item's own `repositories`
24//! field, under one rule.** Exactly one entry names the repository the issue is created in:
25//! a task issue is where a person finds the work from the repository it changes, and one
26//! filed in a board's nominated repository is invisible from every other. Zero entries, or
27//! two or more, name none, so a task's or a document's issue is created in the repository
28//! its parent project's issue lives in — read from the board, or from this process's own
29//! record of a project it created earlier in the same command — and a project's issue, or
30//! a task or document written with no parent, is created in the configured `repository:`.
31//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
32//! existing issue is never moved: the update path leaves the issue where it is and records
33//! the list in the metadata slot when it differs, so the read side's derivation and the
34//! creation rule agree by construction.
35//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
36//!
37//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
38//! board has no document type and nothing but issues to hold one in, so the title is the
39//! discriminator and it is the whole of it. The title this source *reports* is the one a
40//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
41//! the body so `content` is what the person wrote — and writing a document puts the prefix
42//! back, so a round trip returns the title that went in.
43//!
44//! **Telling a document from a project from a task.** The design prefix is read **first**:
45//! a document is never a project and never a task, whatever sub-issues it has or does not
46//! have. Only then does the rest apply — a board issue is a project when *either* it has
47//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
48//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
49//! necessary: it is what makes an *empty* project — the state a project copy passes
50//! through between creating the project and filing its first task — readable as a
51//! project, while the sub-issue arm lets a person author a project on the board by hand
52//! with no knowledge of this product's metadata at all. Reading the prefix later than the
53//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
54//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
55//! task nor a document and are ignored.
56//!
57//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
58//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
59//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
60//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
61//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
62//! every comment as the account the token belongs to, so a comment handed an author of its
63//! own is refused rather than posted under another name. GitHub's comment mutations take the
64//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
65//! is on, and a comment on some other issue is one this task does not have.
66//!
67//! **Where an entity is, is a link.** Every project, task and document this source reports
68//! carries a [`Location::Url`] naming the issue's own web address — the same address the
69//! `url` field already reports, in the shape that says a reader can open it. That is the
70//! contrast the location contract exists for: a reader holding an entity from this source
71//! is handed something to link to and one holding an entity from a folder of Markdown is
72//! handed a path, and neither has to know which plugin answered. It does not replace or
73//! derive from `url`; that field goes on reporting what it always reported.
74//!
75//! **Where metadata lives.** Short typed things go to typed fields and native relations:
76//! status to the board's `Status` single-select and the issue's own state, the copy
77//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
78//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
79//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
80//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
81//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
82//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
83//! a project or a document alike — is one update of the issue body that changes that slot
84//! and not one byte outside it, and it is not sent at all when the key already holds the
85//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
86//! is kept in that same slot, written by that same update.
87//!
88//! **A caller's value can also be projected onto a board text field**, so the board can filter
89//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
90//! metadata key and a path inside its value. The slot stays the value's one home — every read
91//! reports metadata from it alone — and the field follows it on every write of an item, a
92//! create, an update and a copy alike: a string is written, and not sent again while the field
93//! holds it — an empty string included, which is written as itself; nothing there or `null`
94//! clears a field holding a value; any other
95//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
96//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
97//! own moves its field first and the body last, as every write of an existing item does. A
98//! board without the field, or with a non-text field of that name, is refused before any
99//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
100//! a text field and never changes a field that is there.
101//!
102// 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.
103//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
104//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
105//! option of the board's one `Status` field for a task and for a project: a bare name is
106//! the option for both kinds, `null` disables the category for both, and `{task, project}`
107//! names it per kind. A category the mapping does not mention keeps its shipped default for
108//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
109//! gets the shipped default for the kind it leaves out. Two categories one kind would read
110//! back from one option are refused as the configuration is read, ignoring case, while one
111//! option may stand for different categories of the two kinds. Writes go by the kind of the
112//! item written: a status that kind has no option for, or whose option the board lacks, is
113//! refused before any mutation, naming the source, the kind, the category and the key
114//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
115//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
116//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
117//! before selecting its option. Reads give a closed issue's reason precedence over its
118//! option, while an open issue's option decides its category through its own kind's
119//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
120//! The guarded [`GitHubProjectsSource::status_options`] and
121//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
122//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
123//! existing option id and verify the field and item assignments immediately afterwards.
124//! They ask for both kinds' options, counting a terminal category's mapped option as
125//! configured because a terminal write refuses without it. No ordinary source read or
126//! write calls that mutation, whose
127//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
128//! and a mistake destroys every item's status. A status this board cannot represent is a
129//! refusal naming the status and the instance instead.
130//!
131//! `unknown` has no shipped option because this source cannot preserve an open-ended
132//! status word: it writes an existing board option and never
133//! creates an option. An operator may map `unknown` to one existing option, in which case
134//! every unknown word lands on that option and reads back as `unknown` under the option's
135//! name. This differs from `local-md`, which writes and reads the original word itself.
136//!
137//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
138//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
139//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
140//! finished tasks were only moved to a "Done" column would read 0% complete forever.
141// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
142//!
143//! # What this source declares, field by field
144//!
145//! One verdict per field of [`Capabilities`], and what `Native` means when this source
146//! says it. *Proven* means a shared journey drives it against the real
147//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
148//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
149//! [`capabilities`](TaskSource::capabilities) from parting.
150//!
151//! | Field | Verdict |
152//! | --- | --- |
153//! | `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. |
154//! | `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. |
155//! | `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. |
156//! | `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. |
157//! | `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. |
158//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
159//! | `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. |
160//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
161//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
162//! | `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. |
163//! | `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. |
164//! | `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. |
165//! | `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. |
166//! | `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. |
167//! | `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. |
168//! | `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. |
169//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
170//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
171//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
172//!
173//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
174//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
175//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
176//! and the raw bytes as the request body. The numeric repository id is read at most once
177//! per repository per source instance. A loopback API endpoint also selects the loopback
178//! uploads host; production needs no assets repository, commits or extra configuration.
179//! Every returned JSON `url` is read with the same token before a body is written: only
180//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
181//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
182//! asset and HTTP status and says the token type may not be accepted by the upload
183//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
184//! fine-grained PATs or OAuth tokens.
185//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
186//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
187//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
188//! and verified afresh, in the repository the issue already lives in on an update.
189//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
190//! Uploads take the same mutation spacing as content writes. Both uploads and verification
191//! reads wait out classified rate limits within the configured per-call retry budget,
192//! on the supplied clock; verifying reads take no mutation slot.
193//!
194//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
195//! source has documents and that its tasks have comments, both of which hold — and the three
196//! facts behind the uniform `Native` on the
197//! predicates beside it are recorded below rather than re-derived, because a reader who
198//! takes `Native` to mean *the remote service filters* will read that uniformity as a
199//! lie.
200//!
201//! First, the plugin contract defines `Support::Native` as *the source applies this
202//! predicate itself*, and says nothing about where it applies it. What the declaration
203//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
204//! — so that the engine may push it down and apply nothing of its own.
205//!
206//! Second, this source can keep that promise for every predicate at no additional API
207//! cost, because whichever of the reads below answers a query has already read every
208//! candidate that query will return before it filters anything. Filtering those items is
209//! in-process work over data already in hand.
210//!
211//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
212//! applied in process over what that question returned. A project filter has a relationship — a
213//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
214//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
215//! a search for metadata values, is the board-scoped issue search carrying the text and each
216//! value as quoted phrases; an origin is the board's own field filter over its origin field
217//! beside the same search for the id. **The text search narrows, and that is this source's
218//! declared semantics:** GitHub matches whole words where the substring rule this source and
219//! the local Markdown source confirm with would match inside one, so an item holding the text
220//! only inside a longer word is never a candidate. Every item returned does contain the text.
221//! A project query's text, and a document query's scoped to no project, is that same search
222//! and narrows on the same terms, its candidates confirmed by their kind as well.
223//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
224//! so those three are applied in process over the candidates, and a query carrying none of
225//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
226//! engine compensate for work this source has already done, and declaring `projects` native
227//! while ignoring the filter (which this source once did) silently returns another project's
228//! tasks, because the engine trusts the declaration and applies nothing locally.
229//!
230//! # The three ways this source reaches an item, and what each costs
231//!
232//! A board read is charged for what its *nested* connections could return rather than for
233//! what was asked, so one whole-board read costs the same whether the question was about
234//! one project or about all of them. That is why a question about one project is never
235//! answered by reading the board:
236//!
237//! | The question | What is sent | What it costs |
238//! | --- | --- | --- |
239//! | 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 |
240//! | 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 |
241//! | one board page with each task's first comments, for `TaskSource::query_task_details` | [`graphql::BOARD_WITH_COMMENTS`] — the board's own `items`, without search completion | tasks and first comment pages in one request; board consistency, with comment-activity filtering refused |
242//! | 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 |
243//! | 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 |
244//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
245//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
246//! | 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 |
247//! | 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 |
248//! | 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 |
249//! | 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 |
250//! | 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 |
251//! | 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 |
252//!
253//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
254//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
255//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
256//! equal to declared points equal to the row. They include the origin lookup and the
257//! field/repository discovery a create needs. A bound re-copy changes status, priority,
258//! content and metadata; comment recount means a subsequent detail read. Each request here
259//! costs one declared point. A membership beyond the embedded page can additionally require
260//! the one-point membership recovery described above. A bound re-copy of a task filed under a
261//! project adds one read, the engine confirming that project's link by its own id once per
262//! command; and the same-source far ends a write newly names — those that do not already block
263//! the item, whose own read answered for them — are read together by their own ids,
264//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
265//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
266//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
267//!
268//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
269//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
270//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
271//! holds both halves.
272//!
273//! **An existing item is written body last.** A bound re-copy and a `task update` send its
274//! board fields first — the `Status` option and the `Priority` together, in one request — then
275//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
276//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
277//! undoing an earlier field when a later one fails, so that order is what makes a write
278//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
279//! the one piece of metadata written before the body, an origin a copy re-points, is put back
280//! when a later write is refused — and when putting it back is refused too, the write's own
281//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
282//! of those writes in turn, whole and as one aliased field failing after the one before it.
283//!
284//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
285//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
286//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
287//!
288//! - **A board is accepted at creation but its item is not answered, so a create still files
289//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
290//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
291//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
292//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
293//! against a real board on 2026-10-01 with a create sending the board there and reading the
294//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
295//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
296//! refused "Content already exists in this project" — GitHub had filed the issue after
297//! answering, and refuses a second filing rather than answering with the item it holds. A
298//! create therefore sends no `projectV2Ids` and files the issue with
299//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
300//! real is the read before it: the board's fields and the repository's id together, in
301//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
302//! - **A comment still reads its target first, so a comment is 2 requests.**
303//! `AddCommentInput.subjectId: ID!` is declared there with
304//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
305//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
306//! project's issue, a document's issue, an issue on no board of this source and a pull
307//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
308//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
309//! refuses those by name.
310//!
311//! | Verb | Requests / points | Documents |
312//! | --- | --- | --- |
313//! | new copy | 4 | ORIGIN_LOOKUPS (the origin, the board's fields and the repository's node and numeric ids together, a page of one carrier for each origin), CREATE_ISSUE filing the issue on the board and under its project, CREATED_BOARD_ITEMS once GitHub has added it, WRITE_FIELD_VALUES |
314//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, CREATED_BOARD_ITEMS, WRITE_FIELD_VALUES: the new copy with no origin to ask about |
315//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS when a board field differs, then UPDATE_ISSUE last carrying only the title, body or state that differs — none, when nothing does |
316//! | 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 |
317//! | 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 |
318//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
319//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
320//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
321//! | 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 |
322//! | recount | 1 | ISSUE_DETAIL |
323//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
324//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
325//! | content | 2 | ISSUE, UPDATE_ISSUE |
326//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
327//! | document metadata, `--expect` a version | 2 | ISSUE, then UPDATE_ISSUE only when the issue is still at that version — the precondition GitHub's own update cannot take — primed with what the read answered |
328//! | 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 |
329//! | record only | 1 | ISSUE |
330//!
331//! <!-- github-search-paging:start -->
332//! Board-scoped text, metadata, project-name and comment-activity searches send every
333//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
334//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
335//! needs rows. A page is never resized to the rows still needed: GitHub orders one
336//! search differently at different page sizes, so one fixed size makes a paged walk
337//! send exactly the requests one whole read sends, and the answer's order is the order
338//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
339//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
340//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
341//! returned and fetched pages: a limit is sliced from the pages it needs, and local
342//! confirmation can require more candidates than matching rows. Walking all pages
343//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
344//! cursor and how far into that page the last answer stopped, and resumes in the same
345//! process or a new one, without duplicates or gaps. It carries no rows: one process
346//! sends each page's search once, and a new process re-reads only the page it resumes
347//! in, then sends a further page once, never as a re-read, only when its limit still
348//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
349//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
350//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
351//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
352//! not required to include the original process's writes still omitted by the index.
353//! <!-- github-search-paging:end -->
354//!
355//! The board half of an issue — its board item's id, its `Status` option and this
356//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
357//! an item reached any of those ways resolves through the same
358//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
359//! same status, the same labels and the same qualified id. That connection comes back a
360//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
361//! on the page in hand and — only if that page reports more of the connection — in the
362//! last row's read of that one issue's memberships, resumed from the page's own cursor and
363//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
364//! report, which is what keeps an id naming another repository's issue from being answered
365//! as an item of this board; and because the page is where the search starts rather than
366//! where it ends, that answer is one about a connection read to exhaustion and never about
367//! an unread page. Nothing costs the extra read but an issue on more boards than a page
368//! holds: an issue this board really does not hold reports no next page, so its
369//! memberships are already exhausted where they arrived.
370//!
371//! **No document here selects the board's own `Labels` field, and nothing is lost by
372//! that.** An item's labels are read from its content alone, wherever that content is
373//! reached: the three documents above select `Issue.labels` on the fragment, and
374//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
375//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
376//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
377//! create one, and `ProjectV2FieldValue` — the whole of what
378//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
379//! it from the content, for every content type it exists on, and there is nothing it can
380//! hold that the content does not already say: for an `Issue` it *is* that issue's own
381//! labels, so selecting it beside them unions a set with itself.
382//!
383//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
384//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
385//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
386//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
387//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
388//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
389//! label set, and that set is the fixture issue's own, by
390//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
391//! item whose content is a draft reports an empty set, by
392//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
393//! selection is held over [`graphql::DOCUMENTS`] by
394//! `no_document_selects_the_boards_own_labels_field`.
395//!
396//! The whole-board row is still the board's own item connection, and deliberately: a
397//! **draft** board item is not an issue, so no search can list one, and the reads that have
398//! to answer for the whole board are the ones whose cost is the board's size anyway.
399//!
400//! **A question about one item this source already names by id never lists the board.**
401//! Whether that item is on this board, and what its board fields are, is answered by reading
402//! that item — its own `Issue.projectItems`, walked to exhaustion by
403//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
404//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
405//! holds. That covers a write's destination, the project a new item is filed under, a
406//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
407//! and the delete that takes back an item a copy made. What such a write needs of the board
408//! and the item does not carry — the board's id, the `Status` and origin field definitions —
409//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
410//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
411//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
412//! minutes. Scanning this host's 842-item board has refused a document copy and an update
413//! even though the items' own reads named that board. A scan there gives the wrong answer
414//! as well as paying for every page. So a `board.items` lookup does not belong on any of
415//! those paths.
416//!
417//! **What a read may return is capped too, and that cap is on the document rather than on
418//! the board.** GitHub limits the number of nodes **one query may return** to
419//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
420//! an error naming the connection the count crossed at, not a slow or a partial result.
421//! Every board this source reads is refused the same way, so no board is too big for these
422//! documents and none is small enough to save one that is over.
423//!
424//! The count is arithmetic over the document's own text: each connection contributes the
425//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
426//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
427//! restate them — `github-graphql-node-count` implements them, and
428//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
429//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
430//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
431//! same text on every run and fails naming any that reaches the limit — so a connection
432//! added to a shared fragment is caught there rather than by GitHub.
433//!
434//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
435//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
436//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
437//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
438//! effectively squared there, which is why it is the one the limit is most sensitive to.
439//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
440//! page of memberships misses is recovered by one further read rather than refused, so it
441//! buys a bound every read pays for at the price of a request only a multi-board issue
442//! pays.
443//!
444//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
445//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
446//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
447//! `cost` is rate-limit points, metered per hour across everything one credential does; it
448//! is what the two limiters [`Limiter`] tells apart meter, and a document under
449//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
450//! that second number, and `tests/point_cost.rs` pins every document in
451//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
452//! one under, the pin itself is the check. The credentialed lane reconciles both figures
453//! against GitHub's own, off a probe it already sends.
454//!
455//! **What is pinned that way is a per-document price and never a session's.** The record in
456//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
457//! **requests** and **worst-case nodes** — and neither is points. What one whole session
458//! consumes of the hourly point allowance is observable only from a credentialed run's own
459//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
460//! and what `tests/live.rs` prints at the end of every run.
461//!
462//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
463//!
464//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
465//! enumerations of a board can supply one alone.** Resolving a node id is strongly
466//! consistent, so a read by id and a project's own sub-issues are already current. The
467//! other two are not, and they are behind by different amounts and in different directions:
468//!
469//! - GitHub's **issue search** is an index and answers a write made moments ago with the
470//! value from before it — usually for a second or two.
471//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
472//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
473//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
474//! for *minutes*, while `Issue.projectItems` names the same membership at once.
475//!
476//! That second one is a measurement rather than a caution. This repository's own
477//! credentialed journey writes a project and waits for the board to report it, then writes a
478//! task and waits for the same thing seconds later on the same board: the project wait is
479//! answered through the search and converged in two or three attempts in each of three runs,
480//! and the task wait is answered through `ProjectV2.items` and converged in none of them
481//! inside thirty. Separately, an item added to a second and larger board was read back by
482//! `Issue.projectItems` on that board's own id while every one of that connection's nine
483//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
484//! through the lagging one alone is what had a board read deny an issue that had certainly
485//! landed on it.
486//!
487//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
488//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
489//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
490//! search reports what the projection is behind on. What closes the last
491//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
492//! source answers is completed with what this process itself wrote, so an item created
493//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
494//! nothing is written down, and the record dies with the process. **A wait that has to
495//! observe GitHub's own data cannot be answered from that record** — which is why the
496//! credentialed journey asks through a source built afresh, and why the union above rather
497//! than a longer wait is what makes such a wait converge.
498//!
499//! **A narrowed read is the same bargain, stated for each of the three predicates it
500//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
501//! than walking the board, and every such answer is completed with what this process wrote —
502//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
503//! each filtered by the same predicates as the rest — so an item this command wrote a moment
504//! ago is returned by a query that matches it whether or not the index has caught up. An item
505//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
506//! consistent. What is left is stated rather than papered over:
507//!
508//! | Read | Finds | Behind by |
509//! | --- | --- | --- |
510//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
511//! | 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 |
512//! | 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 |
513//! | origin, third read | this process's own writes | nothing |
514//!
515//! So an origin carrier another process added within the last second or two, before either
516//! index has it, can be missing from an origin query, and one written by the release before
517//! this one — its origin in the field alone — can be missing for as long as the board's own
518//! item connection is behind on it. A copy that must not duplicate its own earlier write
519//! relies on the link it records, not on either index. **A board draft is not an issue**, so
520//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
521//! lists one, the origin lookup drops any the board's own field filter names, and one this
522//! process wrote is not added back either.
523//!
524//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
525//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
526//! body's metadata slot, so the issue search can find it in seconds. The field is
527//! authoritative: this source reads an item's origin from the field alone, so a slot that
528//! disagrees with it, or holds one where the field holds none, is never read as a second
529//! origin — and the release before this one reads the slot, drops that key's copy for the
530//! field's, and sees the same one origin.
531//!
532//! Filtering happens before paging, so a page of a filtered result is a page of the
533//! survivors rather than the survivors of a page. Label matching and the substring rule a
534//! text candidate is confirmed by answer the same question the same way the local Markdown
535//! source's do; which candidates a text search has to confirm is GitHub's word match, which
536//! is the one place the two sources can answer the same text differently.
537//!
538//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
539//! has one source, `capabilities`, and the note above is the reasoning behind it rather
540//! than a second copy of it: without the three facts recorded here a reader takes the
541//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
542//! crate's own capabilities test, which pins every field of it against a fully spelled-out
543//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
544//! fails to compile there rather than going unasserted. -->
545//! The fixture-server tests above run wherever this crate is selected; the credentialed
546//! lane runs in the same required check, beside them, and can fail it — it verifies the
547//! 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,
548//! one filed under neither, a label on one of the three and a closed status on another —
549//! because that shape is what tells an honoured predicate from an ignored one: a board
550//! holding a single project answers a project filter the same way whether or not this
551//! source applies it, which is exactly how the defect above went unseen.
552//!
553//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
554//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
555//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
556//! session or request, independently of the live demand. Fix that variable in the machine's
557//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
558//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
559//! policy crate; absent credentials or nominations otherwise skip.
560//!
561//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
562//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
563//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
564//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
565//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
566//! after its run reads back as `completed`, immediately before the listed-artifact batch.
567//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
568//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
569//! and the board's `onetaskgraph.origin` field untouched.
570//!
571//! # What a session of requests costs, and where the report is
572//!
573//! This source records **every** request it sends into [`accounting::Accounting`], at
574//! `send_once` — the one place a request leaves this crate, which is why a read path added
575//! later is counted without anybody remembering to count it. That is the whole of what this
576//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
577//! session's spend is arrived at, and what it deliberately does not know are set out.
578//!
579//! What one whole session of the live journey costs, counted that way against this crate's
580//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
581//! reduction it came out of, and with what it does and does not say about rate-limit points.
582//!
583//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
584//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
585//! code path — no environment variable, no feature, no build configuration — because an
586//! instrument nobody switches on measures nothing, and
587//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
588//! source's counts the whole session rather than this source's share. The credentialed lane
589//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
590//! passed or failed.
591//!
592//! **A live session refuses to start unless the account can afford it.** Before it does any
593//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
594//! GitHub documents as not counting against the REST rate limit and which answers both of
595//! its budgets at once — and starts only if, for each of them, what remains minus this
596//! session's estimated cost is still at least
597//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
598//! allowance. A session that cannot **declines**: it did not run, so it is
599//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
600//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
601//! waiting for the budget to come back. The estimate is derived offline from
602//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
603//! which is also where the published rule that model rests on is cited; the accounting
604//! above records the gate's own read like any other request, and
605//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
606//!
607//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
608//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
609//! text, which is what lets it run on every platform and on a pull request from a fork with
610//! no credential — and that is what actually stops a regression merging. But an offline
611//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
612//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
613//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
614//! number of nodes this query may return"* and whose `cost` is what that document would
615//! spend, both for a document **without executing it**, and the lane asks it for every query
616//! document this source sends, under the largest bindings this source sends, and fails when
617//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
618//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
619//! one; the offline pins still cover it. It records what those calls reported about the
620//! account's own allowance, because whether asking is free is a thing to observe rather than
621//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
622//! and `cost` is metered against an hourly allowance the accounting above reads off a
623//! credentialed run's own response headers.
624//!
625//! **GitHub has two rate limiters and this source is refused by both, so nothing here
626//! treats them as one thing.** The primary budget is the hourly allowance `gh api
627//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
628//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
629//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
630//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
631//! one part of.
632#![deny(missing_docs)]
633
634use std::collections::BTreeMap;
635use std::sync::{Arc, Mutex};
636use std::time::Duration;
637
638use chrono::{DateTime, Utc};
639use onetaskgraph_plugin_api::{
640 Capabilities, Classification, ClosedAs, Comment, CommentBody, CommentOrigin, Completion,
641 ContradictedItem, Coverage, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
642 DependencySupport, Designator, Direction, Document, DocumentQuery, DocumentVersionCoverage,
643 Health, HeldTask, IssueHolding, IssueRef, ItemKind, ItemWrite, Label, LabelFilter, Location,
644 MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
645 ProjectFilter, ProjectQuery, ProjectVersionCoverage, Repository, SecretResolver, SharedClock,
646 SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusContradiction,
647 StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
648 TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
649 WaitCause, WriteJournal, WriteSupport, system_clock,
650};
651use reqwest::{Client, StatusCode, Url};
652use schemars::{Schema, schema_for};
653use secrecy::{ExposeSecret, SecretString};
654use serde::{Deserialize, Serialize};
655use serde_json::{Value, json};
656
657pub mod accounting;
658mod assets;
659mod batch;
660pub mod change_token;
661mod expected;
662pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
663mod pacing;
664mod projection;
665mod relations;
666mod visibility;
667
668use accounting::Accounting;
669
670/// The registry name for this plugin.
671pub const KIND: &str = "github-projects";
672/// GitHub's maximum connection page size.
673pub const MAX_PAGE_SIZE: u32 = 100;
674/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
675/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
676/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
677pub const SEARCH_PAGE_SIZE: u32 = 20;
678/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
679/// its comments: the largest batch the node-count model prices at one point.
680///
681/// Each aliased item is resolved once, and what GitHub charges for it is the connections
682/// under it — its labels, its page of board memberships, the field values of each of those
683/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
684/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
685/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
686/// one point and fails if one item more would still be priced at one.
687pub const DETAIL_BATCH: usize = 24;
688/// How many issue references one [`graphql::ISSUES_BY_REFERENCE`] request reads.
689///
690/// Each reference asks GitHub for one connection — a page of its board memberships, each with
691/// its `Status` value read by name rather than through a connection of field values — so fifty
692/// come to fifty requests' worth of the aggregate GitHub divides by a hundred, which is its
693/// one-point minimum, and the batch a landing's closures are read in costs one point per fifty
694/// issues per source. `tests/point_cost.rs` prices the document at one point.
695pub const REFERENCE_BATCH: usize = 50;
696/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
697/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
698/// point.
699///
700/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
701/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
702/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
703/// if one item more would still be priced at one.
704pub const COPY_DETAIL_BATCH: usize = 21;
705/// How many mutations one aliased request of a copy's creates, closes and updates carries:
706/// [`graphql::CREATE_ISSUES`], [`graphql::CLOSE_ISSUES`] and [`graphql::UPDATE_ISSUES`].
707///
708/// Eleven is what GitHub was seen to accept in one request — the timing spike
709/// (`board-copy-speed-2026-10-18-spike-copy-timing-report`) sent eleven aliased mutations of four
710/// kinds, three `createIssue`, three `addProjectV2ItemById`, three field updates, an
711/// `addSubIssue` and an `addBlockedBy`, and every one ran — and no more was established.
712pub const MUTATION_BATCH: usize = 11;
713
714/// The most relation writes — `addSubIssue`, `removeSubIssue`, `addRelatesTo`,
715/// `removeRelatesTo` — one request carries: a 60-alias `addRelatesTo` answered HTTP 504 after
716/// landing 58 of its 60 (`spike-relations`), and 30 were accepted every time.
717pub const RELATION_BATCH: usize = 30;
718/// How many aliased writes one request of a copy's board field values and blocked-by edges
719/// carries: [`graphql::WRITE_FIELD_VALUES`], [`graphql::ADD_BLOCKED_BYS`] and, for a request
720/// holding both kinds, [`graphql::WRITE_FIELDS_AND_EDGES`].
721///
722/// Nineteen is the most aliases the feasibility spike
723/// (`board-copy-gaps-2026-10-24-spike-feasibility-report`) sent in one mutation request and
724/// saw GitHub price at the one point every mutation request costs; no larger request was
725/// established, so a copy holding more is split.
726pub const WRITE_BATCH: usize = 19;
727/// How many issues one [`graphql::DELETE_ISSUES`] request deletes: one request of eleven
728/// aliased `deleteIssue` met `RESOURCE_LIMITS_EXCEEDED` from the eighth alias on, and batches
729/// of five succeeded (the feasibility spike's undo-batching run).
730pub const DELETE_BATCH: usize = 5;
731/// How many origins one [`graphql::ORIGIN_LOOKUPS`] request asks about: the nine records of a
732/// plan and the project's document, and no more, since each carries a page of the board's field
733/// filter and one of the issue search.
734pub const ORIGIN_BATCH: usize = 10;
735/// How many searches one [`graphql::SEARCH_BATCH`] request asks: what `task find` asks of a
736/// board in one call, each search an aliased `search` field of its own.
737pub const SEARCH_BATCH: usize = 20;
738/// How many origins one [`graphql::SEARCH_BATCH`] request looks up beside its searches, each by
739/// the board's own field filter in an aliased `items` field of its own and by one of the
740/// request's searches.
741pub const SEARCH_BATCH_ORIGINS: usize = 5;
742/// The rows each search of a [`graphql::SEARCH_BATCH`] request asks for on its first page.
743///
744/// A batched find asks narrowed questions that each expect a handful of answers, and every
745/// search slot is priced at its page size: three rows a slot keep a first round of
746/// [`SEARCH_BATCH`] searches at three points, where a page of [`SEARCH_PAGE_SIZE`] each would
747/// cost twenty. A search with more to answer is walked on at [`SEARCH_PAGE_SIZE`] rows a page.
748pub const SEARCH_BATCH_FIRST_PAGE: u32 = 3;
749
750/// The rows each resumed page of one [`graphql::SEARCH_BATCH`] round asks for, when `resumed`
751/// of its slots — searches and origin item listings alike — resume from a cursor.
752///
753/// Every resumed page of a round shares one [`SEARCH_PAGE_SIZE`] of rows, rather than each
754/// asking that many: a round in which all twenty searches walked on at twenty rows a slot cost
755/// 22 points in one request, while sharing holds every round to what its first pages cost and
756/// one page more. A search with more to answer is walked on over more rounds, each page still
757/// read, so what a walk answers is unchanged; a round of one resumed search asks the page a
758/// search alone asks.
759#[must_use]
760pub fn search_batch_resumed_page(resumed: usize) -> u32 {
761 let resumed = u32::try_from(resumed.max(1)).unwrap_or(u32::MAX);
762 (SEARCH_PAGE_SIZE / resumed).max(1)
763}
764
765/// The page sizes of the most expensive [`graphql::SEARCH_BATCH`] round this source sends:
766/// every slot on its first page, at [`SEARCH_BATCH_FIRST_PAGE`] rows, but one resumed slot
767/// asking all of [`search_batch_resumed_page`]'s shared rows.
768///
769/// `tests/point_cost.rs` holds every round shape [`search_batch_resumed_page`] allows to cost
770/// no more than this, and the credentialed lane reconciles GitHub's own price of the document
771/// under it. [`largest_page_sizes`] still binds every slot at [`SEARCH_PAGE_SIZE`], an upper
772/// bound on this, so the offline worst case covers more than any round sends.
773#[must_use]
774pub fn search_batch_largest_round() -> Variables {
775 let mut sizes = largest_page_sizes();
776 for slot in 0..SEARCH_BATCH {
777 sizes.insert(format!("first{slot}"), SEARCH_BATCH_FIRST_PAGE);
778 }
779 for slot in 0..SEARCH_BATCH_ORIGINS {
780 sizes.insert(format!("itemsFirst{slot}"), SEARCH_BATCH_FIRST_PAGE);
781 }
782 sizes.insert("first0".to_owned(), search_batch_resumed_page(1));
783 sizes
784}
785/// How many comments one [`graphql::ADD_COMMENTS`] request adds, and how many issue updates one
786/// [`graphql::WRITE_TASKS`] request carries: [`MUTATION_BATCH`], the most aliased mutations
787/// GitHub was seen to accept in one request.
788pub const COMMENT_BATCH: usize = MUTATION_BATCH;
789/// How many board field values one [`graphql::WRITE_TASKS`] request carries, beside
790/// [`MUTATION_BATCH`] field clears: [`WRITE_BATCH`].
791pub const TASK_FIELD_BATCH: usize = WRITE_BATCH;
792
793/// The most nodes any one document this source sends may be asked to return.
794///
795/// GitHub's own published per-query ceiling, taken from
796/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
797/// workspace cannot hold a stale copy of somebody else's number. A query above it is
798/// **refused before it is executed**, whoever is asking and whatever board they are
799/// asking about — so this is a bound on the documents rather than a budget that runs out.
800///
801/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
802/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
803/// everything the credential does — two numbers against two limits, and this constant
804/// bounds only the first. The second is computed offline too, per document:
805/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
806/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
807/// lane. There is no constant like this one to hold a price under, because points are an
808/// hourly allowance rather than a per-call bound.
809///
810/// Neither is a session's price. What `session-cost.md` records of a whole session is its
811/// **requests** and its **worst-case nodes**; what a whole session spends in points is
812/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
813/// [`accounting`]. The module section on the three ways this source reaches an item says how
814/// the count is arrived at, and which of the page sizes below decide it.
815pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
816
817/// Nested connection size for the connections that hang off one item.
818///
819/// It multiplies through every document that reaches an item under a page — the count
820/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
821/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
822/// every document under these constants and fails naming any that reaches the limit, so
823/// raising this is caught there rather than by GitHub.
824const NESTED_PAGE_SIZE: u32 = 50;
825/// How many of one issue's board memberships are read when an issue is reached directly.
826///
827/// An issue reached through a search or through its own node id carries its board half in
828/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
829/// under a page of issues, so every point of it multiplies through the whole document and
830/// is paid for whether or not any issue is on a second board — which is why it is
831/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
832///
833/// **Three, because what a page misses is now recovered rather than refused**, and the
834/// recovery is what the value is chosen against. An issue whose entry for this board sits
835/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
836/// that page's own cursor — so the value trades a bound every read pays for a request only
837/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
838/// boards would pay that request *per issue*, which is order N against the one page per
839/// hundred issues a read costs today. At three it is only reached by an issue on four or
840/// more boards at once, which keeps the recovery path exceptional rather than routine for
841/// a plausible deployment.
842const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
843/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
844/// its two connections for.
845///
846/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
847/// second is a duplicate a copy already takes the first of. Both connections are walked to
848/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
849/// never what the answer is. It is small because every point of it is paid on every lookup,
850/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
851/// worst-case nodes than the one whole-board read they replaced.
852const ORIGIN_PAGE_SIZE: u32 = 3;
853/// How many carriers of each origin the batched [`graphql::ORIGIN_LOOKUPS`] asks each of its two
854/// connections for: one, the one carrier an origin names.
855///
856/// A second carrier still shows: the page then says there is more, and that origin is left to
857/// its own [`graphql::ORIGIN_LOOKUP`], which walks every page at `ORIGIN_PAGE_SIZE` — so a
858/// duplicate is found exactly as before, by the lookup that reads all of them. The feasibility
859/// spike (`board-copy-gaps-2026-10-24-spike-feasibility-report`) priced the batch of ten
860/// origins at two points with a first page of three and at one with a first page of one.
861const ORIGIN_LOOKUPS_PAGE_SIZE: u32 = 1;
862
863/// How many of one project's sub-issues one [`graphql::SUB_ISSUES`] page asks for: the most
864/// one point buys, since every row carries six connections GitHub charges for once per row.
865/// `session-cost.md` derives it; `tests/point_cost.rs` prices the document at one point and
866/// fails if one row more would still be priced at one.
867pub const MEMBER_PAGE_SIZE: u32 = 23;
868
869pub use github_graphql_node_count::{NodeCountError, Variables};
870
871/// The largest value this source can bind to each page-size variable its documents name.
872///
873/// Every `first:` in [`graphql`] reads one of these seven, and each is capped at the
874/// constant above it wherever a caller's own limit could reach it — `$first` at
875/// [`MAX_PAGE_SIZE`], `$commentFirst` at [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
876/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`, `$lookupFirst` at
877/// `ORIGIN_LOOKUPS_PAGE_SIZE`, `$memberFirst` at [`MEMBER_PAGE_SIZE`]. So this is the worst case a caller can drive this source to,
878/// not one configuration of it, which is what makes a bound computed under it a bound on
879/// every read.
880pub fn largest_page_sizes() -> Variables {
881 let mut sizes = Variables::from([
882 ("first".to_owned(), MAX_PAGE_SIZE),
883 ("commentFirst".to_owned(), MAX_PAGE_SIZE),
884 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
885 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
886 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
887 ("lookupFirst".to_owned(), ORIGIN_LOOKUPS_PAGE_SIZE),
888 ("memberFirst".to_owned(), MEMBER_PAGE_SIZE),
889 ]);
890 // Each slot of the batched search pages at most what one search does.
891 for slot in 0..SEARCH_BATCH {
892 sizes.insert(format!("first{slot}"), SEARCH_PAGE_SIZE);
893 }
894 for slot in 0..SEARCH_BATCH_ORIGINS {
895 sizes.insert(format!("itemsFirst{slot}"), SEARCH_PAGE_SIZE);
896 }
897 sizes
898}
899
900/// The most nodes `document` could be asked to return, by GitHub's published rules.
901///
902/// Computed offline from the document's own text under [`largest_page_sizes`] — no
903/// network, no credential and no schema — by
904/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
905/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
906/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
907///
908/// # Errors
909///
910/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
911/// no single operation, or binds a page size this source does not name — each of which is
912/// a defect in the document rather than a number.
913pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
914 node_count(document, &largest_page_sizes())
915}
916
917/// The most rate-limit points one call of `document` could spend, by GitHub's published
918/// rules.
919///
920/// Computed offline from the document's own text under [`largest_page_sizes`] — no
921/// network, no credential and no schema — by
922/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
923/// This is `cost`, metered **per hour** against the allowance one credential shares across
924/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
925/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
926/// under, so what `tests/point_cost.rs` does with it is pin every document in
927/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
928/// figures against GitHub's own reported `cost`.
929///
930/// # Errors
931///
932/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
933/// no single operation, or binds a page size this source does not name — each of which is
934/// a defect in the document rather than a number.
935pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
936 github_graphql_node_count::point_cost(document, &largest_page_sizes())
937}
938
939/// The rate-limit points one call of `document` spends under `variables`, by GitHub's published
940/// rules: [`worst_case_point_cost`] is this under [`largest_page_sizes`], and a request this
941/// source sent is priced by it under the page sizes that request really bound — what GitHub
942/// charges the request, since it charges every connection at the page size asked for.
943///
944/// # Errors
945///
946/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
947/// no single operation, or binds a page size `variables` does not name.
948pub fn point_cost(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
949 github_graphql_node_count::point_cost(document, variables)
950}
951
952/// The most nodes `document` could be asked to return under `variables`.
953///
954/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
955/// [`accounting`] is this under the bindings one request really sent — one spelling of the
956/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
957/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
958///
959/// # Errors
960///
961/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
962/// no single operation, or binds a page size `variables` does not name.
963pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
964 github_graphql_node_count::node_count(document, variables)
965}
966
967/// `variables` with the page sizes and flags a token twin's inline issue selection binds,
968/// at a read's page sizes.
969fn token_variables(mut variables: Value) -> Value {
970 if let Some(object) = variables.as_object_mut() {
971 object.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
972 object.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
973 object.insert("duplicates".to_owned(), json!(true));
974 }
975 variables
976}
977
978/// The input members a mutation names the issue or board item it writes by.
979const WRITTEN_NAMES: [&str; 4] = ["id", "issueId", "subjectId", "itemId"];
980
981tokio::task_local! {
982 /// The source a targeted update is running on, by its address, while it runs: what has its
983 /// mutations sent as the twins selecting the whole issue, so the update answers the version
984 /// its own last response shows (see `GitHubProjectsSource::written_tokens`).
985 static SPELLING: usize;
986}
987
988/// The issue-title prefix that makes a board issue a document.
989///
990/// A GitHub Projects board has no document type — it holds issues — so the discriminator
991/// is the title, and this is the whole of it: an issue whose title begins with these bytes
992/// is a document and every other issue is the task or project the sub-issue rule makes it.
993///
994/// It is spelled **once**, here, and read rather than restated everywhere else — including
995/// by the shared journeys, which take it from this constant so a board fixture cannot
996/// drift from what this source reads. `docs/metadata.md` records the two consequences that
997/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
998/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
999/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
1000pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
1001
1002/// The most characters GitHub stores in one issue's body: a longer one is refused as
1003/// `body is too long (maximum is 65536 characters)`.
1004pub const BODY_LIMIT: u64 = 65_536;
1005
1006/// The address a new upload's URL is spelled at, as GitHub serves a user attachment: what the
1007/// size of a body is measured with before an asset of it is uploaded, a UUID long.
1008const PREDICTED_ATTACHMENT_URL: &str =
1009 "https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000";
1010
1011/// Exact GraphQL query documents issued by this plugin.
1012///
1013/// Keeping the production documents here lets the pinned-schema test validate the same
1014/// bytes that are sent to GitHub, rather than a test-only copy which could drift
1015/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
1016/// field, and its guarded caller always supplies the complete existing option set with ids.
1017pub mod graphql {
1018 /// The board half of one item: the field values every document here reads it from.
1019 ///
1020 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
1021 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
1022 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
1023 /// *the same value*, because
1024 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
1025 /// one path. Three spellings of it is what would drift, so there is one.
1026 ///
1027 /// The `Status` option and this source's own origin text field are the whole of it. It
1028 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
1029 /// content, so it holds nothing the content's own `labels` do not already say, and it
1030 /// would sit a label connection two page sizes deep.
1031 macro_rules! board_item_values {
1032 () => {
1033 r#"fieldValues(first:$nestedFirst){nodes{
1034 ... on ProjectV2ItemFieldSingleSelectValue{name field{
1035 ... on ProjectV2SingleSelectField{id name options{id name}}
1036 }}
1037 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
1038 }pageInfo{hasNextPage}}"#
1039 };
1040 }
1041
1042 /// The variable every document reading an issue declares for the relationship events its
1043 /// change token counts, with its value as the default, so no caller binds it.
1044 ///
1045 /// A variable rather than a literal list because every argument a production document
1046 /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
1047 /// its default to the pinned enum. Its default is the six event types GitHub records a
1048 /// relationship added or removed with: a dependency on either end, as `blockedBy` and
1049 /// `blocking`, so one a person makes from the far end moves this end's count too; and a
1050 /// project membership on the task, as its parent, which GitHub records there whichever
1051 /// issue the move was made from. A sub-issue's events on its parent are not counted: a
1052 /// project carries no task token, and a copy writes nothing of a task its own sub-issues
1053 /// decide. [`super::change_token`] says why the count is in the token.
1054 macro_rules! relationship_events_variable {
1055 () => {
1056 ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
1057 };
1058 }
1059
1060 /// Everything this source reads about one issue, wherever it reaches that issue.
1061 ///
1062 /// A macro rather than a constant so the three documents below can `concat!` it: one
1063 /// spelling of these fields is what makes an issue read through the board-scoped
1064 /// search, through its own node id, and through its project's sub-issue relationship
1065 /// resolve to *the same* item, which is the whole of what
1066 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
1067 ///
1068 /// `projectItems` is what carries the board half of an issue: the board item's own id
1069 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
1070 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
1071 /// issue rather than on the board, which is what makes the cost of a read proportional
1072 /// to what was asked for instead of to the board's size.
1073 ///
1074 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
1075 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
1076 /// not on that page: a page here is where the search for the entry starts rather than
1077 /// where it ends.
1078 ///
1079 /// It does **not** select the board's `Labels` field value, and that is the whole of
1080 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
1081 /// a label connection there sits under `fieldValues` under `projectItems` under a page
1082 /// of issues, spending `$nestedFirst` twice down one path, and took
1083 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
1084 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
1085 /// above, and that connection is where every label this source reports comes from. No
1086 /// document in this module selects the board field any longer, [`BOARD`] included; the
1087 /// module documentation records why nothing it could have held is lost.
1088 macro_rules! board_issue {
1089 () => {
1090 concat!(
1091 r#" fragment BoardIssue on Issue{"#,
1092 board_issue_fields!(),
1093 "}"
1094 )
1095 };
1096 }
1097
1098 /// What [`board_issue!`] selects of an issue, written out where a document selects it
1099 /// inline rather than through the fragment.
1100 macro_rules! board_issue_fields {
1101 () => {
1102 concat!(
1103 r#"__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}
1104 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
1105 projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1106 "#,
1107 board_item_values!(),
1108 r#"}pageInfo{hasNextPage endCursor}}"#
1109 )
1110 };
1111 }
1112
1113 /// One page of a repository's issues, newest first, each with when it was created and its
1114 /// body: what a retried copy reads to find the issue its earlier attempt created, by the
1115 /// create key in that body's metadata slot — never through search, whose index lags.
1116 pub const ISSUES_BY_CREATION: &str = "query($owner:String!,$name:String!,$first:Int!,$after:String){repository(owner:$owner,name:$name){issues(first:$first,after:$after,orderBy:{field:CREATED_AT,direction:DESC}){nodes{id createdAt body}pageInfo{hasNextPage endCursor}}}}";
1117 /// Every issue of one board, found by a search scoped to that board.
1118 ///
1119 /// This is how the projects a board holds are listed, and it selects no `items`
1120 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
1121 /// container walked page by page, so nothing nested inside a board item is paid for.
1122 /// Which of the issues it returns is a project is then read off `parent` — GitHub
1123 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
1124 /// discriminator has to be applied to the field, which is a scalar on the issue and
1125 /// costs nothing.
1126 pub const SEARCH_ISSUES: &str = concat!(
1127 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1128 relationship_events_variable!(),
1129 r#"){
1130 search(query:$search,type:$type,first:$first,after:$after){
1131 pageInfo{hasNextPage endCursor}
1132 nodes{__typename ...BoardIssue}
1133 }
1134 }"#,
1135 board_issue!()
1136 );
1137
1138 /// What a dependency read selects of each far end: enough to say which kind of item it
1139 /// is, its body included for the kind marker.
1140 macro_rules! related_issue {
1141 () => {
1142 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
1143 };
1144 }
1145
1146 /// One issue by its own node id, which is what a qualified id names here — with what a
1147 /// write of it needs and the issue does not carry in `board_issue!`: the field
1148 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
1149 ///
1150 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
1151 /// answers a write made moments ago with the value from before it, and resolving a node
1152 /// id does not.
1153 ///
1154 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
1155 /// reads it by its own id, and with them that one read answers everything the write
1156 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
1157 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
1158 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
1159 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
1160 /// they sit under one item, and this read is still one point.
1161 pub const ISSUE: &str = concat!(
1162 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1163 relationship_events_variable!(),
1164 r#"){
1165 node(id:$id){__typename ...BoardIssue ... on Issue{
1166 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1167 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1168 ... on ProjectV2Field{__typename id name dataType}
1169 }pageInfo{hasNextPage}}}}}
1170 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1171 }}
1172 }"#,
1173 board_issue!(),
1174 related_issue!()
1175 );
1176
1177 /// One document by its own node id, as [`ISSUE`] reads one, with the project it is filed
1178 /// under and that project's members beside it: the project issue whole, and a page of its
1179 /// sub-issues, each as [`SUB_ISSUES`] carries one but with no page of what blocks it.
1180 ///
1181 /// What a guarded write of a document's metadata reads, and what reading a document by its
1182 /// id is: a write to a board the boundary screens is held to who may read the document,
1183 /// which is the project's and every member's classification as much as its own, and here
1184 /// the one request that holds the version the write is compared against answers all of it.
1185 /// Its members carry no edges, which nothing classifying them reads.
1186 pub const DOCUMENT_IN_PROJECT: &str = concat!(
1187 r#"query($id:ID!,$first:Int!,$memberFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1188 relationship_events_variable!(),
1189 r#"){
1190 node(id:$id){__typename ...BoardIssue ... on Issue{
1191 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
1192 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1193 ... on ProjectV2Field{__typename id name dataType}
1194 }pageInfo{hasNextPage}}}}}
1195 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
1196 project:parent{__typename ...BoardIssue
1197 subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}
1198 }
1199 }}
1200 }"#,
1201 board_issue!(),
1202 related_issue!()
1203 );
1204
1205 /// One project's tasks: the sub-issues of the issue that project is, each with a page of
1206 /// what blocks it.
1207 ///
1208 /// Paged at `$memberFirst` — [`MEMBER_PAGE_SIZE`](super::MEMBER_PAGE_SIZE), the most rows
1209 /// one point buys — rather than at `$first`, because every row carries six connections and
1210 /// a hundred-row page cost six points to list a project of ten.
1211 ///
1212 /// The work this costs is the project's own size. Nothing about it grows as the board
1213 /// gains projects, or as those projects gain tasks.
1214 ///
1215 /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1216 /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1217 /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1218 /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1219 /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1220 /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1221 /// It adds a connection under each issue of the page — one rate-limit point per page,
1222 /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1223 /// answer questions that never read an edge.
1224 ///
1225 /// **The project issue itself rides here too**, with the page of what blocks it, because
1226 /// the reader of a project's tasks reads the project next: a copy writing the project reads
1227 /// it and its edges before it writes them. Carried on the request that already names the
1228 /// project, the first page answers that read and the dependency read after it, at the
1229 /// state GitHub answered in this command, rather than spending one more read on an item
1230 /// the cache's token cannot vouch for whole. Each sub-issue is kept the same way, so a
1231 /// copy's read of a task this listing carried is answered by it rather than sent again.
1232 /// Only a copy's read-before-write takes them; every other read by id reads GitHub.
1233 pub const SUB_ISSUES: &str = concat!(
1234 r#"query($id:ID!,$memberFirst:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1235 relationship_events_variable!(),
1236 r#"){
1237 node(id:$id){__typename ...BoardIssue
1238 ... on Issue{blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1239 subIssues(first:$memberFirst,after:$after){
1240 pageInfo{hasNextPage endCursor}
1241 nodes{__typename ...BoardIssue ... on Issue{
1242 blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1243 }}
1244 }}}
1245 }"#,
1246 board_issue!(),
1247 related_issue!()
1248 );
1249
1250 /// Everything this source reads about one issue comment, wherever it reaches one.
1251 ///
1252 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1253 /// and a comment just edited are handed to one mapper, so they are selected by one
1254 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1255 /// longer exists, and `login` is the one member every kind of actor carries; `__typename`
1256 /// says whether it is a `Bot`, whose `login` GitHub's own pages spell with `[bot]` after it.
1257 macro_rules! issue_comment {
1258 () => {
1259 "id author{__typename login} createdAt updatedAt body url"
1260 };
1261 }
1262
1263 /// What a read of the board's own `items` selects of each item's content.
1264 ///
1265 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1266 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1267 /// content by one spelling.
1268 macro_rules! board_item_content {
1269 () => { board_item_content!("") };
1270 ($comments:expr) => {
1271 concat!(r#" content{
1272 ... 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}}"#, $comments, r#"}
1273 ... on PullRequest{__typename id}
1274 ... on DraftIssue{__typename id title body createdAt updatedAt}
1275 }"#)
1276 };
1277 }
1278
1279 macro_rules! board_listing {
1280 ($variables:expr, $comments:expr) => { concat!(
1281 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1282 relationship_events_variable!(), $variables,
1283 r#"){
1284 owner:repositoryOwner(login:$owner){
1285 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1286 }
1287 } fragment Board on ProjectV2 { id title
1288 fields(first:$nestedFirst){nodes{
1289 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1290 ... on ProjectV2Field{__typename id name dataType}
1291 }pageInfo{hasNextPage}}
1292 items(first:$first,after:$after){nodes{id updatedAt "#,
1293 board_item_values!(),
1294 board_item_content!($comments),
1295 r#"} pageInfo{hasNextPage endCursor}}
1296 }"#
1297 ) };
1298 }
1299
1300 /// Reads the board's fields and one page of its items.
1301 pub const BOARD: &str = board_listing!("", "");
1302
1303 /// A board page carrying each issue's first comment page in the same request.
1304 pub const BOARD_WITH_COMMENTS: &str = concat!(
1305 board_listing!(
1306 ",$commentFirst:Int!",
1307 concat!(
1308 "comments(first:$commentFirst){nodes{",
1309 issue_comment!(),
1310 "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}"
1311 )
1312 ),
1313 related_issue!()
1314 );
1315
1316 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1317 /// the board.
1318 ///
1319 /// **`originItems`** is the board's own items narrowed by its own field filter —
1320 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1321 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1322 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1323 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1324 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1325 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1326 /// fresh `addProjectV2ItemById` the way that connection does.
1327 ///
1328 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1329 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1330 /// indexes that comment, and the index catches up with a write in a second or two rather
1331 /// than in minutes, so it finds a carrier another process wrote that the first read is
1332 /// still behind on.
1333 ///
1334 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1335 /// — and resumes from its own cursor; a connection already walked to its end is resumed
1336 /// from its last cursor, which answers an empty page. Every candidate either read returns
1337 /// is confirmed against its own origin field before it is reported, so a token match of
1338 /// the search or anything else the filter admits never is.
1339 ///
1340 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1341 /// own whole reads counts this one among them.
1342 pub const ORIGIN_LOOKUP: &str = concat!(
1343 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1344 relationship_events_variable!(),
1345 r#"){
1346 originItems:repositoryOwner(login:$owner){
1347 ... on ProjectV2Owner{projectV2(number:$number){
1348 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1349 board_item_values!(),
1350 board_item_content!(),
1351 r#"} pageInfo{hasNextPage endCursor}}
1352 }}
1353 }
1354 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1355 pageInfo{hasNextPage endCursor}
1356 nodes{__typename ...BoardIssue}
1357 }
1358 }"#,
1359 board_issue!()
1360 );
1361
1362 /// Every closed item of the board, by the board's own field filter — what
1363 /// `task status reconcile` reads, and nothing else of the board.
1364 ///
1365 /// **`ProjectV2.items(query:)` narrowed by `$filter`**, which this source binds to
1366 /// `is:closed` alone, so a board whose open items are most of it is not read whole to find
1367 /// the closed ones. It is narrowed rather than trusted: every item it answers is confirmed
1368 /// closed, with a reason and a card that contradict each other, from the item node it
1369 /// already carries, so a filter that also let open items through would cost reads and
1370 /// write nothing wrong.
1371 ///
1372 /// The board's own id and field definitions ride along, as [`BOARD`] carries them, so the
1373 /// option writes that follow need no read of the board. The root is aliased `closedItems`
1374 /// rather than `owner`, so nothing counting the board's own whole reads counts this one
1375 /// among them.
1376 pub const CLOSED_ITEMS: &str = concat!(
1377 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$filter:String!,$nestedFirst:Int!,$duplicates:Boolean!"#,
1378 relationship_events_variable!(),
1379 r#"){
1380 closedItems:repositoryOwner(login:$owner){
1381 ... on ProjectV2Owner{projectV2(number:$number){id
1382 fields(first:$nestedFirst){nodes{
1383 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1384 ... on ProjectV2Field{__typename id name dataType}
1385 }pageInfo{hasNextPage}}
1386 items(first:$first,after:$after,query:$filter){nodes{id updatedAt "#,
1387 board_item_values!(),
1388 board_item_content!(),
1389 r#"} pageInfo{hasNextPage endCursor}}
1390 }}
1391 }
1392 }"#
1393 );
1394
1395 /// One alias of [`ISSUES_BY_REFERENCE`]: the issue `$owner<n>/$name<n>#$number<n>` names.
1396 macro_rules! reference_alias {
1397 ($n:literal) => {
1398 concat!(
1399 "\n r",
1400 stringify!($n),
1401 ":repository(owner:$owner",
1402 stringify!($n),
1403 ",name:$name",
1404 stringify!($n),
1405 "){issue(number:$number",
1406 stringify!($n),
1407 "){...HeldIssue}}"
1408 )
1409 };
1410 }
1411
1412 /// [`REFERENCE_BATCH`](super::REFERENCE_BATCH) issues at once, each by its repository and
1413 /// number — the one read `task status complete` makes of a source, and nothing else of
1414 /// the board: no `items`, no search and no project read.
1415 ///
1416 /// **Its own fragment, lighter than `board_issue!`, and that is what makes fifty
1417 /// references one point.** It selects no labels, and of each board membership only the
1418 /// `Status` value, by name, with `fieldValueByName` — so each alias asks GitHub for one
1419 /// connection, its page of memberships, where the whole fragment asks for five. What a
1420 /// completion writes is a card and, at most, the issue's state, so the labels and the other
1421 /// field values it leaves out are never what it needs; an item read this way is held for
1422 /// that write alone and never answers another read. An issue whose entry for this board is
1423 /// past its first page of memberships is walked with [`ISSUE_BOARD_ITEMS`], exactly as
1424 /// every other read of an issue recovers one.
1425 ///
1426 /// Fixed-size, as [`ISSUE_DETAILS`] is, so there is one document to price: a batch of
1427 /// fewer references binds the slots it has none for to its last one.
1428 pub const ISSUES_BY_REFERENCE: &str = concat!(
1429 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!"#,
1430 relationship_events_variable!(),
1431 r#"){"#,
1432 reference_alias!(0),
1433 reference_alias!(1),
1434 reference_alias!(2),
1435 reference_alias!(3),
1436 reference_alias!(4),
1437 reference_alias!(5),
1438 reference_alias!(6),
1439 reference_alias!(7),
1440 reference_alias!(8),
1441 reference_alias!(9),
1442 reference_alias!(10),
1443 reference_alias!(11),
1444 reference_alias!(12),
1445 reference_alias!(13),
1446 reference_alias!(14),
1447 reference_alias!(15),
1448 reference_alias!(16),
1449 reference_alias!(17),
1450 reference_alias!(18),
1451 reference_alias!(19),
1452 reference_alias!(20),
1453 reference_alias!(21),
1454 reference_alias!(22),
1455 reference_alias!(23),
1456 reference_alias!(24),
1457 reference_alias!(25),
1458 reference_alias!(26),
1459 reference_alias!(27),
1460 reference_alias!(28),
1461 reference_alias!(29),
1462 reference_alias!(30),
1463 reference_alias!(31),
1464 reference_alias!(32),
1465 reference_alias!(33),
1466 reference_alias!(34),
1467 reference_alias!(35),
1468 reference_alias!(36),
1469 reference_alias!(37),
1470 reference_alias!(38),
1471 reference_alias!(39),
1472 reference_alias!(40),
1473 reference_alias!(41),
1474 reference_alias!(42),
1475 reference_alias!(43),
1476 reference_alias!(44),
1477 reference_alias!(45),
1478 reference_alias!(46),
1479 reference_alias!(47),
1480 reference_alias!(48),
1481 reference_alias!(49),
1482 "\n }",
1483 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}
1484 projectItems(first:$boardItems){nodes{id updatedAt project{id number}
1485 status:fieldValueByName(name:$statusField){... on ProjectV2ItemFieldSingleSelectValue{name field{
1486 ... on ProjectV2SingleSelectField{id name options{id name}}
1487 }}}
1488 }pageInfo{hasNextPage endCursor}}}"#
1489 );
1490
1491 /// The board's own id and field definitions, and not one of its items.
1492 ///
1493 /// What a write needs of the board when the item it writes does not say: the id a field
1494 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1495 /// origin fields. It selects no `items`, so what it costs is the board's field list
1496 /// however many items the board holds — and it decides nothing about which items those
1497 /// are, which is the question a read of one item by its own id answers instead.
1498 ///
1499 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1500 /// board's item reads by their root counts this one among them.
1501 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1502 boardFields:repositoryOwner(login:$owner){
1503 ... on ProjectV2Owner{projectV2(number:$number){id
1504 fields(first:$nestedFirst){nodes{
1505 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1506 ... on ProjectV2Field{__typename id name dataType}
1507 }pageInfo{hasNextPage}}
1508 }}
1509 }
1510 }"#;
1511
1512 /// One board draft by its own node id, with the board item it sits in.
1513 ///
1514 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1515 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1516 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1517 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1518 /// board listing hands it to, and nothing has to list the board to find one.
1519 pub const DRAFT: &str = concat!(
1520 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1521 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1522 projectV2Items(first:$boardItems){nodes{id project{id number}
1523 "#,
1524 board_item_values!(),
1525 r#"}pageInfo{hasNextPage endCursor}}}}
1526 }"#
1527 );
1528
1529 /// One issue's board memberships alone, walked past the page a read of it carried.
1530 ///
1531 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1532 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1533 /// boards than that page holds may have this board's entry past its end. This asks that
1534 /// one issue for its memberships and nothing else — the caller already holds the issue —
1535 /// so an answer of "this board does not hold it" is only ever given about a connection
1536 /// read to exhaustion.
1537 ///
1538 /// It selects the board item's id, its project number and the same
1539 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1540 /// very same resolver: an issue recovered this way reports the same title, the same
1541 /// status, the same labels and the same qualified id as one whose entry was on the
1542 /// page.
1543 ///
1544 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1545 /// multiplies through it and the membership connection can be walked at
1546 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1547 /// further request for any issue a person really keeps.
1548 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1549 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1550 node(id:$id){
1551 ... on Issue{projectItems(first:$first,after:$after){
1552 nodes{id updatedAt project{id number}
1553 "#,
1554 board_item_values!(),
1555 r#"}
1556 pageInfo{hasNextPage endCursor}}}
1557 }
1558 }"#
1559 );
1560 /// Whether the board's Project is public — half of what decides whether a write here can
1561 /// be read by anybody. Needs the `read:project` scope.
1562 pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1563 /// Resolves the configured repository's node id, which creating an issue requires.
1564 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1565 /// What creating an issue needs and has not read yet: the board's own id and field
1566 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1567 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1568 ///
1569 /// Sent at the point a create knows which repository it is for, when neither half is
1570 /// already known to this process; a create needing only one of them sends that one's own
1571 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1572 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1573 /// status.
1574 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1575 boardFields:repositoryOwner(login:$owner){
1576 ... on ProjectV2Owner{projectV2(number:$number){id
1577 fields(first:$nestedFirst){nodes{
1578 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1579 ... on ProjectV2Field{__typename id name dataType}
1580 }pageInfo{hasNextPage}}
1581 }}
1582 }
1583 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1584 }"#;
1585 /// Reads both dependency directions for one issue, with each far end's own kind — and
1586 /// the issue's own body, which is where an edge to another source is recorded, so that
1587 /// half of a dependency read needs no second read of the issue or of the board.
1588 pub const ISSUE_DEPENDENCIES: &str = concat!(
1589 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1590 ... on Issue{body
1591 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1592 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1593 }}}"#,
1594 related_issue!()
1595 );
1596 /// What one item depends on: [`ISSUE_DEPENDENCIES`] narrowed to `blockedBy`, so a
1597 /// dependency read asks GitHub for the one direction it answers. The issue's `body` rides
1598 /// along because its metadata slot is where an edge to another source is recorded — and a
1599 /// board draft's `body` too, with the one board item GitHub links a draft to and, aliased
1600 /// `board`, this board's own id, so a draft's recorded edges are read off this same answer
1601 /// and only when that item is this board's: no draft read and no board read follow it.
1602 ///
1603 /// The draft's board item is asked for under a root of its own, aliased `draft`, rather
1604 /// than beside its `body` under `node`. GitHub counts the type-conditional fragments of
1605 /// one selection as the largest of them — `blockedBy`'s 100 alone, where this workspace's
1606 /// calculation adds the draft's 3 — while separate roots are summed by both, so this is
1607 /// the spelling whose node count GitHub and the calculation agree on. Both price it at
1608 /// one point; the credentialed lane's reconciliation is what holds the two to that.
1609 pub const ISSUE_DEPENDS_ON: &str = concat!(
1610 r#"query($id:ID!,$first:Int!,$after:String,$owner:String!,$number:Int!,$boardItems:Int!){
1611 node(id:$id){__typename
1612 ... on Issue{body
1613 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1614 }
1615 ... on DraftIssue{id body}}
1616 draft:node(id:$id){... on DraftIssue{projectV2Items(first:$boardItems){nodes{project{id number}}pageInfo{hasNextPage}}}}
1617 board:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){id}}}}"#,
1618 related_issue!()
1619 );
1620 /// What depends on one issue: [`ISSUE_DEPENDENCIES`] with its `blocking` alone. No `body`:
1621 /// the reverse of a recorded edge is derived from the far end, never written down here.
1622 pub const ISSUE_DEPENDED_ON_BY: &str = concat!(
1623 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1624 ... on Issue{
1625 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1626 }}}"#,
1627 related_issue!()
1628 );
1629 /// Creates one issue. Outside a copy it is created on no board, and [`ADD_TO_BOARD`] files
1630 /// it; inside one, its input names the board in `projectV2Ids` and its project in
1631 /// `parentIssueId`, so this one request files it under both — see
1632 /// [`GitHubProjectsSource::create_filed_issue`](super::GitHubProjectsSource).
1633 pub const CREATE_ISSUE: &str =
1634 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1635 /// Puts an existing issue on the configured board.
1636 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1637 /// Updates an issue's visible fields and its open or closed state in one call.
1638 pub const UPDATE_ISSUE: &str =
1639 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1640 /// Updates an existing draft's user-visible fields.
1641 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1642 /// Updates a text or single-select value on one project item.
1643 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}}}}}}}}"#;
1644 /// Writes up to six board fields and clears up to three in one ordered mutation: every
1645 /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1646 /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1647 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}}}"#;
1648 /// Clears one project item's value of one field, which is what a `none` priority is.
1649 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}}}}}}}}"#;
1650 /// Creates one field: a single-select one with its options, or a text one for a projected
1651 /// metadata value. Only the guarded field setup may use this document, and only for a
1652 /// field the board lacks.
1653 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1654 /// Replaces a single-select field's options. Only the guarded field setup — the
1655 /// `status-options` and `fields` operations — may use this document, because GitHub
1656 /// treats the input as the complete option list.
1657 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1658 /// A fresh snapshot of the Status field and every board item's assignment.
1659 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}}}}}}"#;
1660 /// Files one issue under another as a sub-issue, which is what project membership is.
1661 pub const ADD_SUB_ISSUE: &str =
1662 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1663 /// Takes one issue back out of its parent.
1664 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1665 /// Adds GitHub's native issue blocked-by relationship.
1666 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1667 /// One origin of [`ORIGIN_LOOKUPS`]: [`ORIGIN_LOOKUP`]'s two connections for the origin the
1668 /// `$filter<n>` and `$search<n>` of slot `<n>` name, sent only while `$ask<n>` holds.
1669 macro_rules! origin_lookup_alias {
1670 ($n:literal) => {
1671 concat!(
1672 "\n o",
1673 stringify!($n),
1674 ":repositoryOwner(login:$owner) @include(if:$ask",
1675 stringify!($n),
1676 "){... on ProjectV2Owner{projectV2(number:$number){items(first:$lookupFirst,query:$filter",
1677 stringify!($n),
1678 "){nodes{id updatedAt ",
1679 board_item_values!(),
1680 board_item_content!(),
1681 "} pageInfo{hasNextPage endCursor}}}}}\n s",
1682 stringify!($n),
1683 ":search(query:$search",
1684 stringify!($n),
1685 ",type:$type,first:$lookupFirst) @include(if:$ask",
1686 stringify!($n),
1687 "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}"
1688 )
1689 };
1690 }
1691
1692 /// [`ORIGIN_LOOKUPS`], with `$extra_variables` declared after its own and `$extra_fields`
1693 /// selected beside them: one spelling of the batched lookup for the documents that send it.
1694 macro_rules! origin_lookups {
1695 ($extra_variables:literal, $extra_fields:literal) => {
1696 concat!(
1697 r#"query($owner:String!,$number:Int!,$type:SearchType!,$lookupFirst:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!,$repositoryOwner:String!,$repositoryName:String!,$filter0:String!,$filter1:String!,$filter2:String!,$filter3:String!,$filter4:String!,$filter5:String!,$filter6:String!,$filter7:String!,$filter8:String!,$filter9:String!,$search0:String!,$search1:String!,$search2:String!,$search3:String!,$search4:String!,$search5:String!,$search6:String!,$search7:String!,$search8:String!,$search9:String!,$ask0:Boolean!,$ask1:Boolean!,$ask2:Boolean!,$ask3:Boolean!,$ask4:Boolean!,$ask5:Boolean!,$ask6:Boolean!,$ask7:Boolean!,$ask8:Boolean!,$ask9:Boolean!"#,
1698 relationship_events_variable!(),
1699 $extra_variables,
1700 r#"){
1701 boardFields:repositoryOwner(login:$owner){
1702 ... on ProjectV2Owner{projectV2(number:$number){id
1703 fields(first:$nestedFirst){nodes{
1704 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1705 ... on ProjectV2Field{__typename id name dataType}
1706 }pageInfo{hasNextPage}}
1707 }}
1708 }
1709 repository(owner:$repositoryOwner,name:$repositoryName){id databaseId nameWithOwner}"#,
1710 $extra_fields,
1711 origin_lookup_alias!(0),
1712 origin_lookup_alias!(1),
1713 origin_lookup_alias!(2),
1714 origin_lookup_alias!(3),
1715 origin_lookup_alias!(4),
1716 origin_lookup_alias!(5),
1717 origin_lookup_alias!(6),
1718 origin_lookup_alias!(7),
1719 origin_lookup_alias!(8),
1720 origin_lookup_alias!(9),
1721 "\n }",
1722 board_issue!()
1723 )
1724 };
1725 }
1726
1727 /// The first page of [`ORIGIN_LOOKUP`] for up to [`ORIGIN_BATCH`](super::ORIGIN_BATCH)
1728 /// origins a copy is about to look for, and everything creating an item then reads —
1729 /// the board's own id and field definitions, as [`CREATION_CONTEXT`] reads them, and the node
1730 /// id and the numeric id of the repository an issue is created in — in one request.
1731 ///
1732 /// What a copy reads before it writes anything, so that finding none of its records on the
1733 /// board and creating every one costs this read and the writes. An origin whose page says
1734 /// there is more is left to its own [`ORIGIN_LOOKUP`]. The repository's numeric id is what an
1735 /// image upload names, so a copy carrying assets needs no `GET /repos/{owner}/{repo}` either.
1736 pub const ORIGIN_LOOKUPS: &str = origin_lookups!("", "");
1737
1738 /// [`ORIGIN_LOOKUPS`], with a page of the members of the project `$project` beside it, each
1739 /// trimmed to what classifying it reads — its id, its parent, its body and its repository,
1740 /// and no connection under it.
1741 ///
1742 /// What a document copy sends where the public boundary screens its write: the document is
1743 /// held to who may read its project, which is its members' classification as much as the
1744 /// project's own, and this request carries both the project — among the origins it asks —
1745 /// and its members. GitHub's own dry run prices it at the one point [`ORIGIN_LOOKUPS`]
1746 /// costs alone (`docs/measurements/board-copy-gaps-2026-10-24/coalesced-members-dry-run`).
1747 pub const ORIGIN_LOOKUPS_WITH_MEMBERS: &str = origin_lookups!(
1748 ",$project:ID!,$memberFirst:Int!",
1749 "\n members:node(id:$project){... on Issue{subIssues(first:$memberFirst){pageInfo{hasNextPage endCursor} nodes{id body parent{id} repository{nameWithOwner}}}}}"
1750 );
1751
1752 /// One slot `<n>` of a copy's aliased writes: `$<prefix><n>` is its input and `$send<n>`
1753 /// whether it is sent.
1754 macro_rules! write_slot {
1755 ($prefix:literal, $field:literal, $selection:literal, $n:literal) => {
1756 concat!(
1757 " ",
1758 $prefix,
1759 stringify!($n),
1760 ":",
1761 $field,
1762 "(input:$",
1763 $prefix,
1764 stringify!($n),
1765 ") @include(if:$send",
1766 stringify!($n),
1767 "){",
1768 $selection,
1769 "}"
1770 )
1771 };
1772 }
1773
1774 /// The variables of a copy's aliased writes: eleven inputs of `$type` named `$<prefix><n>`,
1775 /// and eleven `$send<n>`.
1776 macro_rules! write_variables {
1777 ($prefix:literal, $type:literal) => {
1778 concat!(
1779 "mutation(",
1780 "$",
1781 $prefix,
1782 "0:",
1783 $type,
1784 "!,$",
1785 $prefix,
1786 "1:",
1787 $type,
1788 "!,$",
1789 $prefix,
1790 "2:",
1791 $type,
1792 "!,$",
1793 $prefix,
1794 "3:",
1795 $type,
1796 "!,$",
1797 $prefix,
1798 "4:",
1799 $type,
1800 "!,$",
1801 $prefix,
1802 "5:",
1803 $type,
1804 "!,$",
1805 $prefix,
1806 "6:",
1807 $type,
1808 "!,$",
1809 $prefix,
1810 "7:",
1811 $type,
1812 "!,$",
1813 $prefix,
1814 "8:",
1815 $type,
1816 "!,$",
1817 $prefix,
1818 "9:",
1819 $type,
1820 "!,$",
1821 $prefix,
1822 "10:",
1823 $type,
1824 "!,",
1825 "$send0:Boolean!,$send1:Boolean!,$send2:Boolean!,$send3:Boolean!,$send4:Boolean!,",
1826 "$send5:Boolean!,$send6:Boolean!,$send7:Boolean!,$send8:Boolean!,$send9:Boolean!,",
1827 "$send10:Boolean!){"
1828 )
1829 };
1830 }
1831
1832 /// A copy's aliased writes of one kind: [`MUTATION_BATCH`](super::MUTATION_BATCH) slots of
1833 /// `$field`, each sent only while its `$send<n>` holds.
1834 macro_rules! write_batch {
1835 ($prefix:literal, $type:literal, $field:literal, $selection:literal) => {
1836 concat!(
1837 write_variables!($prefix, $type),
1838 write_slot!($prefix, $field, $selection, 0),
1839 write_slot!($prefix, $field, $selection, 1),
1840 write_slot!($prefix, $field, $selection, 2),
1841 write_slot!($prefix, $field, $selection, 3),
1842 write_slot!($prefix, $field, $selection, 4),
1843 write_slot!($prefix, $field, $selection, 5),
1844 write_slot!($prefix, $field, $selection, 6),
1845 write_slot!($prefix, $field, $selection, 7),
1846 write_slot!($prefix, $field, $selection, 8),
1847 write_slot!($prefix, $field, $selection, 9),
1848 write_slot!($prefix, $field, $selection, 10),
1849 "}"
1850 )
1851 };
1852 }
1853
1854 /// Creates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues in one request, each filed on
1855 /// the board and under its project by the same `createIssue` — its `projectV2Ids` and
1856 /// `parentIssueId` — as [`CREATE_ISSUE`] creates one.
1857 pub const CREATE_ISSUES: &str = write_batch!(
1858 "create",
1859 "CreateIssueInput",
1860 "createIssue",
1861 "issue{id number url}"
1862 );
1863
1864 /// One issue of [`CREATED_BOARD_ITEMS`]: the board items the issue `$id<n>` names is on, with
1865 /// the `Status` value of each, read only while `$read<n>` holds.
1866 macro_rules! created_item_alias {
1867 ($n:literal) => {
1868 concat!(
1869 "\n i",
1870 stringify!($n),
1871 ":node(id:$id",
1872 stringify!($n),
1873 ") @include(if:$read",
1874 stringify!($n),
1875 "){... on Issue{id projectItems(first:$boardItems){nodes{id project{id} status:fieldValueByName(name:$statusName){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}"
1876 )
1877 };
1878 }
1879
1880 /// The board item of each issue a copy created, up to
1881 /// [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH) at a time, with its `Status` as the board
1882 /// set it: what `createIssue`'s `projectV2Ids` files the issue as, which GitHub adds after
1883 /// answering the create, so its id comes from a read made once every create has been sent.
1884 pub const CREATED_BOARD_ITEMS: &str = concat!(
1885 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!,$read0:Boolean!,$read1:Boolean!,$read2:Boolean!,$read3:Boolean!,$read4:Boolean!,$read5:Boolean!,$read6:Boolean!,$read7:Boolean!,$read8:Boolean!,$read9:Boolean!,$read10:Boolean!,$read11:Boolean!,$read12:Boolean!,$read13:Boolean!,$read14:Boolean!,$read15:Boolean!,$read16:Boolean!,$read17:Boolean!,$read18:Boolean!,$read19:Boolean!,$read20:Boolean!,$boardItems:Int!,$statusName:String!){"#,
1886 created_item_alias!(0),
1887 created_item_alias!(1),
1888 created_item_alias!(2),
1889 created_item_alias!(3),
1890 created_item_alias!(4),
1891 created_item_alias!(5),
1892 created_item_alias!(6),
1893 created_item_alias!(7),
1894 created_item_alias!(8),
1895 created_item_alias!(9),
1896 created_item_alias!(10),
1897 created_item_alias!(11),
1898 created_item_alias!(12),
1899 created_item_alias!(13),
1900 created_item_alias!(14),
1901 created_item_alias!(15),
1902 created_item_alias!(16),
1903 created_item_alias!(17),
1904 created_item_alias!(18),
1905 created_item_alias!(19),
1906 created_item_alias!(20),
1907 "\n }"
1908 );
1909
1910 /// The variables and the aliases of [`WRITE_BATCH`](super::WRITE_BATCH) slots: `$<prefix><n>`
1911 /// is a slot's input and `$<send><n>` whether it is sent, for the slot numbers given.
1912 macro_rules! wide_variables {
1913 ($prefix:literal, $type:literal, $send:literal; $($n:literal)+) => {
1914 concat!($("$", $prefix, stringify!($n), ":", $type, "!,$", $send, stringify!($n), ":Boolean!,",)+)
1915 };
1916 }
1917 macro_rules! wide_slots {
1918 ($prefix:literal, $field:literal, $selection:literal, $send:literal; $($n:literal)+) => {
1919 concat!($(
1920 " ", $prefix, stringify!($n), ":", $field, "(input:$", $prefix, stringify!($n),
1921 ") @include(if:$", $send, stringify!($n), "){", $selection, "}",
1922 )+)
1923 };
1924 }
1925
1926 /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) board field values a copy writes on the items it
1927 /// created, each on its own item, in one ordered request.
1928 pub const WRITE_FIELD_VALUES: &str = concat!(
1929 "mutation(",
1930 wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1931 "){",
1932 wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1933 "}"
1934 );
1935
1936 /// A copy's board field values and its blocked-by edges together, up to
1937 /// [`WRITE_BATCH`](super::WRITE_BATCH) of the two kinds between them, in one ordered request:
1938 /// every field value its `$sendField<n>` sends, then every edge its `$sendBlock<n>` sends.
1939 /// What a copy sends when one request of its writes holds both kinds; one holding a single
1940 /// kind is [`WRITE_FIELD_VALUES`] or [`ADD_BLOCKED_BYS`].
1941 pub const WRITE_FIELDS_AND_EDGES: &str = concat!(
1942 "mutation(",
1943 wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1944 wide_variables!("block", "AddBlockedByInput", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1945 "){",
1946 wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1947 wide_slots!("block", "addBlockedBy", "issue{id} blockingIssue{id}", "sendBlock"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1948 "}"
1949 );
1950
1951 /// Closes up to [`MUTATION_BATCH`](super::MUTATION_BATCH) issues a copy created at a closing
1952 /// status, each with its own `stateInput` and nothing else, once its option is selected.
1953 pub const CLOSE_ISSUES: &str =
1954 write_batch!("close", "UpdateIssueInput", "updateIssue", "issue{id}");
1955
1956 /// Updates up to [`MUTATION_BATCH`](super::MUTATION_BATCH) existing issues a copy rewrites,
1957 /// each with the members its own [`UPDATE_ISSUE`] would have sent, in one request.
1958 pub const UPDATE_ISSUES: &str =
1959 write_batch!("update", "UpdateIssueInput", "updateIssue", "issue{id}");
1960
1961 /// Up to [`WRITE_BATCH`](super::WRITE_BATCH) native blocked-by relationships a copy adds to the
1962 /// issues it created, in one request.
1963 pub const ADD_BLOCKED_BYS: &str = concat!(
1964 "mutation(",
1965 wide_variables!("block", "AddBlockedByInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1966 "){",
1967 wide_slots!("block", "addBlockedBy", "issue{id} blockingIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
1968 "}"
1969 );
1970
1971 /// Removes one native issue blocked-by relationship.
1972 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1973 /// Deletes one issue, which takes its board item with it.
1974 ///
1975 /// The engine sends this in one situation only: undoing a copy that could not finish,
1976 /// over the items that same copy created. Deleting the issue removes the board item
1977 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1978 pub const DELETE_ISSUE: &str =
1979 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1980 /// Deletes up to [`DELETE_BATCH`](super::DELETE_BATCH) issues in one request, each as
1981 /// [`DELETE_ISSUE`] deletes one: what a write that created several issues and was refused
1982 /// part way sends to take them back.
1983 pub const DELETE_ISSUES: &str = concat!(
1984 "mutation(",
1985 wide_variables!("delete", "DeleteIssueInput", "send"; 0 1 2 3 4),
1986 "){",
1987 wide_slots!("delete", "deleteIssue", "repository{id}", "send"; 0 1 2 3 4),
1988 "}"
1989 );
1990
1991 /// One task's comments: a page of its issue's own `comments` connection.
1992 ///
1993 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1994 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1995 /// list every time somebody edited it; left unordered the connection answers in the order
1996 /// the comments were written, which is the order GitHub documents for the same collection
1997 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1998 /// node count and the caller's own page size is pushed straight down.
1999 pub const ISSUE_COMMENTS: &str = concat!(
2000 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
2001 issue_comment!(),
2002 r#"}pageInfo{hasNextPage endCursor}}}}}"#
2003 );
2004 /// One issue by its own node id, with a page of its comments: what `task show` and a
2005 /// comment listing read, in one request.
2006 ///
2007 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
2008 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
2009 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
2010 /// connection there would multiply through both of those documents' price, and neither
2011 /// needs one.
2012 pub const ISSUE_DETAIL: &str = concat!(
2013 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2014 relationship_events_variable!(),
2015 r#"){
2016 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
2017 issue_comment!(),
2018 r#"}pageInfo{hasNextPage endCursor}}}}
2019 }"#,
2020 board_issue!()
2021 );
2022
2023 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
2024 /// page of its comments when `$comments` asks for them.
2025 macro_rules! issue_details_alias {
2026 ($n:literal) => {
2027 concat!(
2028 "\n i",
2029 stringify!($n),
2030 ":node(id:$id",
2031 stringify!($n),
2032 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
2033 issue_comment!(),
2034 "}pageInfo{hasNextPage endCursor}}}}"
2035 )
2036 };
2037 }
2038
2039 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
2040 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
2041 ///
2042 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
2043 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
2044 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
2045 /// neither — so every connection under it would be priced at nothing and the pin in
2046 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
2047 /// one-item read the model already prices, so the batch costs what its aliases cost.
2048 ///
2049 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
2050 /// slots it has no item for to the last item it does, and reads that item again; the
2051 /// price is the document's, whatever its variables, so a short batch costs what a full
2052 /// one does and nothing more.
2053 pub const ISSUE_DETAILS: &str = concat!(
2054 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!"#,
2055 relationship_events_variable!(),
2056 r#"){"#,
2057 issue_details_alias!(0),
2058 issue_details_alias!(1),
2059 issue_details_alias!(2),
2060 issue_details_alias!(3),
2061 issue_details_alias!(4),
2062 issue_details_alias!(5),
2063 issue_details_alias!(6),
2064 issue_details_alias!(7),
2065 issue_details_alias!(8),
2066 issue_details_alias!(9),
2067 issue_details_alias!(10),
2068 issue_details_alias!(11),
2069 issue_details_alias!(12),
2070 issue_details_alias!(13),
2071 issue_details_alias!(14),
2072 issue_details_alias!(15),
2073 issue_details_alias!(16),
2074 issue_details_alias!(17),
2075 issue_details_alias!(18),
2076 issue_details_alias!(19),
2077 issue_details_alias!(20),
2078 issue_details_alias!(21),
2079 issue_details_alias!(22),
2080 issue_details_alias!(23),
2081 "\n }",
2082 board_issue!()
2083 );
2084
2085 /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
2086 /// page of its comments and the issues it is blocked by.
2087 macro_rules! issue_copy_details_alias {
2088 ($n:literal) => {
2089 concat!(
2090 "\n i",
2091 stringify!($n),
2092 ":node(id:$id",
2093 stringify!($n),
2094 "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
2095 issue_comment!(),
2096 "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
2097 )
2098 };
2099 }
2100
2101 /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
2102 /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
2103 /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
2104 /// at a time, so a copy of a changed task costs this read and nothing else.
2105 ///
2106 /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
2107 /// what `task show-many` is priced by and which reads no edge.
2108 ///
2109 /// `children`, read only while `$children` holds, is which issues the issue `$project` has
2110 /// as sub-issues — each one's id and title, the page `$nestedFirst` allows — so that a
2111 /// project copy, which reads its project and every task at once, knows from this same read
2112 /// which tasks the project holds at the destination, and its orphan check is answered by it
2113 /// rather than by a listing of the project.
2114 pub const ISSUE_COPY_DETAILS: &str = concat!(
2115 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!,$project:ID!,$children:Boolean!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2116 relationship_events_variable!(),
2117 r#"){"#,
2118 issue_copy_details_alias!(0),
2119 issue_copy_details_alias!(1),
2120 issue_copy_details_alias!(2),
2121 issue_copy_details_alias!(3),
2122 issue_copy_details_alias!(4),
2123 issue_copy_details_alias!(5),
2124 issue_copy_details_alias!(6),
2125 issue_copy_details_alias!(7),
2126 issue_copy_details_alias!(8),
2127 issue_copy_details_alias!(9),
2128 issue_copy_details_alias!(10),
2129 issue_copy_details_alias!(11),
2130 issue_copy_details_alias!(12),
2131 issue_copy_details_alias!(13),
2132 issue_copy_details_alias!(14),
2133 issue_copy_details_alias!(15),
2134 issue_copy_details_alias!(16),
2135 issue_copy_details_alias!(17),
2136 issue_copy_details_alias!(18),
2137 issue_copy_details_alias!(19),
2138 issue_copy_details_alias!(20),
2139 "\n children:node(id:$project) @include(if:$children){... on Issue{id subIssues(first:$nestedFirst){nodes{id title} pageInfo{hasNextPage}}}}",
2140 "\n }",
2141 board_issue!(),
2142 related_issue!()
2143 );
2144
2145 /// Which issue one comment is on, read before that comment is edited or removed.
2146 ///
2147 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
2148 /// comment id given against the wrong task would change a comment on another issue.
2149 pub const COMMENT_ISSUE: &str =
2150 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
2151 /// Adds one comment to an issue, signed as the account the token belongs to.
2152 pub const ADD_COMMENT: &str = concat!(
2153 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
2154 issue_comment!(),
2155 r#"}}}}"#
2156 );
2157 /// Replaces the body of one issue comment.
2158 pub const UPDATE_COMMENT: &str = concat!(
2159 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
2160 issue_comment!(),
2161 r#"}}}"#
2162 );
2163 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
2164 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
2165
2166 /// The variables of the search slots `<n>` of [`SEARCH_BATCH`]: its search, where it
2167 /// resumes, its page size and whether it is asked.
2168 macro_rules! search_batch_variables {
2169 ($($n:literal)+) => {
2170 concat!($(
2171 ",$search", stringify!($n), ":String!,$after", stringify!($n),
2172 ":String,$first", stringify!($n), ":Int!,$ask", stringify!($n), ":Boolean!",
2173 )+)
2174 };
2175 }
2176 /// The search slots `<n>` of [`SEARCH_BATCH`]: [`SEARCH_ISSUES`]'s one connection, aliased
2177 /// `s<n>`, sent only while `$ask<n>` holds.
2178 macro_rules! search_batch_slots {
2179 ($($n:literal)+) => {
2180 concat!($(
2181 "\n s", stringify!($n), ":search(query:$search", stringify!($n),
2182 ",type:$type,first:$first", stringify!($n), ",after:$after", stringify!($n),
2183 ") @include(if:$ask", stringify!($n),
2184 "){pageInfo{hasNextPage endCursor} nodes{__typename ...BoardIssue}}",
2185 )+)
2186 };
2187 }
2188 /// The variables of the origin slots `<n>` of [`SEARCH_BATCH`]: the board's field filter
2189 /// naming the origin, where its items resume, their page size and whether it is asked.
2190 macro_rules! origin_batch_variables {
2191 ($($n:literal)+) => {
2192 concat!($(
2193 ",$filter", stringify!($n), ":String!,$itemsAfter", stringify!($n),
2194 ":String,$itemsFirst", stringify!($n), ":Int!,$origin", stringify!($n),
2195 ":Boolean!",
2196 )+)
2197 };
2198 }
2199 /// The origin slots `<n>` of [`SEARCH_BATCH`]: [`ORIGIN_LOOKUP`]'s board half, the board's
2200 /// own items through its field filter, aliased `o<n>`, sent only while `$origin<n>` holds.
2201 macro_rules! origin_batch_slots {
2202 ($($n:literal)+) => {
2203 concat!($(
2204 "\n o", stringify!($n), ":repositoryOwner(login:$owner) @include(if:$origin",
2205 stringify!($n),
2206 "){... on ProjectV2Owner{projectV2(number:$number){items(first:$itemsFirst",
2207 stringify!($n), ",after:$itemsAfter", stringify!($n), ",query:$filter",
2208 stringify!($n), "){nodes{id updatedAt ", board_item_values!(),
2209 board_item_content!(), "} pageInfo{hasNextPage endCursor}}}}}",
2210 )+)
2211 };
2212 }
2213
2214 /// Up to [`SEARCH_BATCH`](super::SEARCH_BATCH) board-scoped issue searches in one request,
2215 /// each [`SEARCH_ISSUES`]'s connection in an aliased `s<n>` field with its own query, page
2216 /// size and cursor, and beside them up to
2217 /// [`SEARCH_BATCH_ORIGINS`](super::SEARCH_BATCH_ORIGINS) origin lookups' board halves —
2218 /// [`ORIGIN_LOOKUP`]'s `items` filtered by the origin field — in aliased `o<n>` fields, each
2219 /// origin's issue search in a search slot of its own.
2220 ///
2221 /// What `task find` asks a board, every search a page at a time: one request a round,
2222 /// rather than one per search. A slot not asked is left out by `@include`, its page size
2223 /// bound to one; GitHub answers an error about one slot at that slot's path, and the other
2224 /// slots are answered beside it.
2225 pub const SEARCH_BATCH: &str = concat!(
2226 r#"query($owner:String!,$number:Int!,$type:SearchType!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2227 relationship_events_variable!(),
2228 search_batch_variables!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2229 origin_batch_variables!(0 1 2 3 4),
2230 "){",
2231 search_batch_slots!(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19),
2232 origin_batch_slots!(0 1 2 3 4),
2233 "\n }",
2234 board_issue!()
2235 );
2236
2237 /// The comment slots `<n>` of [`ADD_COMMENTS`]: [`ADD_COMMENT`]'s mutation, aliased
2238 /// `comment<n>`, sent only while `$send<n>` holds.
2239 macro_rules! comment_slots {
2240 ($($n:literal)+) => {
2241 concat!($(
2242 " comment", stringify!($n), ":addComment(input:$comment", stringify!($n),
2243 ") @include(if:$send", stringify!($n), "){subject{id} commentEdge{node{",
2244 issue_comment!(), "}}}",
2245 )+)
2246 };
2247 }
2248
2249 /// Adds up to [`COMMENT_BATCH`](super::COMMENT_BATCH) comments in one request, each as
2250 /// [`ADD_COMMENT`] adds one, in order: what `task comment add-many` sends. GitHub runs each
2251 /// alias on its own and answers one it refused at its own path, beside the others.
2252 pub const ADD_COMMENTS: &str = concat!(
2253 "mutation(",
2254 wide_variables!("comment", "AddCommentInput", "send"; 0 1 2 3 4 5 6 7 8 9 10),
2255 "){",
2256 comment_slots!(0 1 2 3 4 5 6 7 8 9 10),
2257 "}"
2258 );
2259
2260 /// Many tasks' targeted updates, each as [`UPDATE_FIELDS`] and [`UPDATE_ISSUE`] write one:
2261 /// up to [`TASK_FIELD_BATCH`](super::TASK_FIELD_BATCH) board field values in `field<n>`,
2262 /// [`MUTATION_BATCH`](super::MUTATION_BATCH) field clears in `clear<n>`, and as many
2263 /// `updateIssue`s in `update<n>`, each sent only while its own `send…<n>` holds.
2264 ///
2265 /// What `task update-many` sends, in two requests at most: every item's field values and
2266 /// clears first, then every issue update of an item whose fields all landed — the order one
2267 /// item's update keeps, so a refusal part way leaves an item's body as it stood.
2268 ///
2269 /// Each `update<n>` selects the issue it updated as a read of it does, so its response spells
2270 /// the change token the task holds after its batch's writes — the version `update-many`
2271 /// answers — with no read after them. Its page sizes are bound by `$nestedFirst` and
2272 /// `$boardItems`, as every read's are.
2273 pub const WRITE_TASKS: &str = concat!(
2274 "mutation(",
2275 wide_variables!("field", "UpdateProjectV2ItemFieldValueInput", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2276 wide_variables!("clear", "ClearProjectV2ItemFieldValueInput", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2277 wide_variables!("update", "UpdateIssueInput", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2278 r#"$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2279 relationship_events_variable!(),
2280 "){",
2281 wide_slots!("field", "updateProjectV2ItemFieldValue", "projectV2Item{id}", "sendField"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18),
2282 wide_slots!("clear", "clearProjectV2ItemFieldValue", "projectV2Item{id}", "sendClear"; 0 1 2 3 4 5 6 7 8 9 10),
2283 wide_slots!("update", "updateIssue", "issue{id ...BoardIssue}", "sendUpdate"; 0 1 2 3 4 5 6 7 8 9 10),
2284 "}",
2285 board_issue!()
2286 );
2287
2288 /// One alias of [`ISSUE_RELATIONS`]: the item a batch's `$id<n>` names, as a read of it by
2289 /// its own id reads it, with a page of its sub-issues' ids.
2290 macro_rules! issue_relations_alias {
2291 ($n:literal) => {
2292 concat!(
2293 "\n i",
2294 stringify!($n),
2295 ":node(id:$id",
2296 stringify!($n),
2297 "){__typename ...BoardIssue ... on Issue{subIssues(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2298 )
2299 };
2300 }
2301
2302 /// [`ISSUE_DETAILS`] with each issue's sub-issues in place of its comments: what `task
2303 /// relations` and a link write read of [`DETAIL_BATCH`](super::DETAIL_BATCH) tasks at once
2304 /// on a source reading sub-issues as children — each task whole, its parent and the ids of up
2305 /// to `$first` of its children.
2306 pub const ISSUE_RELATIONS: &str = concat!(
2307 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!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2308 relationship_events_variable!(),
2309 r#"){"#,
2310 issue_relations_alias!(0),
2311 issue_relations_alias!(1),
2312 issue_relations_alias!(2),
2313 issue_relations_alias!(3),
2314 issue_relations_alias!(4),
2315 issue_relations_alias!(5),
2316 issue_relations_alias!(6),
2317 issue_relations_alias!(7),
2318 issue_relations_alias!(8),
2319 issue_relations_alias!(9),
2320 issue_relations_alias!(10),
2321 issue_relations_alias!(11),
2322 issue_relations_alias!(12),
2323 issue_relations_alias!(13),
2324 issue_relations_alias!(14),
2325 issue_relations_alias!(15),
2326 issue_relations_alias!(16),
2327 issue_relations_alias!(17),
2328 issue_relations_alias!(18),
2329 issue_relations_alias!(19),
2330 issue_relations_alias!(20),
2331 issue_relations_alias!(21),
2332 issue_relations_alias!(22),
2333 issue_relations_alias!(23),
2334 "\n }",
2335 board_issue!()
2336 );
2337
2338 /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) parent writes in one request:
2339 /// `parent<n>` an `addSubIssue` (a re-parent carrying `replaceParent: true`) and `unparent<n>`
2340 /// a `removeSubIssue`, each sent only while its own `send…<n>` holds. GitHub runs each alias
2341 /// on its own and answers one it refused — a cycle, a depth or children limit — at its own
2342 /// path, beside the others.
2343 pub const SET_PARENTS: &str = concat!(
2344 "mutation(",
2345 wide_variables!("parent", "AddSubIssueInput", "sendParent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2346 wide_variables!("unparent", "RemoveSubIssueInput", "sendUnparent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2347 "){",
2348 wide_slots!("parent", "addSubIssue", "issue{id} subIssue{id}", "sendParent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2349 wide_slots!("unparent", "removeSubIssue", "issue{id} subIssue{id}", "sendUnparent"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2350 "}"
2351 );
2352
2353 /// Up to [`RELATION_BATCH`](super::RELATION_BATCH) `relates-to` relations added in one
2354 /// request, each `relate<n>` sent only while `$send<n>` holds: what a link of a type a
2355 /// source mirrors natively writes beside its metadata.
2356 pub const ADD_RELATES_TO: &str = concat!(
2357 "mutation(",
2358 wide_variables!("relate", "AddRelatesToInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2359 "){",
2360 wide_slots!("relate", "addRelatesTo", "issue{id} relatedIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2361 "}"
2362 );
2363
2364 /// [`ADD_RELATES_TO`]'s removal: up to [`RELATION_BATCH`](super::RELATION_BATCH)
2365 /// `relates-to` relations removed in one request.
2366 pub const REMOVE_RELATES_TO: &str = concat!(
2367 "mutation(",
2368 wide_variables!("unrelate", "RemoveRelatesToInput", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2369 "){",
2370 wide_slots!("unrelate", "removeRelatesTo", "issue{id} relatedIssue{id}", "send"; 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29),
2371 "}"
2372 );
2373
2374 /// One alias of [`RELATES_TO_READS`]: the issue `$id<n>` names and the ids of up to
2375 /// `$first` of the issues it relates to.
2376 macro_rules! relates_to_alias {
2377 ($n:literal) => {
2378 concat!(
2379 "\n r",
2380 stringify!($n),
2381 ":node(id:$id",
2382 stringify!($n),
2383 "){... on Issue{id relatesTo(first:$first){nodes{id}pageInfo{hasNextPage}}}}"
2384 )
2385 };
2386 }
2387
2388 /// What [`DETAIL_BATCH`](super::DETAIL_BATCH) issues relate to, one alias each: read
2389 /// after a relation write whose answer never arrived, so each relation is answered from what
2390 /// GitHub holds rather than sent again blindly.
2391 pub const RELATES_TO_READS: &str = concat!(
2392 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!){"#,
2393 relates_to_alias!(0),
2394 relates_to_alias!(1),
2395 relates_to_alias!(2),
2396 relates_to_alias!(3),
2397 relates_to_alias!(4),
2398 relates_to_alias!(5),
2399 relates_to_alias!(6),
2400 relates_to_alias!(7),
2401 relates_to_alias!(8),
2402 relates_to_alias!(9),
2403 relates_to_alias!(10),
2404 relates_to_alias!(11),
2405 relates_to_alias!(12),
2406 relates_to_alias!(13),
2407 relates_to_alias!(14),
2408 relates_to_alias!(15),
2409 relates_to_alias!(16),
2410 relates_to_alias!(17),
2411 relates_to_alias!(18),
2412 relates_to_alias!(19),
2413 relates_to_alias!(20),
2414 relates_to_alias!(21),
2415 relates_to_alias!(22),
2416 relates_to_alias!(23),
2417 "\n }"
2418 );
2419
2420 /// What a mutation of a write the store's cache expects selects of the issue it wrote:
2421 /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
2422 /// change token's every part and the first page of its comments.
2423 ///
2424 /// Sent **only** while that write runs, in place of the document named beside each
2425 /// below, so every other write's documents, prices and request counts are what they
2426 /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
2427 /// mutation's response is stamped seconds after the change it made, so against GitHub
2428 /// itself a response rarely determines a version; the cache is then left unserveable
2429 /// for the task, and its next read refreshes it.
2430 macro_rules! whole_issue {
2431 () => {
2432 concat!(
2433 r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
2434 issue_comment!(),
2435 r#"}pageInfo{hasNextPage endCursor}}}"#
2436 )
2437 };
2438 }
2439
2440 /// The variables every document of `whole_issue!` declares beside its own.
2441 macro_rules! whole_variables {
2442 () => {
2443 concat!(
2444 r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2445 relationship_events_variable!()
2446 )
2447 };
2448 }
2449
2450 /// [`ADD_COMMENT`], selecting the whole issue commented on.
2451 pub const ADD_COMMENT_WHOLE: &str = concat!(
2452 r#"mutation($input:AddCommentInput!"#,
2453 whole_variables!(),
2454 r#"){addComment(input:$input){subject{id "#,
2455 whole_issue!(),
2456 r#"} commentEdge{node{"#,
2457 issue_comment!(),
2458 r#"}}}}"#,
2459 board_issue!()
2460 );
2461 /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
2462 pub const UPDATE_COMMENT_WHOLE: &str = concat!(
2463 r#"mutation($input:UpdateIssueCommentInput!"#,
2464 whole_variables!(),
2465 r#"){updateIssueComment(input:$input){issueComment{"#,
2466 issue_comment!(),
2467 r#" issue{"#,
2468 whole_issue!(),
2469 r#"}}}}"#,
2470 board_issue!()
2471 );
2472 /// [`UPDATE_ISSUE`], selecting the whole issue updated.
2473 pub const UPDATE_ISSUE_WHOLE: &str = concat!(
2474 r#"mutation($input:UpdateIssueInput!"#,
2475 whole_variables!(),
2476 r#"){updateIssue(input:$input){issue{id "#,
2477 whole_issue!(),
2478 r#"}}}"#,
2479 board_issue!()
2480 );
2481 /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
2482 pub const UPDATE_FIELD_WHOLE: &str = concat!(
2483 r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2484 whole_variables!(),
2485 r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2486 whole_issue!(),
2487 r#"}}}}"#,
2488 board_issue!()
2489 );
2490 /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
2491 pub const CLEAR_FIELD_WHOLE: &str = concat!(
2492 r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2493 whole_variables!(),
2494 r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
2495 whole_issue!(),
2496 r#"}}}}"#,
2497 board_issue!()
2498 );
2499
2500 /// The variables every document selecting [`board_issue_fields!`] inline declares beside
2501 /// its own.
2502 macro_rules! token_variables {
2503 () => {
2504 concat!(
2505 r#",$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
2506 relationship_events_variable!()
2507 )
2508 };
2509 }
2510
2511 /// [`UPDATE_ISSUE`], selecting what a read of the issue updated selects — every part of its
2512 /// change token, and no comment — inline, so its root fields are the plain document's.
2513 ///
2514 /// What a targeted update sends in the plain document's place, so the version it answers
2515 /// is the one its own last response spells (`GitHubProjectsSource::written_tokens`). It
2516 /// costs the one point the plain document does.
2517 pub const UPDATE_ISSUE_TOKEN: &str = concat!(
2518 r#"mutation($input:UpdateIssueInput!"#,
2519 token_variables!(),
2520 r#"){updateIssue(input:$input){issue{"#,
2521 board_issue_fields!(),
2522 r#"}}}"#
2523 );
2524 /// [`UPDATE_FIELD`], selecting the issue behind the board item written as
2525 /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2526 pub const UPDATE_FIELD_TOKEN: &str = concat!(
2527 r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2528 token_variables!(),
2529 r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2530 board_issue_fields!(),
2531 r#"}}}}}"#
2532 );
2533 /// [`CLEAR_FIELD`], selecting the issue behind the board item cleared as
2534 /// [`UPDATE_ISSUE_TOKEN`] selects the issue it updates.
2535 pub const CLEAR_FIELD_TOKEN: &str = concat!(
2536 r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
2537 token_variables!(),
2538 r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{... on Issue{"#,
2539 board_issue_fields!(),
2540 r#"}}}}}"#
2541 );
2542
2543 /// The document a targeted update sends in `document`'s place, when it has one: the twin
2544 /// whose answer spells the change token of the issue written.
2545 #[must_use]
2546 pub fn token_twin(document: &str) -> Option<&'static str> {
2547 [
2548 (UPDATE_ISSUE, UPDATE_ISSUE_TOKEN),
2549 (UPDATE_FIELD, UPDATE_FIELD_TOKEN),
2550 (CLEAR_FIELD, CLEAR_FIELD_TOKEN),
2551 ]
2552 .into_iter()
2553 .find(|(plain, _)| *plain == document)
2554 .map(|(_, token)| token)
2555 }
2556
2557 /// The document sent in `document`'s place while a write the store's cache expects runs,
2558 /// when it has one.
2559 #[must_use]
2560 pub fn whole_twin(document: &str) -> Option<&'static str> {
2561 [
2562 (ADD_COMMENT, ADD_COMMENT_WHOLE),
2563 (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
2564 (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
2565 (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
2566 (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
2567 ]
2568 .into_iter()
2569 .find(|(plain, _)| *plain == document)
2570 .map(|(_, whole)| whole)
2571 }
2572
2573 /// Every document above, with what this source is doing when it sends one.
2574 ///
2575 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
2576 /// name the call that was refused, and a `match` with a catch-all arm would answer a
2577 /// document added later with "talking to GitHub" and never say so.
2578 ///
2579 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
2580 /// const` here that this list omits, so the two cannot part — which is the same guard
2581 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
2582 pub const DOCUMENTS: [(&str, &str); 68] = [
2583 (
2584 ISSUE_RELATIONS,
2585 "reading several issues with their parents and sub-issues",
2586 ),
2587 (SET_PARENTS, "writing several issues' parents"),
2588 (ADD_RELATES_TO, "adding several relates-to relations"),
2589 (REMOVE_RELATES_TO, "removing several relates-to relations"),
2590 (
2591 RELATES_TO_READS,
2592 "reading what several issues relate to after a relation write went unanswered",
2593 ),
2594 (
2595 ISSUES_BY_CREATION,
2596 "listing a repository's issues newest first for a create key",
2597 ),
2598 (SEARCH_ISSUES, "searching this board's issues"),
2599 (ISSUE, "reading one issue"),
2600 (
2601 DOCUMENT_IN_PROJECT,
2602 "reading one document with its project and the project's members",
2603 ),
2604 (
2605 ISSUE_BOARD_ITEMS,
2606 "reading one issue's board memberships past the page it came with",
2607 ),
2608 (SUB_ISSUES, "reading a project's tasks"),
2609 (BOARD, "reading the board"),
2610 (
2611 BOARD_WITH_COMMENTS,
2612 "reading a board page with first comments",
2613 ),
2614 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
2615 (CLOSED_ITEMS, "reading the board's closed items"),
2616 (
2617 ISSUES_BY_REFERENCE,
2618 "reading a batch of issues by their references",
2619 ),
2620 (BOARD_FIELDS, "reading the board's fields"),
2621 (DRAFT, "reading one draft"),
2622 (REPOSITORY, "reading the destination repository"),
2623 (
2624 PROJECT_VISIBILITY,
2625 "reading whether the board's project is public",
2626 ),
2627 (
2628 CREATION_CONTEXT,
2629 "reading the board's fields and the destination repository",
2630 ),
2631 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
2632 (ISSUE_DEPENDS_ON, "reading what an item depends on"),
2633 (ISSUE_DEPENDED_ON_BY, "reading what depends on an issue"),
2634 (CREATE_ISSUE, "creating an issue"),
2635 (
2636 ORIGIN_LOOKUPS,
2637 "looking up the items copied from several origins, and what creating one reads",
2638 ),
2639 (
2640 ORIGIN_LOOKUPS_WITH_MEMBERS,
2641 "looking up the items copied from several origins, with a project's members",
2642 ),
2643 (CREATE_ISSUES, "creating several issues together"),
2644 (
2645 CREATED_BOARD_ITEMS,
2646 "reading the board items of the issues a copy created",
2647 ),
2648 (
2649 WRITE_FIELD_VALUES,
2650 "writing board fields of the items a copy created",
2651 ),
2652 (
2653 WRITE_FIELDS_AND_EDGES,
2654 "writing board fields and dependencies of the items a copy created",
2655 ),
2656 (CLOSE_ISSUES, "closing the issues a copy created"),
2657 (UPDATE_ISSUES, "updating several issues together"),
2658 (ADD_BLOCKED_BYS, "recording several dependencies together"),
2659 (ADD_TO_BOARD, "adding an issue to the board"),
2660 (UPDATE_ISSUE, "updating an issue"),
2661 (UPDATE_DRAFT, "updating a draft item"),
2662 (UPDATE_FIELD, "writing a board field"),
2663 (UPDATE_FIELDS, "writing board fields together"),
2664 (CLEAR_FIELD, "clearing a board field"),
2665 (
2666 CREATE_FIELD,
2667 "creating a board single-select field with its options",
2668 ),
2669 (
2670 STATUS_OPTIONS_SNAPSHOT,
2671 "snapshotting board Status options and assignments",
2672 ),
2673 (
2674 STATUS_OPTIONS_UPDATE,
2675 "safely replacing the board Status option list",
2676 ),
2677 (ADD_SUB_ISSUE, "filing an issue under its project"),
2678 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
2679 (ADD_BLOCKED_BY, "recording a dependency"),
2680 (REMOVE_BLOCKED_BY, "removing a dependency"),
2681 (DELETE_ISSUE, "deleting an issue"),
2682 (DELETE_ISSUES, "deleting several issues together"),
2683 (ISSUE_COMMENTS, "reading a task's comments"),
2684 (ISSUE_DETAIL, "reading one issue with its comments"),
2685 (
2686 ISSUE_DETAILS,
2687 "reading a batch of issues with their comments",
2688 ),
2689 (
2690 ISSUE_COPY_DETAILS,
2691 "reading a batch of issues with their comments and dependencies for a copy",
2692 ),
2693 (COMMENT_ISSUE, "reading which issue a comment is on"),
2694 (ADD_COMMENT, "adding a comment"),
2695 (UPDATE_COMMENT, "editing a comment"),
2696 (DELETE_COMMENT, "deleting a comment"),
2697 (
2698 SEARCH_BATCH,
2699 "searching this board's issues several ways at once",
2700 ),
2701 (ADD_COMMENTS, "adding several comments together"),
2702 (WRITE_TASKS, "updating several tasks together"),
2703 (
2704 ADD_COMMENT_WHOLE,
2705 "adding a comment, answered with the whole issue",
2706 ),
2707 (
2708 UPDATE_COMMENT_WHOLE,
2709 "editing a comment, answered with the whole issue",
2710 ),
2711 (
2712 UPDATE_ISSUE_WHOLE,
2713 "updating an issue, answered with it whole",
2714 ),
2715 (
2716 UPDATE_FIELD_WHOLE,
2717 "writing a board field, answered with the whole issue",
2718 ),
2719 (
2720 CLEAR_FIELD_WHOLE,
2721 "clearing a board field, answered with the whole issue",
2722 ),
2723 (
2724 UPDATE_ISSUE_TOKEN,
2725 "updating an issue, answered with its version",
2726 ),
2727 (
2728 UPDATE_FIELD_TOKEN,
2729 "writing a board field, answered with the issue's version",
2730 ),
2731 (
2732 CLEAR_FIELD_TOKEN,
2733 "clearing a board field, answered with the issue's version",
2734 ),
2735 ];
2736}
2737
2738/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
2739/// under, the variable carrying its input, and the boolean variable that includes it.
2740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2741pub struct FieldSlot {
2742 /// The key of the answer's `data` this field's payload is under.
2743 pub alias: &'static str,
2744 /// The variable carrying this field's input.
2745 pub variable: &'static str,
2746 /// The boolean variable whose `true` sends this field.
2747 pub include: &'static str,
2748}
2749
2750/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
2751pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
2752 FieldSlot {
2753 alias: "updateProjectV2ItemFieldValue",
2754 variable: "input",
2755 include: "writeFirst",
2756 },
2757 FieldSlot {
2758 alias: "second",
2759 variable: "second",
2760 include: "writeSecond",
2761 },
2762 FieldSlot {
2763 alias: "third",
2764 variable: "third",
2765 include: "writeThird",
2766 },
2767 FieldSlot {
2768 alias: "fourth",
2769 variable: "fourth",
2770 include: "writeFourth",
2771 },
2772 FieldSlot {
2773 alias: "fifth",
2774 variable: "fifth",
2775 include: "writeFifth",
2776 },
2777 FieldSlot {
2778 alias: "sixth",
2779 variable: "sixth",
2780 include: "writeSixth",
2781 },
2782];
2783
2784/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
2785pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
2786 FieldSlot {
2787 alias: "cleared",
2788 variable: "clear",
2789 include: "writeClear",
2790 },
2791 FieldSlot {
2792 alias: "clearedSecond",
2793 variable: "clearSecond",
2794 include: "writeClearSecond",
2795 },
2796 FieldSlot {
2797 alias: "clearedThird",
2798 variable: "clearThird",
2799 include: "writeClearThird",
2800 },
2801];
2802
2803/// Which of GitHub's two rate limiters refused a request.
2804///
2805/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
2806/// secondary one — so an operator told the wrong one takes the wrong next step, which is
2807/// the whole reason this is carried rather than collapsed into "rate limited".
2808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2809enum Limiter {
2810 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
2811 Primary,
2812 /// The burst limiter over content-generating requests, which nothing reports.
2813 Secondary,
2814}
2815
2816/// The wordings GitHub answers a secondary rate limit with.
2817///
2818/// It sends them under a forbidden status, under a too-many-requests status, and inside
2819/// the `errors` of a *successful* response, which is why the text is what this matches on
2820/// rather than the status. `abuse detection` is the wording GitHub used before the
2821/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
2822/// what a burst of content creation is refused with.
2823///
2824/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
2825/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
2826/// when, and the drift gate reconciles the two lists both ways. Public for that gate
2827/// alone — a caller has no use for it, and matching on a refusal is this source's job.
2828pub const SECONDARY_WORDINGS: [&str; 5] = [
2829 "secondary rate limit",
2830 "temporarily blocked from content creation",
2831 "abuse detection",
2832 "submitted too quickly",
2833 "exceeded a secondary",
2834];
2835
2836/// The wordings GitHub answers an exhausted primary budget with.
2837///
2838/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
2839/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
2840/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
2841/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
2842/// two phrases is a substring of it, so without it that answer read as a refusal that will
2843/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
2844/// one reason.
2845pub const PRIMARY_WORDINGS: [&str; 4] = [
2846 "api rate limit exceeded",
2847 "api rate limit already exceeded",
2848 "rate limit exceeded",
2849 "rate_limited",
2850];
2851
2852/// The wordings GitHub answers a write that collided with another change to the same
2853/// ordering with, in the `errors` of an HTTP 200: "Your attempt to move this item created a
2854/// temporary conflict. Please try again." The write did not take effect and GitHub asks for it
2855/// again, so it is retried on the same growing schedule a rate limit without a hint is, within
2856/// the same retry budget — except a `createIssue`, whose answer cannot say the issue was not
2857/// made, and which is therefore reported rather than replayed into a duplicate.
2858///
2859/// Observed rather than documented, so it is pinned exactly as [`SECONDARY_WORDINGS`] is:
2860/// `tests/fixtures/rate-limits.json` records where it was read under `conflict.observed`, and
2861/// the same drift gate reconciles the two both ways. Public for that gate alone.
2862pub const CONFLICT_WORDINGS: [&str; 1] = ["created a temporary conflict"];
2863
2864/// What a response *says about itself*, which is the only place a refusal can be read.
2865///
2866/// Deliberately not the whole response body. A board is a place people write about their
2867/// own work, and a task on it titled "the secondary rate limit" would, matched across the
2868/// raw text, turn a perfectly good answer into a refusal this source then waited out and
2869/// reported. So the item data is never read: what is read is GitHub's own REST-style
2870/// `message` envelope, which is what a forbidden status carries, and the `message` and
2871/// `type` of each GraphQL error, which is where a *successful* response says it.
2872///
2873/// A body that is not JSON at all has nothing structured to read, so only a failing
2874/// response's own text is taken — a successful response that is not JSON is malformed
2875/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
2876fn refusal_wording(status: StatusCode, body: &str) -> String {
2877 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
2878 return if status.is_success() {
2879 String::new()
2880 } else {
2881 body.to_owned()
2882 };
2883 };
2884 let mut said: Vec<&str> = parsed
2885 .get("message")
2886 .and_then(Value::as_str)
2887 .into_iter()
2888 .collect();
2889 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
2890 for error in errors {
2891 said.extend(
2892 ["message", "type"]
2893 .into_iter()
2894 .filter_map(|key| error.get(key).and_then(Value::as_str)),
2895 );
2896 }
2897 }
2898 said.join("; ")
2899}
2900
2901impl Limiter {
2902 /// Which limiter refused this response, or `None` when none of them did.
2903 ///
2904 /// The wording is read first and the status only decides what carries none of it,
2905 /// because GitHub answers a secondary limit with a forbidden status far more often
2906 /// than with too-many-requests — while a forbidden status saying nothing about a limit
2907 /// really is a credential this token lacks.
2908 ///
2909 /// A response is a refusal because of its status or its own wording. A spent budget
2910 /// only ever explains one; it never turns an answer into a refusal.
2911 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
2912 let normalized = refusal_wording(status, body).to_ascii_lowercase();
2913 if SECONDARY_WORDINGS
2914 .iter()
2915 .any(|wording| normalized.contains(wording))
2916 {
2917 return Some(Self::Secondary);
2918 }
2919 if status == StatusCode::TOO_MANY_REQUESTS {
2920 return Some(Self::Primary);
2921 }
2922 // An exhausted budget *explains* a response that failed; it does not make one that
2923 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
2924 // request the budget allowed as well as on the ones it then refuses, so reading
2925 // the header alone threw away a good answer — and, once refusals were retried,
2926 // replayed a request that had already taken effect.
2927 if !status.is_success() && budget_exhausted {
2928 return Some(Self::Primary);
2929 }
2930 // A successful response saying it: GitHub reports a GraphQL rate limit in the
2931 // `errors` of an HTTP 200, where nothing about the status says so at all.
2932 if status.is_success()
2933 && PRIMARY_WORDINGS
2934 .iter()
2935 .any(|wording| normalized.contains(wording))
2936 {
2937 return Some(Self::Primary);
2938 }
2939 None
2940 }
2941
2942 /// What this limiter is called where an operator can look it up.
2943 const fn name(self) -> &'static str {
2944 match self {
2945 Self::Primary => "GitHub's primary API rate limit",
2946 Self::Secondary => "GitHub's secondary rate limit",
2947 }
2948 }
2949
2950 /// What the endpoint an operator would go and check says about this limiter.
2951 const fn where_to_look(self) -> &'static str {
2952 match self {
2953 Self::Primary => {
2954 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
2955 comes back."
2956 }
2957 Self::Secondary => {
2958 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
2959 primary budget and does not report this one, so budget showing there says \
2960 nothing about this refusal, and every further attempt extends it."
2961 }
2962 }
2963 }
2964
2965 /// The next step this limiter actually calls for.
2966 const fn what_to_do(self) -> &'static str {
2967 match self {
2968 Self::Primary => {
2969 "wait for the reset `gh api rate_limit` reports, then run the command again."
2970 }
2971 Self::Secondary => {
2972 "leave this board alone for a few minutes, then run the command again — or \
2973 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
2974 }
2975 }
2976 }
2977}
2978
2979/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
2980#[derive(Debug, Clone, Copy)]
2981struct Limited {
2982 limiter: Limiter,
2983 hint: Option<u64>,
2984 /// Which header the hint came from: `retry-after`, or an exhausted primary budget's
2985 /// `x-ratelimit-reset`.
2986 hinted_by: Hint,
2987}
2988
2989/// Where a refusal's hint came from, which is what its wait is named after.
2990#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2991enum Hint {
2992 /// `retry-after`, or no hint at all.
2993 RetryAfter,
2994 /// An exhausted primary budget's `x-ratelimit-reset`.
2995 PrimaryReset,
2996}
2997
2998impl Limited {
2999 /// The wait this refusal is answered with, when the backing-off schedule is at `backoff`,
3000 /// and the cause it is named by: the hint, raised to the schedule's own next wait —
3001 /// GitHub really does send `retry-after: 0`, and retrying at once is the one move that
3002 /// extends a secondary limit — or the schedule alone when there is none.
3003 fn wait(self, backoff: Duration) -> (Duration, WaitCause) {
3004 match (self.hint, self.hinted_by) {
3005 (Some(hint), Hint::RetryAfter) => (
3006 Duration::from_secs(hint).max(backoff),
3007 pacing::cause::RETRY_AFTER,
3008 ),
3009 (Some(hint), Hint::PrimaryReset) => (
3010 Duration::from_secs(hint).max(backoff),
3011 pacing::cause::PRIMARY_RESET,
3012 ),
3013 (None, _) => (backoff, pacing::cause::BACKOFF),
3014 }
3015 }
3016
3017 /// What the caller is told once this source has waited as long as it may.
3018 ///
3019 /// Both limiters report as [`SourceError::RateLimited`], because that is what
3020 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
3021 /// about *which* limiter it was makes it a different kind of failure. What differs is
3022 /// the operator's next step, and that is what the message carries — a secondary
3023 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
3024 /// budget looks fine, and then back to retry the very burst that was refused.
3025 fn exhausted(
3026 self,
3027 doing: &str,
3028 waits: u32,
3029 waited: Duration,
3030 needed: Duration,
3031 budget: Duration,
3032 ) -> SourceError {
3033 SourceError::RateLimited {
3034 retry_after_seconds: self.hint,
3035 message: Some(format!(
3036 "{} refused this source while {doing}; it waited {} out over {} and was refused \
3037 again, and the next wait of {} would take it past the {} one call may spend \
3038 waiting. {} next: {}",
3039 self.limiter.name(),
3040 plural(waits, "refusal"),
3041 seconds(waited),
3042 seconds(needed),
3043 seconds(budget),
3044 self.limiter.where_to_look(),
3045 self.limiter.what_to_do(),
3046 )),
3047 }
3048 }
3049}
3050
3051/// One HTTP attempt's result, with what its response said about the rate limit.
3052///
3053/// The two travel together so the record and the outcome are written from the same place:
3054/// what a response said about the budget is only readable while that response is in hand,
3055/// and what the attempt *meant* is only decidable once its body has been read.
3056struct Attempted {
3057 result: Result<Value, Attempt>,
3058 limits: accounting::RateLimit,
3059 /// GitHub's own reported cost for this call, for a document that asked for it.
3060 reported_cost: Option<u64>,
3061 response_outcome: accounting::ResponseOutcome,
3062}
3063
3064/// One attempt's outcome: an error to report, or a rate limit to wait out.
3065enum Attempt {
3066 Failed(SourceError),
3067 Limited(Limited),
3068 /// GitHub refused a write as a temporary conflict and asked for it again — see
3069 /// [`CONFLICT_WORDINGS`]. Carries what GitHub said, for the diagnostic once retrying ends.
3070 Conflicted(String),
3071}
3072
3073fn plural(count: u32, thing: &str) -> String {
3074 if count == 1 {
3075 format!("{count} {thing}")
3076 } else {
3077 format!("{count} {thing}s")
3078 }
3079}
3080
3081fn seconds(duration: Duration) -> String {
3082 format!("{:.1}s", duration.as_secs_f64())
3083}
3084
3085/// The member [`stamp_served`] writes onto every node of a response that says when it last
3086/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
3087const SERVED_AT: &str = "onetaskgraphServedAt";
3088
3089/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
3090/// node's time later knows the server's clock on the response that read it — which is what
3091/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
3092fn stamp_served(data: &mut Value, served: &str) {
3093 match data {
3094 Value::Object(members) => {
3095 if members.contains_key("updatedAt") {
3096 members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
3097 }
3098 for member in members.values_mut() {
3099 stamp_served(member, served);
3100 }
3101 }
3102 Value::Array(items) => {
3103 for item in items {
3104 stamp_served(item, served);
3105 }
3106 }
3107 _ => {}
3108 }
3109}
3110
3111/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
3112///
3113/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
3114/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
3115/// header, and neither is what makes a response a refusal — so the whole cost of one this
3116/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
3117/// instead. Refusing the response over the header would turn a readable refusal into an
3118/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
3119fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
3120 value
3121 .and_then(|value| value.to_str().ok())
3122 .and_then(|value| value.trim().parse::<u64>().ok())
3123}
3124
3125/// A transport failure with every cause beneath it, outermost first.
3126///
3127/// The client's own text for a request it could not complete is `error sending request for
3128/// url (…)`, and the socket error that says why — a refused or reset connection, an exhausted
3129/// port range, a connection closed before its answer — is only in the chain of sources below
3130/// it. Those differ in what to do next, so all of them are kept.
3131pub(crate) fn transport_error(error: &(dyn std::error::Error + 'static)) -> String {
3132 let mut text = error.to_string();
3133 let mut cause = error.source();
3134 while let Some(next) = cause {
3135 let said = next.to_string();
3136 if !text.ends_with(&said) {
3137 text = format!("{text}: {said}");
3138 }
3139 cause = next.source();
3140 }
3141 text
3142}
3143
3144/// Every mutation this source sends creates content — an issue, a board item, a field of
3145/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
3146/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
3147/// and what the keyword says are the same set. That is what makes the keyword a sound test
3148/// rather than a convenient one: counting an edit or a removal the limiter might not have
3149/// counted costs a wait, and not counting one it did count costs the next fifty minutes.
3150pub(crate) fn is_mutation(query: &str) -> bool {
3151 query.trim_start().starts_with("mutation")
3152}
3153
3154/// What this source was doing, for a diagnostic that has to say so.
3155///
3156/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
3157/// a document added without a description is caught by that list's own gate instead of
3158/// falling through to the vague arm below.
3159fn operation_description(query: &str) -> &'static str {
3160 graphql::DOCUMENTS
3161 .iter()
3162 .find(|(document, _)| *document == query)
3163 .map_or("talking to GitHub", |(_, doing)| *doing)
3164}
3165
3166/// GitHub's published ceiling on content-generating requests in any one minute.
3167///
3168/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
3169/// GitHub's number rather than this source's. This source paces at it over its window: a
3170/// content-creating request goes out at once while the minute's creations leave room for it,
3171/// and waits until they do otherwise — see the `pacing` module.
3172pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
3173/// The same ceiling as GitHub publishes it for any one hour, paced over its window as the
3174/// per-minute one is. Pinned beside its sibling so the gate would notice either one moving.
3175pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
3176/// GitHub's published ceiling on secondary points in any one minute: what every GraphQL
3177/// request is charged against, a mutation [`SECONDARY_POINTS_PER_MUTATION`] and a query
3178/// [`SECONDARY_POINTS_PER_QUERY`]. Pinned and paced as [`CONTENT_CREATION_PER_MINUTE`] is.
3179pub const SECONDARY_POINTS_PER_MINUTE: u64 = 2_000;
3180/// The secondary points one GraphQL request carrying a mutation is charged.
3181pub const SECONDARY_POINTS_PER_MUTATION: u64 = 5;
3182/// The secondary points one GraphQL request carrying no mutation is charged.
3183pub const SECONDARY_POINTS_PER_QUERY: u64 = 1;
3184/// The fastest *steady* spacing of content-creating mutations that cannot exceed
3185/// [`CONTENT_CREATION_PER_MINUTE`], in milliseconds: 60000/80 is 750.
3186///
3187/// It is no longer the default. This source paces by GitHub's windows instead, so a copy's
3188/// few dozen writes go out as fast as GitHub answers them while the minute has room, rather
3189/// than 750 ms apart — which was most of a plan copy's wall time. An installation that wants
3190/// mutations spaced as well says so through `pacing.min_mutation_interval_ms`, and this is the
3191/// value that holds a long sustained burst under the per-minute bound by spacing alone.
3192pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
3193/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
3194///
3195/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
3196/// own advice for a secondary limit — wait, and wait longer each time — without spending
3197/// the first minute of a transient refusal doing nothing.
3198pub const RETRY_BACKOFF_MS: u64 = 1_000;
3199/// Total time one call may spend waiting out rate limits before it reports a failure.
3200///
3201/// Two minutes is long enough to ride out the refusals a paced copy still collects and
3202/// short enough that a command an operator is watching returns. The bound is what makes
3203/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
3204/// the limiter, not in a process nobody can tell from a wedged one.
3205pub const RETRY_BUDGET_MS: u64 = 120_000;
3206
3207fn default_token_env() -> String {
3208 "GH_PROJECTS_TOKEN".to_owned()
3209}
3210fn default_endpoint() -> String {
3211 "https://api.github.com/graphql".to_owned()
3212}
3213
3214/// The name of a `Status` single-select option on the board.
3215///
3216/// Validated on the way in rather than checked later, so a blank option name — which
3217/// nothing on a board can be — is a state this type cannot hold.
3218#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3219#[serde(try_from = "String")]
3220#[schemars(extend("minLength" = 1))]
3221pub struct ColumnName(String);
3222
3223impl ColumnName {
3224 /// The option name, as the board spells it.
3225 fn as_str(&self) -> &str {
3226 &self.0
3227 }
3228}
3229
3230impl TryFrom<String> for ColumnName {
3231 type Error = String;
3232
3233 fn try_from(name: String) -> Result<Self, Self::Error> {
3234 if name.trim().is_empty() {
3235 return Err("a status_mapping option name cannot be blank".to_owned());
3236 }
3237 Ok(Self(name))
3238 }
3239}
3240
3241/// The two closed states this product can mean.
3242///
3243/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which nothing here ever writes:
3244/// an issue closed as one *reads* `cancelled`, because its work is another issue's, but this
3245/// product never decides on a person's behalf that two issues are the same work.
3246#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
3247#[serde(rename_all = "kebab-case")]
3248pub enum ClosedState {
3249 /// `COMPLETED` — precisely done.
3250 Completed,
3251 /// `NOT_PLANNED` — precisely cancelled.
3252 NotPlanned,
3253}
3254
3255impl ClosedState {
3256 const fn reason(self) -> &'static str {
3257 match self {
3258 Self::Completed => "COMPLETED",
3259 Self::NotPlanned => "NOT_PLANNED",
3260 }
3261 }
3262}
3263
3264/// Configuration for one GitHub Projects v2 board.
3265///
3266/// It serializes to a document this same type reads back as itself, so a caller writing a
3267/// configuration — a consumer filing onto a board it configures — can build one here rather
3268/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
3269#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3270#[serde(default, deny_unknown_fields)]
3271pub struct GitHubProjectsConfig {
3272 /// Login of the user or organization which owns the board.
3273 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
3274 /// The project number shown in the board's GitHub URL.
3275 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
3276 // 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.
3277 /// `owner/name` of the repository this source creates an issue in when the item's own
3278 /// `repositories` field does not decide it.
3279 ///
3280 /// An item naming exactly one repository is created there — on a source declared
3281 /// `visibility: private`, only when that repository reads private, and otherwise as an
3282 /// item naming none is; a task or a document naming none or several is created in its
3283 /// parent project's repository; and a project, or a task or document with no parent,
3284 /// naming none or several is created here. A board
3285 /// has no repository of its own and `createIssue` requires one, so a write without
3286 /// this is refused naming the field. Reads never need it.
3287 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
3288 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
3289 /// GitHub logins whose repositories this source may create an issue in.
3290 ///
3291 /// Absent or empty, the default, restricts nothing. Otherwise a create whose issue the
3292 /// rule `repository` describes would file in a repository owned by anyone else is refused
3293 /// before anything of it is written — by `task copy`, `task copy --create`, a member
3294 /// create of a project copy and a dry run of each — with the error kind `creation-owner`,
3295 /// naming the repository and these owners. Logins compare case-insensitively, as GitHub
3296 /// compares them. An update of an issue that already exists is never refused by it.
3297 /// `task locate` reports such a refusal as `creates_in.refused` rather than failing.
3298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3299 // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3300 // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3301 #[schemars(!skip_serializing_if)]
3302 pub creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates each entry against GitHub's login grammar, naming the one it refuses.
3303 /// Environment variable containing a fine-grained token with Projects and Issues
3304 /// read/write plus Pull requests read-only access for every repository represented on
3305 /// the board.
3306 #[serde(default = "default_token_env")]
3307 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
3308 /// GraphQL endpoint. GitHub Enterprise installations may override it.
3309 #[serde(default = "default_endpoint")]
3310 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
3311 /// Per-instance mapping from a status category to the option of the board's one
3312 /// `Status` field it lands on, for a task and for a project.
3313 ///
3314 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
3315 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
3316 /// where a kind left out leaves the category unmapped for that kind. A category this
3317 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
3318 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
3319 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
3320 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
3321 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
3322 /// either kind. No two categories may name one option for the same kind, ignoring case.
3323 /// `unknown` may name one existing option; every unknown word then lands on it and
3324 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
3325 /// each unknown word because it never creates board options.
3326 #[serde(default)]
3327 pub status_mapping: StatusMapping,
3328 /// Per-instance mapping from a task's priority to an option of this board's
3329 /// single-select field named `Priority`.
3330 ///
3331 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
3332 /// other priority is refused before it reaches this board. Present, each of `urgent`,
3333 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
3334 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
3335 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
3336 /// no two levels may name one option. Reads and writes never create the field or an
3337 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
3338 /// the board lacks is refused pointing there.
3339 #[serde(default)]
3340 pub priority_mapping: Option<PriorityMappingConfig>,
3341 /// Caller metadata values this source also writes to board text fields, so the board can
3342 /// filter on them.
3343 ///
3344 /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
3345 /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
3346 /// value. On every item write — create, update and copy, of a task, a project or a
3347 /// document — a string found there is written to the field, and nothing is sent when the
3348 /// field already holds it; nothing there, or `null`, clears the field when it holds a
3349 /// value; any other JSON type is refused before any mutation. The metadata comment in the
3350 /// issue body stays the one home of the value, and every read reports metadata from it.
3351 /// Reads and writes never create the field: `onetaskgraph sources fields <source>
3352 /// --apply` does, as a text field, and a write to a board lacking it, or holding a
3353 /// non-text field of that name, is refused pointing there.
3354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3355 // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
3356 // list out, so both SDKs model an absent list as an empty one rather than as `null`.
3357 #[schemars(!skip_serializing_if)]
3358 pub metadata_fields: Vec<MetadataFieldConfig>,
3359 /// How fast this source writes, and how long it waits out a rate-limit refusal.
3360 ///
3361 /// Every field keeps its shipped default when it is absent, and the defaults are
3362 /// GitHub's own published limits rather than taste. See [`Pacing`].
3363 #[serde(default)]
3364 pub pacing: PacingConfig,
3365 /// What an issue's sub-issues mean on this board.
3366 ///
3367 /// `project`, the default, is this source's meaning since it was first written: an issue
3368 /// with sub-issues reads as a project and its sub-issues as that project's tasks. `parent`
3369 /// reads every issue as a task and a sub-issue as a child under the parent relation, which
3370 /// `task parent set-many` writes and `task relations` reads; a project write to such a
3371 /// source is refused naming this setting. The two meanings answer differently for the same
3372 /// issues, so a `parent` source needs a board of its own: two sources naming one board with
3373 /// different `sub_issues` are refused when the configuration is read.
3374 #[serde(default, skip_serializing_if = "SubIssues::is_project")]
3375 pub sub_issues: SubIssues,
3376 /// The link types this source also writes as GitHub's own `relates-to` relation, beside the
3377 /// store's metadata, so they show in the issue's Relationships panel: each type name mapped
3378 /// to `{"native": "relates-to"}`. A type not named here is kept in metadata alone. Any other
3379 /// word — `blocks`, `blocked-by`, `duplicate`, `similar` among them — is refused when the
3380 /// source is built.
3381 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3382 // Kept in the schema as `"default": {}` although a serialized configuration leaves an empty
3383 // map out, so both SDKs model an absent map as an empty one rather than as `null`.
3384 #[schemars(!skip_serializing_if)]
3385 pub link_types: BTreeMap<String, onetaskgraph_plugin_api::LinkTypeConfig>,
3386}
3387
3388/// What an issue's sub-issues mean on a GitHub Projects board: see
3389/// [`GitHubProjectsConfig::sub_issues`].
3390#[derive(
3391 Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
3392)]
3393#[serde(rename_all = "kebab-case")]
3394pub enum SubIssues {
3395 /// An issue with sub-issues is a project, and its sub-issues are its tasks.
3396 #[default]
3397 Project,
3398 /// Every issue is a task, and a sub-issue is a child under the parent relation.
3399 Parent,
3400}
3401
3402impl SubIssues {
3403 /// Whether this is the default meaning, which a serialized configuration leaves out.
3404 #[must_use]
3405 pub const fn is_project(&self) -> bool {
3406 matches!(self, Self::Project)
3407 }
3408}
3409
3410/// Which option of the board's `Priority` field each priority lands on.
3411///
3412/// One member per level rather than a map, so a key that is not a level is refused where
3413/// the configuration is read, naming the levels there are. `none` is not a member: it is no
3414/// value in the field, not an option of it.
3415#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3416#[serde(default, deny_unknown_fields)]
3417pub struct PriorityMappingConfig {
3418 /// The option `urgent` lands on; `Urgent` when absent.
3419 pub urgent: Option<PriorityOptionName>,
3420 /// The option `high` lands on; `High` when absent.
3421 pub high: Option<PriorityOptionName>,
3422 /// The option `medium` lands on; `Medium` when absent.
3423 pub medium: Option<PriorityOptionName>,
3424 /// The option `low` lands on; `Low` when absent.
3425 pub low: Option<PriorityOptionName>,
3426}
3427
3428/// The name of an option of the board's `Priority` single-select field.
3429///
3430/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
3431/// blank name.
3432#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3433#[serde(try_from = "String")]
3434#[schemars(extend("minLength" = 1))]
3435pub struct PriorityOptionName(String);
3436
3437impl PriorityOptionName {
3438 /// The option name, as the board spells it.
3439 fn as_str(&self) -> &str {
3440 &self.0
3441 }
3442}
3443
3444impl TryFrom<String> for PriorityOptionName {
3445 type Error = String;
3446
3447 fn try_from(name: String) -> Result<Self, Self::Error> {
3448 if name.trim().is_empty() {
3449 return Err("a priority_mapping option name cannot be blank".to_owned());
3450 }
3451 Ok(Self(name))
3452 }
3453}
3454
3455/// The name of the board field a priority is held in.
3456pub const PRIORITY_FIELD: &str = "Priority";
3457
3458/// The four priorities a board option can hold, in the order a new `Priority` field lists
3459/// them. `none` is not among them: it is the field holding no value.
3460///
3461/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
3462/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
3463/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
3464/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
3465pub const PRIORITY_LEVELS: [Priority; 4] = [
3466 Priority::Urgent,
3467 Priority::High,
3468 Priority::Medium,
3469 Priority::Low,
3470];
3471
3472/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
3473/// see that list for what this pins.
3474#[must_use]
3475pub const fn level_position(priority: Priority) -> Option<usize> {
3476 match priority {
3477 Priority::None => None,
3478 Priority::Urgent => Some(0),
3479 Priority::High => Some(1),
3480 Priority::Medium => Some(2),
3481 Priority::Low => Some(3),
3482 }
3483}
3484
3485/// This instance's complete priority-to-option mapping, read in both directions.
3486///
3487/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
3488/// two levels name one option.
3489#[derive(Debug, Clone)]
3490struct PriorityMapping {
3491 options: [PriorityOptionName; 4],
3492}
3493
3494impl PriorityMapping {
3495 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3496 let shipped = |name: &str| PriorityOptionName(name.to_owned());
3497 let mapping = Self {
3498 options: [
3499 config.urgent.unwrap_or_else(|| shipped("Urgent")),
3500 config.high.unwrap_or_else(|| shipped("High")),
3501 config.medium.unwrap_or_else(|| shipped("Medium")),
3502 config.low.unwrap_or_else(|| shipped("Low")),
3503 ],
3504 };
3505 for (index, option) in mapping.options.iter().enumerate() {
3506 if let Some(earlier) = mapping.options[..index]
3507 .iter()
3508 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
3509 {
3510 return Err(SourceError::Config {
3511 message: format!(
3512 "priority_mapping of source {instance} sends both {} and {} to the board \
3513 option {:?}; one option cannot read back as two priorities",
3514 PRIORITY_LEVELS[earlier],
3515 PRIORITY_LEVELS[index],
3516 option.as_str()
3517 ),
3518 });
3519 }
3520 }
3521 Ok(mapping)
3522 }
3523
3524 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
3525 fn option(&self, priority: Priority) -> Option<&str> {
3526 level_position(priority).map(|index| self.options[index].as_str())
3527 }
3528
3529 /// The priority a board option name reports, or `None` when nothing maps to it.
3530 fn priority_of(&self, option: &str) -> Option<Priority> {
3531 self.options
3532 .iter()
3533 .position(|name| name.as_str().eq_ignore_ascii_case(option))
3534 .map(|index| PRIORITY_LEVELS[index])
3535 }
3536
3537 /// Every mapped option name, in the order a new `Priority` field lists them.
3538 fn names(&self) -> impl Iterator<Item = &str> {
3539 self.options.iter().map(PriorityOptionName::as_str)
3540 }
3541}
3542
3543/// One metadata value this source projects onto a board text field.
3544#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3545#[serde(deny_unknown_fields)]
3546pub struct MetadataFieldConfig {
3547 /// The name of the board text field the value is written to.
3548 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.
3549 /// The top-level metadata key the value is read from. A key under the reserved
3550 /// `onetaskgraph.` prefix is refused.
3551 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.
3552 /// Object keys walked inside that key's value, outermost first. Absent or empty, the
3553 /// key's own value is the one projected. Left out of a serialized entry when empty.
3554 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3555 // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
3556 #[schemars(!skip_serializing_if)]
3557 pub path: Vec<String>,
3558}
3559
3560/// The board fields no metadata value may be projected onto, compared ignoring case: the
3561/// three this source writes itself, and those GitHub owns on every board.
3562// 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.
3563const UNPROJECTABLE_FIELDS: [&str; 13] = [
3564 STATUS_FIELD,
3565 PRIORITY_FIELD,
3566 ORIGIN_FIELD,
3567 "Title",
3568 "Assignees",
3569 "Labels",
3570 "Linked pull requests",
3571 "Milestone",
3572 "Repository",
3573 "Reviewers",
3574 "Parent issue",
3575 "Sub-issues progress",
3576 "Type",
3577];
3578
3579/// One validated [`MetadataFieldConfig`] entry.
3580#[derive(Debug, Clone)]
3581struct MetadataField {
3582 field: ProjectedField,
3583 key: ProjectedKey,
3584 path: Vec<String>,
3585}
3586
3587/// The name of a board text field a metadata value is projected onto: never blank, never one
3588/// this source or GitHub writes, and never one another entry of the same source names.
3589#[derive(Debug, Clone, PartialEq, Eq)]
3590struct ProjectedField(String);
3591
3592/// The top-level metadata key a projected value is read from: never blank, and never under
3593/// the namespace this product reserves.
3594#[derive(Debug, Clone, PartialEq, Eq)]
3595struct ProjectedKey(String);
3596
3597impl std::ops::Deref for ProjectedField {
3598 type Target = str;
3599 fn deref(&self) -> &str {
3600 &self.0
3601 }
3602}
3603
3604impl std::ops::Deref for ProjectedKey {
3605 type Target = str;
3606 fn deref(&self) -> &str {
3607 &self.0
3608 }
3609}
3610
3611/// What one item's metadata says one projected field should hold.
3612#[derive(Debug, Clone, PartialEq, Eq)]
3613enum Projected {
3614 /// A string to hold.
3615 Text(String),
3616 /// Nothing: the key or a step of the path is absent, or the value is `null`.
3617 Nothing,
3618}
3619
3620impl MetadataField {
3621 /// Validate every entry of one instance's `metadata_fields`, refusing the first that
3622 /// cannot stand, naming the source and the entry.
3623 fn resolve(
3624 configured: Vec<MetadataFieldConfig>,
3625 instance: &SourceName,
3626 ) -> Result<Vec<Self>, SourceError> {
3627 let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
3628 for (index, entry) in configured.into_iter().enumerate() {
3629 let refuse = |why: String| SourceError::Config {
3630 message: format!(
3631 "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
3632 entry.field, entry.key
3633 ),
3634 };
3635 if entry.field.trim().is_empty() {
3636 return Err(refuse("names a blank field".to_owned()));
3637 }
3638 if entry.key.trim().is_empty() {
3639 return Err(refuse("names a blank key".to_owned()));
3640 }
3641 if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
3642 return Err(refuse(format!(
3643 "names a key under the reserved \"{}.\" prefix, which this product keeps \
3644 for its own keys; next: project a caller key instead",
3645 MetadataKey::RESERVED_NAMESPACE
3646 )));
3647 }
3648 if let Some(owned) = UNPROJECTABLE_FIELDS
3649 .iter()
3650 .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
3651 {
3652 return Err(refuse(format!(
3653 "names the board field {owned:?}, which this source or GitHub already \
3654 writes; next: name a text field of your own"
3655 )));
3656 }
3657 if let Some(earlier) = resolved
3658 .iter()
3659 .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
3660 {
3661 return Err(refuse(format!(
3662 "names the same board field as metadata_fields[{earlier}]; one field \
3663 cannot hold two values"
3664 )));
3665 }
3666 resolved.push(Self {
3667 field: ProjectedField(entry.field),
3668 key: ProjectedKey(entry.key),
3669 path: entry.path,
3670 });
3671 }
3672 Ok(resolved)
3673 }
3674
3675 /// The path as a refusal spells it: `[]` for the key's own value.
3676 fn spelled_path(&self) -> String {
3677 serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
3678 }
3679
3680 /// What `metadata` says this field should hold, or the refusal for a value no text field
3681 /// can hold.
3682 fn projected(
3683 &self,
3684 metadata: &BTreeMap<String, Value>,
3685 instance: &SourceName,
3686 ) -> Result<Projected, SourceError> {
3687 let mut value = metadata.get(&*self.key);
3688 for step in &self.path {
3689 value = value.and_then(|held| held.get(step.as_str()));
3690 }
3691 let found = match value {
3692 None | Some(Value::Null) => return Ok(Projected::Nothing),
3693 Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
3694 Some(Value::Bool(_)) => "a boolean",
3695 Some(Value::Number(_)) => "a number",
3696 Some(Value::Array(_)) => "an array",
3697 Some(Value::Object(_)) => "an object",
3698 };
3699 Err(SourceError::Refused {
3700 message: format!(
3701 "source {instance} projects metadata key {:?} at path {} onto its board text \
3702 field {:?}, and this item holds {found} there; only a string, null or nothing \
3703 can be written to a text field; next: store a string there, or remove the \
3704 value",
3705 &*self.key,
3706 self.spelled_path(),
3707 &*self.field
3708 ),
3709 })
3710 }
3711}
3712
3713/// What one item's `Priority` field says, read through this instance's mapping.
3714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
3715enum HeldPriority {
3716 /// A priority this source reports: an option the mapping names, or no value (`none`).
3717 Read(Priority),
3718 /// An option the mapping does not name, which is never read as a level or as `none`.
3719 Unmapped(String),
3720}
3721
3722/// How fast this source writes, and how long it waits out a rate-limit refusal.
3723///
3724/// Configurable because a GitHub Enterprise installation sets its own limits and an
3725/// operator who has already been refused may want to go slower still — not because the
3726/// defaults are guesses.
3727#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3728#[serde(default, deny_unknown_fields)]
3729pub struct PacingConfig {
3730 /// Shortest interval between two content-creating mutations, in milliseconds.
3731 ///
3732 /// Zero sends them as fast as they are asked for, which is what a fixture server on
3733 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
3734 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.
3735 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
3736 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
3737 /// zero while there is a budget to spend, because a schedule of zero-length waits
3738 /// consumes none of it and so never ends.
3739 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.
3740 /// Total time one call may spend waiting out rate limits, in milliseconds.
3741 ///
3742 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
3743 /// the bound is what makes this a wait rather than a hang.
3744 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.
3745}
3746
3747/// The largest any pacing setting may be, in milliseconds.
3748///
3749/// One hour. GitHub's own harshest published bound on content-generating requests works
3750/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
3751/// anything a real limit asks for, and past it the settings stop describing pacing at all:
3752/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
3753/// and an interval beyond it is a command that never sends its second mutation. It also
3754/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
3755/// what a `Duration` can hold on every platform.
3756pub const MAX_PACING_MS: u64 = 3_600_000;
3757
3758/// [`PacingConfig`] with every default resolved and every value checked, which is what the
3759/// source holds.
3760///
3761/// Unset, `min_mutation_interval_ms` spaces nothing, and every request is paced by GitHub's
3762/// secondary windows alone — see the `pacing` module; set, it spaces content-creating mutations
3763/// on top of those windows, [`MIN_MUTATION_INTERVAL_MS`] being the spacing that holds a sustained
3764/// burst under the per-minute bound by spacing alone; and zero sends every request as fast as it
3765/// is asked for, windows included.
3766#[derive(Debug, Clone, Copy)]
3767struct Pacing {
3768 /// Whether requests are held for GitHub's secondary windows: always, unless
3769 /// `min_mutation_interval_ms` is set to exactly zero, which has always asked for no
3770 /// pacing at all — what a fixture server on loopback wants and no board on github.com does.
3771 windows: bool,
3772 min_mutation_interval: Duration,
3773 retry_backoff: Duration,
3774 retry_budget: Duration,
3775}
3776
3777impl Pacing {
3778 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
3779 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
3780 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
3781 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
3782 message: format!(
3783 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
3784 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
3785 GitHub's own harshest published limit"
3786 ),
3787 }),
3788 Some(value) => Ok(Duration::from_millis(value)),
3789 None => Ok(Duration::from_millis(default)),
3790 };
3791 let retry_backoff = bounded(
3792 config.retry_backoff_ms,
3793 RETRY_BACKOFF_MS,
3794 "retry_backoff_ms",
3795 )?;
3796 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
3797 if retry_backoff.is_zero() && !retry_budget.is_zero() {
3798 return Err(SourceError::Config {
3799 message: format!(
3800 "pacing.retry_backoff_ms of source {instance} is 0 while \
3801 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
3802 none of that budget, so it would retry a refusal forever. Set a backoff of \
3803 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
3804 waiting at all",
3805 retry_budget.as_millis()
3806 ),
3807 });
3808 }
3809 Ok(Self {
3810 windows: config.min_mutation_interval_ms != Some(0),
3811 min_mutation_interval: bounded(
3812 config.min_mutation_interval_ms,
3813 0,
3814 "min_mutation_interval_ms",
3815 )?,
3816 retry_backoff,
3817 retry_budget,
3818 })
3819 }
3820}
3821
3822/// Factory for [`GitHubProjectsSource`].
3823#[derive(Debug, Clone, Copy, Default)]
3824pub struct Plugin;
3825
3826impl SourcePlugin for Plugin {
3827 fn kind(&self) -> &'static str {
3828 KIND
3829 }
3830 /// The endpoint, the owner and the board's number: what names one board.
3831 fn remote_identity(&self, config: &Value) -> Option<String> {
3832 let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
3833 Some(format!(
3834 "{}\n{}\n{}",
3835 config.endpoint, config.owner, config.project_number
3836 ))
3837 }
3838 fn config_schema(&self) -> Schema {
3839 schema_for!(GitHubProjectsConfig)
3840 }
3841 fn build(
3842 &self,
3843 name: &SourceName,
3844 config: &Value,
3845 secrets: &dyn SecretResolver,
3846 ) -> Result<Box<dyn TaskSource>, SourceError> {
3847 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
3848 }
3849
3850 fn build_with_clock(
3851 &self,
3852 name: &SourceName,
3853 config: &Value,
3854 secrets: &dyn SecretResolver,
3855 clock: SharedClock,
3856 ) -> Result<Box<dyn TaskSource>, SourceError> {
3857 self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
3858 }
3859}
3860
3861/// The client `cell` holds, building it with `build` the first time it is asked for.
3862///
3863/// A failed build is the request's failure, with the message the source's construction gave it
3864/// before clients were deferred.
3865// llmlint: ignore[changed_behavior_has_e2e] A client build fails only when the TLS backend cannot initialise, which nothing outside the process can induce: a certificate bundle of garbage still builds one. That the build is deferred is proven end to end by `store_startup::a_command_builds_no_client_for_a_source_it_sends_nothing`.
3866fn built(
3867 cell: &std::sync::OnceLock<Client>,
3868 build: impl FnOnce() -> Result<Client, SourceError>,
3869) -> Result<&Client, SourceError> {
3870 if let Some(client) = cell.get() {
3871 return Ok(client);
3872 }
3873 let client = build()?;
3874 Ok(cell.get_or_init(|| client))
3875}
3876
3877impl Plugin {
3878 /// Build a source recording every request it sends into an accounting the caller holds.
3879 ///
3880 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
3881 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
3882 /// session total rather than two — see [`accounting`] and
3883 /// [`GitHubProjectsSource::recording_into`].
3884 ///
3885 /// # Errors
3886 ///
3887 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
3888 /// [`SourceError::Config`] for configuration this plugin cannot use and
3889 /// [`SourceError::Auth`] for a credential it cannot find.
3890 pub fn build_recording_into(
3891 &self,
3892 name: &SourceName,
3893 config: &Value,
3894 secrets: &dyn SecretResolver,
3895 ledger: Arc<Accounting>,
3896 ) -> Result<Box<dyn TaskSource>, SourceError> {
3897 self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
3898 }
3899
3900 fn build_recording_with_clock(
3901 &self,
3902 name: &SourceName,
3903 config: &Value,
3904 secrets: &dyn SecretResolver,
3905 ledger: Arc<Accounting>,
3906 clock: SharedClock,
3907 ) -> Result<Box<dyn TaskSource>, SourceError> {
3908 let config: GitHubProjectsConfig =
3909 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
3910 message: format!("source {name}: {e}"),
3911 })?;
3912 let prefix = format!("source {name}: ");
3913 let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
3914 .map_err(|error| match error {
3915 // The shared `StatusMapping::distinct` names the source itself.
3916 SourceError::Config { message } if message.starts_with(&prefix) => {
3917 SourceError::Config { message }
3918 }
3919 SourceError::Config { message } => SourceError::Config {
3920 message: format!("{prefix}{message}"),
3921 },
3922 SourceError::Auth { message } => SourceError::Auth {
3923 message: format!("source {name}: {message}"),
3924 },
3925 other => other,
3926 })?;
3927 source.clock = clock;
3928 Ok(Box::new(source))
3929 }
3930}
3931
3932/// Where a status category lands on this board, once configuration is resolved.
3933#[derive(Debug, Clone, PartialEq, Eq)]
3934enum StatusTarget {
3935 /// Not usable against this instance for this kind, and why.
3936 Disabled(UnmappedStatus),
3937 /// The board's `Status` option of this name.
3938 Column(ColumnName),
3939 /// A closed issue, with both its board option and the reason that says which closed it means.
3940 // 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.
3941 Terminal(ColumnName, ClosedState),
3942}
3943
3944/// Every status category, in the order the vocabulary declares them.
3945///
3946/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
3947/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
3948/// added to the shared vocabulary fails to compile until it is named there, and this
3949/// crate's suite reconciles this list against that enum's own derived schema, which is
3950/// generated from the variants rather than written beside them. The schema is what
3951/// catches a list left one short — a list checking only the positions it already holds
3952/// would pass while every mapping indexed by the new position panicked.
3953pub const CATEGORIES: [StatusCategory; 8] = [
3954 StatusCategory::Draft,
3955 StatusCategory::Backlog,
3956 StatusCategory::Todo,
3957 StatusCategory::Queued,
3958 StatusCategory::InProgress,
3959 StatusCategory::Done,
3960 StatusCategory::Cancelled,
3961 StatusCategory::Unknown,
3962];
3963
3964/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
3965#[must_use]
3966pub const fn category_position(category: StatusCategory) -> usize {
3967 match category {
3968 StatusCategory::Draft => 0,
3969 StatusCategory::Backlog => 1,
3970 StatusCategory::Todo => 2,
3971 StatusCategory::Queued => 3,
3972 StatusCategory::InProgress => 4,
3973 StatusCategory::Done => 5,
3974 StatusCategory::Cancelled => 6,
3975 StatusCategory::Unknown => 7,
3976 }
3977}
3978
3979/// The spelling a status category is configured and reported under.
3980fn category_name(category: StatusCategory) -> &'static str {
3981 match category {
3982 StatusCategory::Draft => "draft",
3983 StatusCategory::Backlog => "backlog",
3984 StatusCategory::Todo => "todo",
3985 StatusCategory::Queued => "queued",
3986 StatusCategory::InProgress => "in-progress",
3987 StatusCategory::Done => "done",
3988 StatusCategory::Cancelled => "cancelled",
3989 StatusCategory::Unknown => "unknown",
3990 }
3991}
3992
3993/// A shipped default's option name.
3994///
3995/// The literals below are this file's own and non-blank, and they are validated by the
3996/// one constructor a configured name goes through rather than beside it.
3997fn shipped_column(name: &'static str) -> ColumnName {
3998 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
3999}
4000
4001/// The shipped default for one category this instance's `status_mapping` does not mention,
4002/// for either kind.
4003fn shipped_default(category: StatusCategory) -> StatusTarget {
4004 match category {
4005 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
4006 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
4007 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
4008 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
4009 StatusCategory::Done => {
4010 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
4011 }
4012 StatusCategory::Cancelled => {
4013 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
4014 }
4015 StatusCategory::Draft | StatusCategory::Unknown => {
4016 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
4017 }
4018 }
4019}
4020
4021/// The two kinds a status is written and read for, each with its own half of the mapping.
4022const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
4023
4024/// This instance's complete category-to-target mapping for each kind, read in both
4025/// directions.
4026///
4027/// One target per category per kind, held at that category's own [`category_position`], so
4028/// a category missing from the mapping, named twice in it, or filed out of order is a state
4029/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
4030/// targets are options of the board's one `Status` field.
4031#[derive(Debug, Clone)]
4032struct BoardStatuses {
4033 tasks: [StatusTarget; CATEGORIES.len()],
4034 projects: [StatusTarget; CATEGORIES.len()],
4035}
4036
4037impl BoardStatuses {
4038 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
4039 /// would read back from one option.
4040 ///
4041 /// A category the mapping does not mention keeps its shipped default for both kinds; one
4042 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
4043 /// omits unmapped rather than defaulted.
4044 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
4045 let resolve_kind =
4046 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
4047 // `CATEGORIES[position] == category` for every category — the crate's suite
4048 // asserts it — so mapping the list in order fills each category's own slot.
4049 let mut targets = CATEGORIES.map(shipped_default);
4050 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
4051 if !configured.mentions(category) {
4052 continue;
4053 }
4054 *slot = match configured.name_for(category, kind) {
4055 Err(why) => StatusTarget::Disabled(why),
4056 Ok(name) => {
4057 let option = ColumnName::try_from(name.as_str().to_owned())
4058 .map_err(|message| SourceError::Config { message })?;
4059 match category {
4060 StatusCategory::Done => {
4061 StatusTarget::Terminal(option, ClosedState::Completed)
4062 }
4063 StatusCategory::Cancelled => {
4064 StatusTarget::Terminal(option, ClosedState::NotPlanned)
4065 }
4066 _ => StatusTarget::Column(option),
4067 }
4068 }
4069 };
4070 }
4071 StatusMapping::distinct(
4072 instance,
4073 kind,
4074 CATEGORIES
4075 .iter()
4076 .zip(&targets)
4077 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
4078 )?;
4079 Ok(targets)
4080 };
4081 Ok(Self {
4082 tasks: resolve_kind(ItemKind::Task)?,
4083 projects: resolve_kind(ItemKind::Project)?,
4084 })
4085 }
4086
4087 /// Every category's target for `kind`, in category order.
4088 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
4089 match kind {
4090 ItemKind::Task => &self.tasks,
4091 ItemKind::Project => &self.projects,
4092 }
4093 }
4094
4095 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
4096 &self.targets(kind)[category_position(category)]
4097 }
4098
4099 /// The category a board option name reports for `kind`, or `None` when nothing of that
4100 /// kind maps to it.
4101 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
4102 CATEGORIES.into_iter().find(|category| {
4103 self.target(kind, *category)
4104 .option()
4105 .is_some_and(|name| name.eq_ignore_ascii_case(option))
4106 })
4107 }
4108
4109 /// Every option name either kind maps a category to, each once ignoring case, in
4110 /// category order with a task's name before a project's — what the guarded setup asks
4111 /// the `Status` field to hold.
4112 fn wanted(&self) -> Vec<String> {
4113 let mut wanted: Vec<String> = Vec::new();
4114 for category in CATEGORIES {
4115 for kind in STATUS_KINDS {
4116 if let Some(name) = self.target(kind, category).option()
4117 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
4118 {
4119 wanted.push(name.to_owned());
4120 }
4121 }
4122 }
4123 wanted
4124 }
4125
4126 /// The status an item of `kind` reports, from the three things a read of it says: its
4127 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
4128 ///
4129 /// **A closed issue's category follows its closure**, by [`closed_category`]: completed
4130 /// or no reason reads `done`, not planned and duplicate read `cancelled`, and any other
4131 /// reason reads `unknown`. `REOPENED`-while-closed is a state this source can never
4132 /// produce, so it is read permissively rather than refused — reads are faithful, and
4133 /// refusals belong on writes.
4134 ///
4135 /// **Its name follows the closure too, unless the option agrees with it.** The option's
4136 /// own name is kept when this kind's mapping reads that option as the same category, or
4137 /// does not name it at all — so a closed issue in a "Shipped" column reports `done`
4138 /// named `Shipped`. An option the mapping reads as another category contradicts the
4139 /// closure, and so does no option at all: the name is then the option the mapping gives
4140 /// the closure's category, so an issue closed as completed at `Todo` reads `done` named
4141 /// by the board's `done` option, and a destination reading `Todo` as todo is never handed
4142 /// it. An `unknown` closure keeps the option, or `Closed` without one.
4143 ///
4144 /// An open item's option reads through its own kind's mapping, and an option that mapping
4145 /// does not name reads as `Unknown` under its own name.
4146 ///
4147 /// One function of those three rather than of a response, so a narrow status write can
4148 /// answer what a re-read would report by applying it to the state it has just written.
4149 fn status(
4150 &self,
4151 kind: ItemKind,
4152 option: Option<&str>,
4153 closed: bool,
4154 reason: Option<&str>,
4155 ) -> Status {
4156 if closed {
4157 let category = closed_category(reason);
4158 let agrees = option.filter(|option| {
4159 category == StatusCategory::Unknown
4160 || self
4161 .category_of(kind, option)
4162 .is_none_or(|read| read == category)
4163 });
4164 let name = agrees.or_else(|| self.target(kind, category).option());
4165 let fallback = match category {
4166 StatusCategory::Done => "Done",
4167 StatusCategory::Cancelled => "Cancelled",
4168 _ => "Closed",
4169 };
4170 return Status {
4171 category,
4172 name: name.or(option).unwrap_or(fallback).to_owned(),
4173 };
4174 }
4175 let name = option.unwrap_or("Open").to_owned();
4176 Status {
4177 category: self
4178 .category_of(kind, &name)
4179 .unwrap_or(StatusCategory::Unknown),
4180 name,
4181 }
4182 }
4183
4184 /// The option a closed item's `Status` has to hold for its card to agree with its
4185 /// closure, and the category that closure reads as — or `None` when the item is open,
4186 /// its closure reads `unknown`, or its option already agrees.
4187 ///
4188 /// What `task status reconcile` writes: only `done` and `cancelled` closures are brought
4189 /// in line, each to the one option the mapping gives its category — an item with no
4190 /// option, or at one the mapping does not name, included.
4191 fn contradicted(
4192 &self,
4193 kind: ItemKind,
4194 option: Option<&str>,
4195 closed: bool,
4196 reason: Option<&str>,
4197 ) -> Option<(ClosedAs, &str)> {
4198 if !closed {
4199 return None;
4200 }
4201 let closed_as = match closed_category(reason) {
4202 StatusCategory::Done => ClosedAs::Done,
4203 StatusCategory::Cancelled => ClosedAs::Cancelled,
4204 _ => return None,
4205 };
4206 let wanted = self.target(kind, closed_as.category()).option()?;
4207 if option.is_some_and(|option| option.eq_ignore_ascii_case(wanted)) {
4208 return None;
4209 }
4210 Some((closed_as, wanted))
4211 }
4212}
4213
4214/// The category a closed issue reads as, from the reason it was closed with.
4215///
4216/// `COMPLETED`, and no reason at all, is finished work; `NOT_PLANNED` and `DUPLICATE` are both
4217/// work nobody will do here — a duplicate's work is the other issue's — and any other reason
4218/// is one this source cannot read a category from.
4219fn closed_category(reason: Option<&str>) -> StatusCategory {
4220 match reason {
4221 None | Some("COMPLETED") => StatusCategory::Done,
4222 Some("NOT_PLANNED" | "DUPLICATE") => StatusCategory::Cancelled,
4223 Some(_) => StatusCategory::Unknown,
4224 }
4225}
4226
4227impl BoardStatuses {
4228 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
4229 /// case; a kind lacking none is left out.
4230 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
4231 STATUS_KINDS
4232 .into_iter()
4233 .filter_map(|kind| {
4234 let missing: Vec<String> = self
4235 .targets(kind)
4236 .iter()
4237 .filter_map(StatusTarget::option)
4238 .filter(|wanted| {
4239 !existing
4240 .iter()
4241 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4242 })
4243 .map(str::to_owned)
4244 .collect();
4245 (!missing.is_empty()).then_some(KindMissing { kind, missing })
4246 })
4247 .collect()
4248 }
4249}
4250
4251impl StatusTarget {
4252 /// The board option this target selects, or `None` for an unmapped one.
4253 fn option(&self) -> Option<&str> {
4254 match self {
4255 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
4256 Self::Disabled(_) => None,
4257 }
4258 }
4259}
4260
4261// 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.
4262/// One repository this source can create an issue in, as `owner/name`.
4263///
4264/// Every `createIssue` this source sends names one of these: the item's own single
4265/// `repositories` entry — on a source declared private, only when it reads private — else
4266/// its parent project issue's repository, else the configured
4267/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
4268/// that choice and says what it refuses before `createIssue`.
4269// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
4270#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
4271#[serde(try_from = "String", into = "String")]
4272struct RepositoryTarget {
4273 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4274 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
4275}
4276
4277impl RepositoryTarget {
4278 fn parse(value: &str) -> Result<Self, SourceError> {
4279 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
4280 message: format!(
4281 "repository must be spelled owner/name; {value:?} names no repository"
4282 ),
4283 })?;
4284 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
4285 return Err(SourceError::Config {
4286 message: format!(
4287 "repository must be spelled owner/name with a GitHub login and one \
4288 repository name; {value:?} is not"
4289 ),
4290 });
4291 }
4292 Ok(Self {
4293 owner: owner.to_owned(),
4294 name: name.to_owned(),
4295 })
4296 }
4297
4298 /// The one host whose repositories this source creates issues in, spelled once: it is
4299 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
4300 const HOST: &str = "github.com";
4301
4302 fn origin(&self) -> String {
4303 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
4304 }
4305
4306 /// The repository a normalized origin names, or why it is none this source can create
4307 /// an issue in: another host, or more or fewer than `owner/name` under this one.
4308 fn from_origin(origin: &Repository) -> Result<Self, String> {
4309 let not_here = || {
4310 format!(
4311 "{} is not a {}/owner/name repository",
4312 origin.as_str(),
4313 Self::HOST
4314 )
4315 };
4316 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
4317 if host != Self::HOST {
4318 return Err(not_here());
4319 }
4320 Self::parse(rest).map_err(|_| not_here())
4321 }
4322
4323 fn slug(&self) -> String {
4324 format!("{}/{}", self.owner, self.name)
4325 }
4326}
4327
4328/// Read back from a kept context as the `owner/name` it was written as, refused unless
4329/// [`RepositoryTarget::parse`] takes it.
4330impl TryFrom<String> for RepositoryTarget {
4331 type Error = String;
4332
4333 fn try_from(slug: String) -> Result<Self, Self::Error> {
4334 Self::parse(&slug).map_err(|error| error.to_string())
4335 }
4336}
4337
4338impl From<RepositoryTarget> for String {
4339 fn from(target: RepositoryTarget) -> Self {
4340 target.slug()
4341 }
4342}
4343
4344/// How long GitHub's issue search may go on missing a comment after it was made or edited,
4345/// which a read of the comments made since an instant sets its watermark back by: four times the
4346/// thirty seconds the credentialed lane has watched it lag.
4347pub const COMMENT_SEARCH_LAG: std::time::Duration = std::time::Duration::from_secs(120);
4348
4349/// A source which reads GitHub afresh for every operation.
4350pub struct GitHubProjectsSource {
4351 /// This source's configured name, used both to tell a far end naming this source
4352 /// from one naming a system it knows nothing about, and to name the instance a
4353 /// status refusal is about.
4354 name: SourceName,
4355 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
4356 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
4357 repository: Option<RepositoryTarget>,
4358 /// The logins whose repositories a create may file in; empty restricts nothing.
4359 creation_owners: Vec<String>, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after each entry passes GitHub's login grammar.
4360 endpoint: Url,
4361 token: SecretString,
4362 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
4363 statuses: BoardStatuses,
4364 /// Where each priority lands on this board, or `None` when this instance holds none.
4365 priorities: Option<PriorityMapping>,
4366 /// The metadata values this instance projects onto board text fields, in configured order.
4367 metadata_fields: Vec<MetadataField>,
4368 /// What this board's sub-issues mean.
4369 sub_issues: SubIssues,
4370 /// The link types this source mirrors as `relates-to`.
4371 link_types: onetaskgraph_plugin_api::LinkTypes,
4372 /// Built on the first request rather than with the source, as [`Self::client`] says.
4373 client: std::sync::OnceLock<Client>,
4374 /// Built on the first attachment request, as [`Self::asset_client`] says.
4375 asset_client: std::sync::OnceLock<Client>,
4376 /// The markup the engine handed this source for its bodies' headings, applied to every
4377 /// visible body it composes and stripped from every one it reads back: see
4378 /// [`TaskSource::attach_body_markup`].
4379 body_markup: std::sync::OnceLock<onetaskgraph_plugin_api::SharedBodyMarkup>,
4380 /// Every item this source has created in this command, in the order it created them —
4381 /// dropped by [`TaskSource::end_command`].
4382 ///
4383 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
4384 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
4385 /// a copy resolving a dependency on an item it had just created refused it as not
4386 /// found. A board read is completed from this — an item remembered here and absent from
4387 /// the read is added back, because the board really does hold it and only the read is
4388 /// behind.
4389 ///
4390 /// It is not a cache of a user's work: nothing is remembered that this process did not
4391 /// itself just write, it lives and dies with the process, and it is never consulted for
4392 /// an item this source did not create.
4393 created: Mutex<Vec<Resolved>>,
4394 /// Every item that already existed and that this source has written in this command, as
4395 /// it wrote it — dropped by [`TaskSource::end_command`].
4396 ///
4397 /// The other half of [`Self::created`], held on the same terms and for the reason a
4398 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
4399 /// filter is an index behind a write this process made moments ago, so a query matching
4400 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
4401 /// is remembered that this process did not itself just write.
4402 updated: Mutex<Vec<Resolved>>,
4403 /// Every issue this source has added a comment to or edited a comment of in this command
4404 /// — dropped by [`TaskSource::end_command`].
4405 ///
4406 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
4407 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
4408 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
4409 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
4410 /// from before — until the index caught up. Each id here is a candidate of every such
4411 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
4412 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
4413 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
4414 /// process wrote is still found only once the index has it.
4415 commented: Mutex<Vec<NativeId>>,
4416 /// How fast this source writes, and how long it waits out a refusal.
4417 pacing: Pacing,
4418 /// When the last content-creating mutation finished, or the moment the furthest-out
4419 /// reserved slot releases the next one, whichever is later — so the one after it can be
4420 /// spaced from that, when `pacing.min_mutation_interval_ms` asks for a spacing. See
4421 /// [`GitHubProjectsSource::paced`] for why completion rather than release is what it is
4422 /// measured from.
4423 last_mutation: Mutex<Option<Duration>>,
4424 /// The `Date` GitHub stated on the last answer this source read: its own time, which the
4425 /// watermark of a comment read is set back from. See [`TaskSource::served_at`].
4426 last_served: Mutex<Option<DateTime<Utc>>>,
4427 /// GitHub's secondary windows as this source has charged them; see the `pacing` module.
4428 windows: Mutex<pacing::Windows>,
4429 /// The tasks a project holds, as a copy's batched read of the project and every one of them
4430 /// found them — see [`TaskSource::read_ahead_for_copy`] — for the length of one command, so
4431 /// a listing of that project's tasks is answered without a read of its own.
4432 task_children: Mutex<BTreeMap<NativeId, Vec<Resolved>>>,
4433 /// The web address of each issue this source created in the current command, as its
4434 /// `createIssue` answered it — what [`TaskSource::written_location`] answers, without a
4435 /// request.
4436 written: Mutex<BTreeMap<NativeId, String>>,
4437 /// What a copy has held back of the items it created, between
4438 /// [`TaskSource::begin_deferred_writes`] and [`TaskSource::finish_writes`] — `None` while
4439 /// nothing is being held back, when every write lands whole as it is made.
4440 deferred: Mutex<Option<Deferred>>,
4441 /// Each existing issue's update held back while writes are, in the order it was made: sent
4442 /// together by [`Self::send_held_updates`] before any other request goes out, so nothing
4443 /// this source sends or reads ever sees the board without them.
4444 held_updates: Mutex<Vec<HeldUpdate>>,
4445 /// The first page of comments a comment-activity read read of each candidate it confirmed,
4446 /// held for the one comment listing of that task that follows in this command — see
4447 /// `batch::hold_comment_pages`.
4448 held_comments: Mutex<BTreeMap<NativeId, Page<Comment>>>,
4449 /// The board field writes and issue updates a batch of targeted updates is holding back,
4450 /// while one runs — see `batch::TaskWrites`.
4451 task_writes: Mutex<Option<batch::TaskWrites>>,
4452 /// The change token each task a targeted update wrote in the current command holds after
4453 /// that write, by the task's id with its board item's id — spelled from the write's own
4454 /// last response, or from the read made right after it where no response showed the whole
4455 /// issue: what [`TaskSource::written_task`] answers as its `version`. Any later mutation
4456 /// naming the task or its board item drops its entry before it is sent, so an entry is
4457 /// never one write behind.
4458 /// A response that showed the issue whole but not every part of its token — a draft, or a
4459 /// page of labels or field values that did not end — records `None`, which a read would
4460 /// not improve on.
4461 written_tokens: Mutex<BTreeMap<NativeId, (String, Option<String>)>>,
4462 clock: SharedClock,
4463 /// Whether the configuration declares this source private, as
4464 /// [`TaskSource::declare_visibility`] told it: what decides where a new issue whose one
4465 /// named repository reads public is created — see [`Self::creation_rule`].
4466 declared_private: std::sync::atomic::AtomicBool,
4467 numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
4468 /// The board as this process last read it, for the length of one command — dropped by
4469 /// [`TaskSource::end_command`].
4470 ///
4471 /// A copy of a project used to re-read the whole board, paged, before writing each of
4472 /// its items, which is by far the largest part of a copy's request count and none of
4473 /// its work. Nothing else changes this board while a command runs — this source's own
4474 /// writes are the only writer — so one read answers them all.
4475 ///
4476 /// It is not a store of a user's work and it is not the cache the no-persistence
4477 /// invariant forbids: it lives and dies with the process exactly as `created` does,
4478 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
4479 /// an item this command created and then depends on resolves whether or not GitHub's
4480 /// own eventually-consistent read has caught up. A write to an item already on the
4481 /// board updates the entry here too, so what this holds is the last read plus this
4482 /// process's own writes rather than a snapshot taken before them.
4483 board_cache: Mutex<Option<Board>>,
4484 /// Every issue this board's own search reported, for the length of one command — dropped
4485 /// by [`TaskSource::end_command`].
4486 ///
4487 /// The second half of a board read, and cached for the same reason and on the same
4488 /// terms as the first: it lives and dies with the process, nothing is written down, and
4489 /// a write this process makes updates the entry here exactly as it updates the one in
4490 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
4491 /// that lists this board's projects and its tasks pays for one search rather than two.
4492 search_cache: Mutex<Option<Vec<Resolved>>>,
4493 /// What each narrowed question GitHub was asked answered, keyed by that question, for
4494 /// the length of one command — dropped by [`TaskSource::end_command`].
4495 ///
4496 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
4497 /// and dies with the process, nothing is written down, a write this process makes
4498 /// updates the entry here as it updates the other two, and every answer is completed
4499 /// with this process's own writes each time it is given. A command that asks the same
4500 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
4501 /// write — pays for it once, which is what the whole-board read it replaced gave it.
4502 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
4503 search_next: Mutex<BTreeMap<String, Option<String>>>,
4504 /// Records already resolved in this command, reused by writes and for comment identity.
4505 /// Explicit item reads still reach GitHub. Nothing is persisted, and
4506 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
4507 /// its item as a person has since left it.
4508 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4509 /// What [`TaskSource::read_ahead_for_copy`] read, by id — `None` for an id that read
4510 /// answered as holding nothing — each taken by the next read of that item by id and by
4511 /// nothing else, dropped by any mutation naming it, and cleared by
4512 /// [`TaskSource::end_command`]. So a read answered from it is the copy's own
4513 /// read-before-write, made in the same request as the others' rather than on its own.
4514 read_ahead: Mutex<BTreeMap<NativeId, Option<Resolved>>>,
4515 /// The origins [`TaskSource::read_ahead_origins_for_copy`] was asked about and has not sent
4516 /// yet: held for the [`TaskSource::read_ahead_for_copy`] that follows it, which sends them —
4517 /// beside the members of the one project it names, when it names one — or for the next
4518 /// request this source makes, whichever comes first.
4519 pending_origins: Mutex<Vec<String>>,
4520 /// Each project's members as a lookup carrying them read them in this command, trimmed to
4521 /// their classification and repositories, taken by [`TaskSource::member_classifications`]
4522 /// of that project and by nothing else, and cleared by [`TaskSource::end_command`].
4523 member_classes: Mutex<BTreeMap<NativeId, Vec<MemberClass>>>,
4524 /// Every item a listing of one project's members carried whole in this command — the
4525 /// project issue and each of its sub-issues — kept for [`TaskSource::read_ahead_for_copy`]
4526 /// alone, which takes each one it is asked for into [`Self::read_ahead`] instead of reading
4527 /// it again. No other read is answered from it, so a read by id outside a copy's
4528 /// read-before-write reads GitHub as it always did. Dropped by any mutation naming an
4529 /// item, and cleared by [`TaskSource::end_command`].
4530 carried: Mutex<BTreeMap<NativeId, Resolved>>,
4531 /// Items an issue read by reference reached, held for the one status write each was read
4532 /// for and taken out by it alone — see [`Self::take_held`] — because that read selects only
4533 /// what such a write needs. Dropped by [`TaskSource::end_command`].
4534 held_cache: Mutex<BTreeMap<NativeId, Resolved>>,
4535 /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
4536 /// asked for under, with the project that selector named — for the length of one command,
4537 /// dropped by [`TaskSource::end_command`].
4538 ///
4539 /// A caller pages through a project's tasks one engine page at a time, and every page
4540 /// is cut from the whole list of them, so without this each page walked every
4541 /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
4542 /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
4543 /// dies with the process, nothing is written down, a write this process makes updates or
4544 /// removes the entry here as it does there, and every answer is completed with this
4545 /// process's own writes each time it is given.
4546 children_cache: Mutex<ProjectChildren>,
4547 /// The board's own id and field definitions as this process last read them on their
4548 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
4549 ///
4550 /// What a write needs of the board and its item does not say, read once per command
4551 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
4552 /// lives and dies with the process and nothing is written down. It holds no item and so
4553 /// can answer no question about one — see [`Self::board_fields`].
4554 fields_cache: Mutex<Option<BoardFields>>,
4555 /// Each destination repository's node id, resolved once per repository
4556 /// rather than per issue created.
4557 ///
4558 /// A repository's node id does not change, and re-reading it for every issue of a copy
4559 /// spent one request per item on an answer this source already had. It is a map rather
4560 /// than one entry because a copy files each item in the repository its own
4561 /// `repositories` field names, so a plan across five repositories asks GitHub five
4562 /// times and not once per item.
4563 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
4564 /// What every request this source sends is recorded into.
4565 ///
4566 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
4567 /// a request leaves this crate, so nothing has to be switched on for a session to be
4568 /// counted. It is shared rather than owned so a caller accounting for a whole session —
4569 /// its own schema verification, board lookups, residue sweep and cleanup beside this
4570 /// source's reads and writes — adds up one accounting instead of two. See
4571 /// [`accounting`] for what a record carries and what a session's spend is and is not.
4572 ledger: Arc<Accounting>,
4573 /// What [`expected`] holds for a write the store's cache expects: the journal while one is
4574 /// open, the issues primed from the cache, and the comments known on each.
4575 expected: expected::Held,
4576 /// What [`projection`] holds for a producer's projection: a kept placement awaiting its
4577 /// setup-context token, and where the last create was placed.
4578 projection: projection::Held,
4579}
4580
4581/// The board item a single field update's answer names, refused unless it is `item_id`.
4582fn field_update_item<'a>(data: &'a Value, item_id: &str) -> Result<&'a Value, SourceError> {
4583 let returned = data
4584 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
4585 .ok_or_else(|| SourceError::Malformed {
4586 message: "GitHub field update returned no project item".into(),
4587 })?;
4588 if required_str(returned, "id")? != item_id {
4589 return Err(SourceError::Malformed {
4590 message: "GitHub field update returned the wrong project item".into(),
4591 });
4592 }
4593 Ok(returned)
4594}
4595
4596/// GitHub's closed single-select color vocabulary.
4597#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
4598#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
4599pub enum StatusOptionColor {
4600 /// Gray.
4601 Gray,
4602 /// Blue.
4603 Blue,
4604 /// Green.
4605 Green,
4606 /// Yellow.
4607 Yellow,
4608 /// Purple.
4609 Purple,
4610 /// Red.
4611 Red,
4612 /// Orange.
4613 Orange,
4614 /// Pink.
4615 Pink,
4616}
4617
4618/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
4619/// applies its additions.
4620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4621pub enum SetupMode {
4622 /// Read without mutation.
4623 Plan,
4624 /// Apply and verify.
4625 Apply,
4626}
4627
4628/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
4629/// against it goes on compiling.
4630pub type StatusOptionsMode = SetupMode;
4631
4632/// The explicit result of the requested operation.
4633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4634#[serde(rename_all = "kebab-case")]
4635pub enum StatusOptionsOutcome {
4636 /// A read-only plan.
4637 Planned,
4638 /// Apply found nothing missing.
4639 Unchanged,
4640 /// Additions were applied and verified.
4641 Applied,
4642}
4643
4644/// A GitHub single-select option's opaque GraphQL node identifier.
4645#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4646#[serde(transparent)]
4647pub struct StatusOptionId(#[schemars(length(min = 1))] String);
4648
4649impl TryFrom<String> for StatusOptionId {
4650 type Error = String;
4651
4652 fn try_from(id: String) -> Result<Self, Self::Error> {
4653 if id.trim().is_empty() {
4654 return Err("a GitHub Status option id cannot be blank".to_owned());
4655 }
4656 Ok(Self(id))
4657 }
4658}
4659
4660/// One existing or proposed option in a guarded Status-field update.
4661#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4662pub struct StatusOption {
4663 /// GitHub's stable id.
4664 pub id: StatusOptionId,
4665 /// The visible option name.
4666 pub name: ColumnName,
4667 /// GitHub's single-select color token.
4668 pub color: StatusOptionColor,
4669 /// The option description, including an empty one.
4670 pub description: String,
4671}
4672
4673/// One board item's Status assignment, retained as recovery data.
4674#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4675pub struct StatusAssignment {
4676 /// The project item id whose assignment this is.
4677 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
4678 // carried verbatim as operator recovery data; introducing a semantic type would claim
4679 // validation rules GitHub does not publish and no operation here interprets.
4680 pub item_id: String,
4681 /// The selected option, absent when the item has no status.
4682 #[serde(skip_serializing_if = "Option::is_none")]
4683 pub option: Option<AssignedStatusOption>,
4684}
4685
4686/// The inseparable id and name of an assigned option.
4687#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4688pub struct AssignedStatusOption {
4689 /// GitHub's stable id.
4690 pub id: StatusOptionId,
4691 /// The visible name.
4692 pub name: ColumnName,
4693}
4694
4695/// The plan and verified outcome of reconciling configured Status options.
4696#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4697pub struct StatusOptionsReport {
4698 /// The configured source name.
4699 pub source: SourceName,
4700 /// Configured option names absent before the operation.
4701 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
4702 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
4703 // serialized string here preserves the report's intentionally simple public contract.
4704 pub missing: Vec<String>,
4705 /// What the requested operation did.
4706 pub outcome: StatusOptionsOutcome,
4707 /// The complete option list observed before any mutation.
4708 pub existing: Vec<StatusOption>,
4709}
4710
4711#[derive(Debug, Clone, PartialEq, Eq)]
4712struct StatusSnapshot {
4713 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4714 // passed back as the mutation's project identity; a newtype could enforce no stronger
4715 // invariant because GitHub publishes no grammar for it.
4716 board_id: String,
4717 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4718 // passed back as the mutation's field identity; a newtype could enforce no stronger
4719 // invariant because GitHub publishes no grammar for it.
4720 field_id: String,
4721 options: Vec<StatusOption>,
4722 assignments: Vec<StatusAssignment>,
4723}
4724
4725/// The name of the board field a status is held in.
4726const STATUS_FIELD: &str = "Status";
4727
4728/// Every item's value of each field `report` names, as it stood before the setup wrote
4729/// anything — what a person puts back when the setup is refused part way.
4730fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
4731 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
4732 .fields
4733 .iter()
4734 .map(|field| (field.field.name(), before.assignments(field.field)))
4735 .collect();
4736 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
4737 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
4738 })
4739}
4740
4741/// One board field the guarded setup reads and writes — every one it reads, and the only
4742/// ones it writes.
4743#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
4744pub enum BoardField {
4745 /// The single-select `Status` field every instance's `status_mapping` resolves into.
4746 Status,
4747 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
4748 Priority,
4749}
4750
4751impl BoardField {
4752 /// The field's name on the board.
4753 #[must_use]
4754 pub const fn name(self) -> &'static str {
4755 match self {
4756 Self::Status => STATUS_FIELD,
4757 Self::Priority => PRIORITY_FIELD,
4758 }
4759 }
4760
4761 /// The field a board calls `name`, or `None` for one this setup does not own.
4762 fn named(name: &str) -> Option<Self> {
4763 [Self::Status, Self::Priority]
4764 .into_iter()
4765 .find(|field| field.name() == name)
4766 }
4767}
4768
4769/// What the guarded setup did to one field.
4770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4771#[serde(rename_all = "kebab-case")]
4772pub enum FieldOutcome {
4773 /// A read-only plan.
4774 Planned,
4775 /// Apply found the field there with every configured option.
4776 Unchanged,
4777 /// Missing options were added to the field that was there, and verified.
4778 Applied,
4779 /// The field was not there; it was created holding the configured options, and verified.
4780 Created,
4781}
4782
4783/// One field's plan, or its verified outcome.
4784#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4785pub struct FieldReport {
4786 /// Which field.
4787 pub field: BoardField,
4788 /// Whether the board had the field before the operation.
4789 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
4790 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
4791 // "outcome", "existing"}` — so folding one into the other would change a published JSON
4792 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
4793 // one constructor, and it derives `outcome` from `exists` in one match.
4794 pub exists: bool,
4795 /// Configured option names the field lacked before the operation — every one of them,
4796 /// in the order a new field lists them, when the field was not there at all.
4797 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4798 // mapping name and has therefore already passed its nonblank validation; the serialized
4799 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
4800 pub missing: Vec<String>,
4801 /// For the `Status` field, which item kind each missing name is configured for: one
4802 /// entry per kind `status_mapping` names a missing option for, task before project, each
4803 /// listing that kind's missing names in category order. A name both kinds use is in
4804 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
4805 /// `Priority`, which only a task holds.
4806 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4807 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4808 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
4809 #[schemars(!skip_serializing_if)]
4810 pub kinds: Vec<KindMissing>,
4811 /// What the requested operation did.
4812 pub outcome: FieldOutcome,
4813 /// The field's complete option list observed before any mutation; empty when the field
4814 /// was not there.
4815 pub existing: Vec<StatusOption>,
4816}
4817
4818/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
4819#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4820pub struct KindMissing {
4821 /// The kind these names are configured for.
4822 pub kind: ItemKind,
4823 /// The names that kind maps a category to and the field lacked, in category order.
4824 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
4825 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
4826 // intentionally simple public contract.
4827 pub missing: Vec<String>,
4828}
4829
4830/// The plan and verified outcome of setting up every field a source's configuration names.
4831#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4832pub struct FieldsReport {
4833 /// The configured source name.
4834 pub source: SourceName,
4835 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
4836 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
4837 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
4838 // per field would change a published JSON shape. The states the list could hold and the
4839 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
4840 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
4841 pub fields: Vec<FieldReport>,
4842 /// Each board text field the source's `metadata_fields` projects a value onto, in
4843 /// configured order. Empty — and left out of the JSON — when it projects none.
4844 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4845 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
4846 // both SDKs model an absent list as an empty one rather than as `null`.
4847 #[schemars(!skip_serializing_if)]
4848 pub metadata_fields: Vec<MetadataFieldReport>,
4849}
4850
4851/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
4852/// its GraphQL type when it has none — which is what a projected field's conflict reports.
4853///
4854/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
4855/// no conflict.
4856// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
4857// refuses exactly what `try_from` below does.
4858#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4859#[serde(transparent)]
4860pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
4861
4862/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
4863/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
4864/// neither SDK's validator can be relied on to support.
4865const NON_TEXT_FIELD_TYPE_PATTERN: &str =
4866 r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
4867
4868impl TryFrom<String> for NonTextFieldType {
4869 type Error = String;
4870
4871 fn try_from(kind: String) -> Result<Self, Self::Error> {
4872 if kind.is_empty() || kind.starts_with(char::is_whitespace) {
4873 return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
4874 }
4875 if kind == "TEXT" {
4876 return Err("a TEXT field is no conflict for a projected text field".to_owned());
4877 }
4878 Ok(Self(kind))
4879 }
4880}
4881
4882impl std::fmt::Display for NonTextFieldType {
4883 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4884 formatter.write_str(&self.0)
4885 }
4886}
4887
4888/// One projected metadata text field's plan, or its verified outcome.
4889///
4890/// The setup creates a missing one as a text field and never alters a field that is there:
4891/// a same-named field of another type is reported in `conflict` and left as it is.
4892#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
4893pub struct MetadataFieldReport {
4894 /// The board field's name.
4895 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.
4896 /// The metadata key the value is read from.
4897 pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
4898 /// The object keys walked inside that key's value; empty for the key's own value.
4899 pub path: Vec<String>,
4900 /// Whether the board had a text field of that name before the operation.
4901 // 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.
4902 pub exists: bool,
4903 /// The type of a field of that name that is not a text field, which the setup leaves as
4904 /// it is; absent — and left out of the JSON — when there is none.
4905 #[serde(default, skip_serializing_if = "Option::is_none")]
4906 pub conflict: Option<NonTextFieldType>,
4907 /// What the requested operation did: `planned`; `created` for a field that was not
4908 /// there; `unchanged` for one that was, and for a conflict, which is never changed.
4909 pub outcome: FieldOutcome,
4910}
4911
4912/// Which options one field is configured with, in the order a new field would list them.
4913struct FieldPlan {
4914 field: BoardField,
4915 wanted: Vec<String>,
4916}
4917
4918/// One single-select field as the guarded setup snapshots it.
4919#[derive(Debug, Clone, PartialEq, Eq)]
4920struct SnapshotField {
4921 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4922 // passed back as the mutation's field identity; a newtype could enforce no stronger
4923 // invariant because GitHub publishes no grammar for it.
4924 field_id: String,
4925 options: Vec<StatusOption>,
4926}
4927
4928/// Every single-select field of a board and every item's value of each.
4929#[derive(Debug, Clone, PartialEq, Eq)]
4930struct BoardSnapshot {
4931 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
4932 // passed back as the mutation's project identity; a newtype could enforce no stronger
4933 // invariant because GitHub publishes no grammar for it.
4934 board_id: String,
4935 fields: BTreeMap<BoardField, SnapshotField>,
4936 /// Each board item's id, and its value of each field this setup owns that it holds one of.
4937 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
4938}
4939
4940impl BoardSnapshot {
4941 /// Every item's value of `field`, in board order — the recovery data a drift refusal
4942 /// carries.
4943 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
4944 self.items
4945 .iter()
4946 .map(|(item_id, values)| StatusAssignment {
4947 item_id: item_id.clone(),
4948 option: values.get(&field).cloned(),
4949 })
4950 .collect()
4951 }
4952}
4953
4954impl GitHubProjectsSource {
4955 /// `content` as this source sends it, with the markup the engine handed it applied.
4956 fn marked(&self, content: &str) -> String {
4957 match self.body_markup.get() {
4958 Some(markup) if !content.is_empty() => markup.written(content),
4959 _ => content.to_owned(),
4960 }
4961 }
4962
4963 /// The visible body and the metadata slot of `body` as [`metadata_body`] splits them,
4964 /// the visible body without the markup [`Self::marked`] applies.
4965 fn visible(
4966 &self,
4967 body: Option<String>,
4968 ) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
4969 let (visible, slot) = metadata_body(body)?;
4970 Ok((
4971 match (visible, self.body_markup.get()) {
4972 (Some(visible), Some(markup)) => {
4973 Some(markup.read(&visible)).filter(|read| !read.is_empty())
4974 }
4975 (visible, _) => visible,
4976 },
4977 slot,
4978 ))
4979 }
4980
4981 /// Report missing configured Status options and, when `apply` is true, add them with
4982 /// a whole-list mutation that preserves every existing id and verifies the result.
4983 ///
4984 /// # Errors
4985 ///
4986 /// Refuses a board without a single-select `Status` field. A post-write difference in
4987 /// any pre-existing option id or item assignment is refused with the complete pre-write
4988 /// assignment snapshot in the diagnostic for recovery.
4989 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
4990 // successful mutation, both drift refusals, source selection, missing Status, casing,
4991 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
4992 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
4993 // responses from entering the defensive malformed-response branches below.
4994 pub async fn status_options(
4995 &self,
4996 mode: StatusOptionsMode,
4997 ) -> Result<StatusOptionsReport, SourceError> {
4998 let before = self.status_snapshot().await?;
4999 // A terminal category's option is as configured as an open one's: a terminal
5000 // write validates it before closing and refuses when the board lacks it. Both
5001 // kinds' names are options of the one field, so both are asked for.
5002 let missing = self
5003 .statuses
5004 .wanted()
5005 .into_iter()
5006 .filter(|wanted| {
5007 !before
5008 .options
5009 .iter()
5010 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5011 })
5012 .collect::<Vec<_>>();
5013 let report = StatusOptionsReport {
5014 source: self.name.clone(),
5015 missing: missing.clone(),
5016 outcome: match (mode, missing.is_empty()) {
5017 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
5018 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
5019 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
5020 },
5021 existing: before.options.clone(),
5022 };
5023 if mode == StatusOptionsMode::Plan || missing.is_empty() {
5024 return Ok(report);
5025 }
5026 let mut options = before
5027 .options
5028 .iter()
5029 .map(|option| {
5030 json!({
5031 "id": option.id, "name": option.name, "color": option.color,
5032 "description": option.description,
5033 })
5034 })
5035 .collect::<Vec<_>>();
5036 options.extend(missing.iter().map(|name| {
5037 json!({
5038 "name": name, "color": "GRAY", "description": ""
5039 })
5040 }));
5041 self.graphql(
5042 graphql::STATUS_OPTIONS_UPDATE,
5043 json!({"input": {
5044 "fieldId": before.field_id,
5045 "singleSelectOptions": options,
5046 }}),
5047 )
5048 .await?;
5049 let after = self.status_snapshot().await?;
5050 let options_preserved = before
5051 .options
5052 .iter()
5053 .all(|old| after.options.iter().any(|new| new == old));
5054 let additions_present = missing.iter().all(|wanted| {
5055 after
5056 .options
5057 .iter()
5058 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5059 });
5060 if !options_preserved || !additions_present || after.assignments != before.assignments {
5061 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
5062 SourceError::Malformed {
5063 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
5064 }
5065 })?;
5066 return Err(SourceError::Refused {
5067 message: format!(
5068 "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}"
5069 ),
5070 });
5071 }
5072 Ok(report)
5073 }
5074
5075 /// A fresh snapshot of the Status field and every board item's assignment of it.
5076 ///
5077 /// # Errors
5078 ///
5079 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
5080 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
5081 // Status alone, as this operation has always read it: a `Priority` field is another
5082 // operation's, so nothing about it can refuse this one.
5083 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
5084 let field = board
5085 .fields
5086 .remove(&BoardField::Status)
5087 .ok_or_else(|| self.no_status_field())?;
5088 Ok(StatusSnapshot {
5089 assignments: board.assignments(BoardField::Status),
5090 board_id: board.board_id,
5091 field_id: field.field_id,
5092 options: field.options,
5093 })
5094 }
5095
5096 /// The refusal a board with no `Status` field is answered with by the guarded setup.
5097 fn no_status_field(&self) -> SourceError {
5098 SourceError::Refused {
5099 message: format!("source {} board has no Status field", self.name),
5100 }
5101 }
5102
5103 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
5104 // the real CLI loopback journey, including pagination. The individual malformed guards
5105 // are defensive validation of a schema-pinned third-party response, not separate user
5106 // journeys; drift and missing-field failures cover the operation's recovery behavior.
5107 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
5108 /// every board item's value of each, walked to the end of the board's items. A field not
5109 /// in `owned` is read past whatever it holds.
5110 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
5111 let mut after: Option<String> = None;
5112 let mut snapshot: Option<BoardSnapshot> = None;
5113 loop {
5114 let data = self
5115 .graphql(
5116 graphql::STATUS_OPTIONS_SNAPSHOT,
5117 json!({
5118 "owner": self.owner, "number": self.project_number,
5119 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
5120 }),
5121 )
5122 .await?;
5123 let board = data
5124 .pointer("/owner/projectV2")
5125 .filter(|board| board.is_object())
5126 .ok_or_else(|| SourceError::Refused {
5127 message: format!(
5128 "source {} has no accessible GitHub Projects board",
5129 self.name
5130 ),
5131 })?;
5132 if board
5133 .pointer("/fields/pageInfo/hasNextPage")
5134 .and_then(Value::as_bool)
5135 != Some(false)
5136 {
5137 return Err(SourceError::Malformed {
5138 message:
5139 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
5140 .into(),
5141 });
5142 }
5143 let mut fields = BTreeMap::new();
5144 // Only the fields this setup owns, by name: a node the single-select fragment did not
5145 // match carries no name, and a person's own single-select field — a `Size`, a
5146 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
5147 // `Status` or `Priority` field without its options is malformed, not absent.
5148 // 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.
5149 for (owned, field) in board
5150 .pointer("/fields/nodes")
5151 .and_then(Value::as_array)
5152 .ok_or_else(|| SourceError::Malformed {
5153 message: "GitHub project fields.nodes is not an array".into(),
5154 })?
5155 .iter()
5156 .filter_map(|field| {
5157 let named = BoardField::named(field.get("name")?.as_str()?)?;
5158 owned.contains(&named).then_some((named, field))
5159 })
5160 {
5161 let options = field
5162 .get("options")
5163 .and_then(Value::as_array)
5164 .ok_or_else(|| SourceError::Malformed {
5165 message: "GitHub single-select field options is not an array".into(),
5166 })?
5167 .iter()
5168 .map(|option| {
5169 Ok(StatusOption {
5170 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
5171 .map_err(|message| SourceError::Malformed { message })?,
5172 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
5173 .map_err(|message| SourceError::Malformed {
5174 message: format!(
5175 "GitHub single-select option name is invalid: {message}"
5176 ),
5177 })?,
5178 color: serde_json::from_value(
5179 option.get("color").cloned().unwrap_or(Value::Null),
5180 )
5181 .map_err(|error| {
5182 SourceError::Malformed {
5183 message: format!(
5184 "GitHub single-select option color is invalid: {error}"
5185 ),
5186 }
5187 })?,
5188 description: optional_str(option, "description")?
5189 .unwrap_or_default()
5190 .to_owned(),
5191 })
5192 })
5193 .collect::<Result<Vec<_>, SourceError>>()?;
5194 let snapshot = SnapshotField {
5195 field_id: required_nonblank_str(field, "id")?.to_owned(),
5196 options,
5197 };
5198 // A board's field names are unique, so a second one is an answer that cannot
5199 // say which field the setup would act on — refused rather than one chosen.
5200 if fields.insert(owned, snapshot).is_some() {
5201 return Err(SourceError::Malformed {
5202 message: format!(
5203 "GitHub answered two {} fields for this board",
5204 owned.name()
5205 ),
5206 });
5207 }
5208 }
5209 let board_id = required_nonblank_str(board, "id")?.to_owned();
5210 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
5211 board_id,
5212 fields,
5213 items: Vec::new(),
5214 });
5215 let items = board
5216 .pointer("/items/nodes")
5217 .and_then(Value::as_array)
5218 .ok_or_else(|| SourceError::Malformed {
5219 message: "GitHub project items.nodes is not an array".into(),
5220 })?;
5221 for item in items {
5222 let field_values =
5223 item.get("fieldValues")
5224 .ok_or_else(|| SourceError::Malformed {
5225 message: "GitHub project item is missing fieldValues".into(),
5226 })?;
5227 if field_values
5228 .pointer("/pageInfo/hasNextPage")
5229 .and_then(Value::as_bool)
5230 != Some(false)
5231 {
5232 return Err(SourceError::Malformed {
5233 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
5234 });
5235 }
5236 let values = item
5237 .pointer("/fieldValues/nodes")
5238 .and_then(Value::as_array)
5239 .ok_or_else(|| SourceError::Malformed {
5240 message: "GitHub project item fieldValues.nodes is not an array".into(),
5241 })?;
5242 let item_id = required_nonblank_str(item, "id")?;
5243 let mut assigned = BTreeMap::new();
5244 for value in values {
5245 let Some(field) = value
5246 .pointer("/field/name")
5247 .and_then(Value::as_str)
5248 .and_then(BoardField::named)
5249 .filter(|field| owned.contains(field))
5250 else {
5251 continue;
5252 };
5253 let held = assigned.insert(
5254 field,
5255 AssignedStatusOption {
5256 id: StatusOptionId::try_from(
5257 required_str(value, "optionId")?.to_owned(),
5258 )
5259 .map_err(|message| SourceError::Malformed { message })?,
5260 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
5261 .map_err(|message| SourceError::Malformed {
5262 message: format!(
5263 "GitHub assigned {} name is invalid: {message}",
5264 field.name()
5265 ),
5266 })?,
5267 },
5268 );
5269 // An item holds one value of a field, so a second one leaves no way to
5270 // tell which it holds — and a verification or recovery built on either
5271 // could restore the wrong one.
5272 if held.is_some() {
5273 return Err(SourceError::Malformed {
5274 message: format!(
5275 "GitHub answered two {} values for board item {item_id}",
5276 field.name()
5277 ),
5278 });
5279 }
5280 }
5281 current.items.push((item_id.to_owned(), assigned));
5282 }
5283 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
5284 message: "GitHub project is missing items".into(),
5285 })?;
5286 let has_next = page
5287 .pointer("/pageInfo/hasNextPage")
5288 .and_then(Value::as_bool)
5289 .ok_or_else(|| SourceError::Malformed {
5290 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
5291 })?;
5292 if !has_next {
5293 break;
5294 }
5295 let next =
5296 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
5297 validate_cursor_progress(after.as_deref(), next)?;
5298 after = Some(next.to_owned());
5299 }
5300 snapshot.ok_or_else(|| SourceError::Malformed {
5301 message: "GitHub returned no board field snapshot".into(),
5302 })
5303 }
5304 // llmlint: ignore-end[changed_behavior_has_e2e]
5305
5306 /// Report every board field this source's configuration names and, with
5307 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
5308 /// the `Priority` field when the board has none.
5309 ///
5310 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
5311 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
5312 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
5313 /// color and description: the whole option list goes back with every existing id, because
5314 /// a re-minted id clears every item's value.
5315 ///
5316 /// # Errors
5317 ///
5318 /// Refuses a board without a single-select `Status` field. After an apply the board is
5319 /// read again, and a pre-existing option or any item's value of either field that moved is
5320 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
5321 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
5322 // unchanged apply, a created field, an added option to each field, drift refusal, a board
5323 // with no Status field and a non-github-projects source through the compiled CLI against
5324 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
5325 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
5326 let owned: Vec<BoardField> = if self.priorities.is_some() {
5327 vec![BoardField::Status, BoardField::Priority]
5328 } else {
5329 vec![BoardField::Status]
5330 };
5331 let before = self.board_snapshot(&owned).await?;
5332 let mut plans = vec![FieldPlan {
5333 field: BoardField::Status,
5334 wanted: self.statuses.wanted(),
5335 }];
5336 if !before.fields.contains_key(&BoardField::Status) {
5337 return Err(self.no_status_field());
5338 }
5339 if let Some(mapping) = &self.priorities {
5340 plans.push(FieldPlan {
5341 field: BoardField::Priority,
5342 wanted: mapping.names().map(str::to_owned).collect(),
5343 });
5344 }
5345 // The snapshot reads single-select fields alone, so a field it did not find may still
5346 // be on the board under the name, of another type: creating one beside it would fail
5347 // part way, or leave two fields of one name. Asked of the board's own field list, and
5348 // only when a field is missing.
5349 if plans
5350 .iter()
5351 .any(|plan| !before.fields.contains_key(&plan.field))
5352 {
5353 let board = self.board_fields().await?;
5354 for plan in plans
5355 .iter()
5356 .filter(|plan| !before.fields.contains_key(&plan.field))
5357 {
5358 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
5359 return Err(SourceError::Refused {
5360 message: format!(
5361 "source {}'s board has a {} field that is not a single-select field \
5362 (it is a {}), so it cannot hold this source's options; next: rename \
5363 or remove that field, then run this again",
5364 self.name,
5365 plan.field.name(),
5366 optional_str(field, "__typename")?.unwrap_or("field of another type")
5367 ),
5368 });
5369 }
5370 }
5371 }
5372 let mut reports = Vec::new();
5373 for plan in &plans {
5374 let held = before.fields.get(&plan.field);
5375 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
5376 let mut missing: Vec<String> = Vec::new();
5377 for wanted in &plan.wanted {
5378 let present = existing
5379 .iter()
5380 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5381 || missing
5382 .iter()
5383 .any(|named| named.eq_ignore_ascii_case(wanted));
5384 if !present {
5385 missing.push(wanted.clone());
5386 }
5387 }
5388 let kinds = match plan.field {
5389 BoardField::Status => self.statuses.missing_by_kind(&existing),
5390 BoardField::Priority => Vec::new(),
5391 };
5392 reports.push(FieldReport {
5393 field: plan.field,
5394 exists: held.is_some(),
5395 kinds,
5396 outcome: match (mode, held.is_some(), missing.is_empty()) {
5397 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
5398 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
5399 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
5400 (SetupMode::Apply, false, _) => FieldOutcome::Created,
5401 },
5402 missing,
5403 existing,
5404 });
5405 }
5406 // A projected field is a text field, which the single-select snapshot cannot see, so
5407 // it is asked of the board's own field list — and only when one is configured.
5408 let mut metadata_fields = Vec::new();
5409 if !self.metadata_fields.is_empty() {
5410 let board = self.board_fields().await?;
5411 for projection in &self.metadata_fields {
5412 let conflict = match Board::field(&board.fields, &projection.field)? {
5413 None => None,
5414 Some(field) => {
5415 let typename = optional_str(field, "__typename")?;
5416 let data_type = optional_str(field, "dataType")?;
5417 if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
5418 None
5419 } else {
5420 let named = data_type
5421 .filter(|kind| *kind != "TEXT")
5422 .or(typename)
5423 .unwrap_or("field of another type");
5424 Some(NonTextFieldType::try_from(named.to_owned()).map_err(
5425 |message| SourceError::Malformed {
5426 message: format!(
5427 "GitHub answered the board's {} field with a type this \
5428 setup cannot read: {message}",
5429 &*projection.field
5430 ),
5431 },
5432 )?)
5433 }
5434 }
5435 };
5436 let exists =
5437 conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
5438 metadata_fields.push(MetadataFieldReport {
5439 field: projection.field.to_string(),
5440 key: projection.key.to_string(),
5441 path: projection.path.clone(),
5442 exists,
5443 outcome: match (mode, exists || conflict.is_some()) {
5444 (SetupMode::Plan, _) => FieldOutcome::Planned,
5445 (SetupMode::Apply, true) => FieldOutcome::Unchanged,
5446 (SetupMode::Apply, false) => FieldOutcome::Created,
5447 },
5448 conflict,
5449 });
5450 }
5451 }
5452 let report = FieldsReport {
5453 source: self.name.clone(),
5454 fields: reports,
5455 metadata_fields,
5456 };
5457 let writes: Vec<&FieldReport> = report
5458 .fields
5459 .iter()
5460 .filter(|field| !field.missing.is_empty() || !field.exists)
5461 .collect();
5462 let creates: Vec<&MetadataFieldReport> = report
5463 .metadata_fields
5464 .iter()
5465 .filter(|field| field.outcome == FieldOutcome::Created)
5466 .collect();
5467 if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
5468 return Ok(report);
5469 }
5470 let mut landed: Vec<&str> = Vec::new();
5471 for field in &writes {
5472 let added = field
5473 .missing
5474 .iter()
5475 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
5476 let sent = match before.fields.get(&field.field) {
5477 Some(held) => {
5478 let mut options = held
5479 .options
5480 .iter()
5481 .map(|option| {
5482 json!({
5483 "id": option.id, "name": option.name, "color": option.color,
5484 "description": option.description,
5485 })
5486 })
5487 .collect::<Vec<_>>();
5488 options.extend(added);
5489 self.graphql(
5490 graphql::STATUS_OPTIONS_UPDATE,
5491 json!({"input": {
5492 "fieldId": held.field_id,
5493 "singleSelectOptions": options,
5494 }}),
5495 )
5496 .await
5497 }
5498 None => {
5499 self.graphql(
5500 graphql::CREATE_FIELD,
5501 json!({"input": {
5502 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
5503 "name": field.field.name(),
5504 "singleSelectOptions": added.collect::<Vec<_>>(),
5505 }}),
5506 )
5507 .await
5508 }
5509 };
5510 // A mutation that failed does not establish that GitHub left its field as it was,
5511 // so every failure from here on carries the recovery data a drift refusal does.
5512 match sent {
5513 Ok(_) => landed.push(field.field.name()),
5514 Err(error) => {
5515 let changed = if landed.is_empty() {
5516 String::new()
5517 } else {
5518 format!("changed the {} field and then ", landed.join(" and "))
5519 };
5520 return Err(SourceError::Refused {
5521 message: format!(
5522 "the guarded field setup {changed}failed on the {} field, which it may \
5523 have changed part way: {error}; the pre-write item assignments \
5524 are:\n{}",
5525 field.field.name(),
5526 recovery(&report, &before)?
5527 ),
5528 });
5529 }
5530 }
5531 }
5532 for field in &creates {
5533 let sent = self
5534 .graphql(
5535 graphql::CREATE_FIELD,
5536 json!({"input": {
5537 "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
5538 }}),
5539 )
5540 .await;
5541 if let Err(error) = sent {
5542 let changed = if landed.is_empty() {
5543 String::new()
5544 } else {
5545 format!("changed the {} field and then ", landed.join(" and "))
5546 };
5547 return Err(SourceError::Refused {
5548 message: format!(
5549 "the guarded field setup {changed}failed creating the {} text field: \
5550 {error}; run it again to create what is still missing",
5551 field.field
5552 ),
5553 });
5554 }
5555 landed.push(&field.field);
5556 }
5557 // The board has been written, so a verification read that fails leaves it unverified
5558 // rather than unchanged, and says what to put back.
5559 let after = match self.board_snapshot(&owned).await {
5560 Ok(after) => after,
5561 Err(error) => {
5562 return Err(SourceError::Refused {
5563 message: format!(
5564 "the guarded field setup changed the {} field and then could not read the \
5565 board back to verify it: {error}; the pre-write item assignments are:\n{}",
5566 landed.join(" and "),
5567 recovery(&report, &before)?
5568 ),
5569 });
5570 }
5571 };
5572 let mut moved = Vec::new();
5573 for field in &report.fields {
5574 let name = field.field.name();
5575 let now = after
5576 .fields
5577 .get(&field.field)
5578 .map(|held| held.options.as_slice())
5579 .unwrap_or_default();
5580 if !field.existing.iter().all(|old| now.contains(old)) {
5581 moved.push(format!(
5582 "a pre-existing {name} option id, name, color or description"
5583 ));
5584 }
5585 if !field.missing.iter().all(|wanted| {
5586 now.iter()
5587 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
5588 }) {
5589 moved.push(format!("an added {name} option"));
5590 }
5591 if after.assignments(field.field) != before.assignments(field.field) {
5592 moved.push(format!("an item's {name} value"));
5593 }
5594 }
5595 if !creates.is_empty() {
5596 // Read afresh: the view held for this command is the one from before the creates.
5597 *self.fields_cache()? = None;
5598 let board = match self.board_fields().await {
5599 Ok(board) => board,
5600 Err(error) => {
5601 return Err(SourceError::Refused {
5602 message: format!(
5603 "the guarded field setup changed the {} field and then could not \
5604 read the board back to verify it: {error}; run it again to verify \
5605 it, which creates nothing a second time",
5606 landed.join(" and ")
5607 ),
5608 });
5609 }
5610 };
5611 for field in &creates {
5612 let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
5613 held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
5614 && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
5615 });
5616 if !text {
5617 moved.push(format!("the created {} text field", field.field));
5618 }
5619 }
5620 }
5621 if !moved.is_empty() {
5622 return Err(SourceError::Refused {
5623 message: format!(
5624 "GitHub changed {} after the guarded field setup; the pre-write item \
5625 assignments are:\n{}",
5626 moved.join(", "),
5627 recovery(&report, &before)?
5628 ),
5629 });
5630 }
5631 Ok(report)
5632 }
5633
5634 /// Validate configuration and capture the named credential without exposing it.
5635 ///
5636 /// # Errors
5637 ///
5638 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
5639 /// [`SourceError::Auth`] when the named credential is missing or empty.
5640 pub fn new(
5641 name: &SourceName,
5642 config: GitHubProjectsConfig,
5643 secrets: &dyn SecretResolver,
5644 ) -> Result<Self, SourceError> {
5645 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
5646 }
5647
5648 /// The same, recording every request it sends into an accounting the caller holds too.
5649 ///
5650 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
5651 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
5652 /// up — passes the one it records those into, so the session total accounts for the
5653 /// whole session rather than for this source's share of it.
5654 ///
5655 /// # Errors
5656 ///
5657 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
5658 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
5659 pub fn recording_into(
5660 name: &SourceName,
5661 config: GitHubProjectsConfig,
5662 secrets: &dyn SecretResolver,
5663 ledger: Arc<Accounting>,
5664 ) -> Result<Self, SourceError> {
5665 if !valid_github_owner(&config.owner) {
5666 return Err(SourceError::Config {
5667 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
5668 });
5669 }
5670 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
5671 return Err(SourceError::Config {
5672 message: format!("project_number must be between 1 and {}", i32::MAX),
5673 });
5674 }
5675 if !valid_environment_name(&config.token_env) {
5676 return Err(SourceError::Config {
5677 message: "token_env must be a valid environment-variable name".into(),
5678 });
5679 }
5680 let repository = config
5681 .repository
5682 .as_deref()
5683 .map(RepositoryTarget::parse)
5684 .transpose()?;
5685 if let Some(entry) = config
5686 .creation_owners
5687 .iter()
5688 .find(|entry| !valid_github_owner(entry))
5689 {
5690 return Err(SourceError::Config {
5691 message: format!(
5692 "creation_owners entry {entry:?} is not a GitHub login: a login is 1-39 ASCII \
5693 letters, digits, or single hyphens, and cannot start or end with a hyphen"
5694 ),
5695 });
5696 }
5697 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
5698 message: format!("endpoint is not a valid URL: {e}"),
5699 })?;
5700 if endpoint.scheme() != "https"
5701 && !(endpoint.scheme() == "http"
5702 && endpoint
5703 .host_str()
5704 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
5705 {
5706 return Err(SourceError::Config {
5707 message:
5708 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
5709 .into(),
5710 });
5711 }
5712 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
5713 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),
5714 })?;
5715 Ok(Self {
5716 name: name.clone(),
5717 owner: config.owner,
5718 project_number: config.project_number,
5719 repository,
5720 creation_owners: config.creation_owners,
5721 asset_client: std::sync::OnceLock::new(),
5722 body_markup: std::sync::OnceLock::new(),
5723 endpoint,
5724 token,
5725 credential_name: config.token_env,
5726 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
5727 priorities: config
5728 .priority_mapping
5729 .map(|mapping| PriorityMapping::resolve(mapping, name))
5730 .transpose()?,
5731 metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
5732 sub_issues: config.sub_issues,
5733 link_types: onetaskgraph_plugin_api::LinkTypes::checked(
5734 KIND,
5735 &config.link_types,
5736 &[relations::RELATES_TO],
5737 relations::REFUSED_WORDS,
5738 )
5739 .map_err(|error| match error {
5740 SourceError::Config { message } => SourceError::Config {
5741 message: format!("source {name}: {message}"),
5742 },
5743 other => other,
5744 })?,
5745 client: std::sync::OnceLock::new(),
5746 created: Mutex::new(Vec::new()),
5747 updated: Mutex::new(Vec::new()),
5748 commented: Mutex::new(Vec::new()),
5749 pacing: Pacing::resolve(config.pacing, name)?,
5750 last_mutation: Mutex::new(None),
5751 last_served: Mutex::new(None),
5752 windows: Mutex::new(pacing::Windows::default()),
5753 task_children: Mutex::new(BTreeMap::new()),
5754 written: Mutex::new(BTreeMap::new()),
5755 deferred: Mutex::new(None),
5756 held_updates: Mutex::new(Vec::new()),
5757 held_comments: Mutex::new(BTreeMap::new()),
5758 task_writes: Mutex::new(None),
5759 written_tokens: Mutex::new(BTreeMap::new()),
5760 clock: system_clock(),
5761 declared_private: std::sync::atomic::AtomicBool::new(false),
5762 numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
5763 board_cache: Mutex::new(None),
5764 search_cache: Mutex::new(None),
5765 narrowed_cache: Mutex::new(BTreeMap::new()),
5766 resolved_cache: Mutex::new(BTreeMap::new()),
5767 read_ahead: Mutex::new(BTreeMap::new()),
5768 pending_origins: Mutex::new(Vec::new()),
5769 member_classes: Mutex::new(BTreeMap::new()),
5770 carried: Mutex::new(BTreeMap::new()),
5771 held_cache: Mutex::new(BTreeMap::new()),
5772 children_cache: Mutex::new(BTreeMap::new()),
5773 search_next: Mutex::new(BTreeMap::new()),
5774 fields_cache: Mutex::new(None),
5775 repository_cache: Mutex::new(BTreeMap::new()),
5776 ledger,
5777 expected: expected::Held::default(),
5778 projection: projection::Held::default(),
5779 })
5780 }
5781
5782 /// The client every API request goes through, built on the first one.
5783 ///
5784 /// Not with the source: building a client loads the system's certificate store, which
5785 /// costs tens of milliseconds, and a command that configures this board but never sends to
5786 /// it — a read of another source — should not pay that in its process.
5787 fn client(&self) -> Result<&Client, SourceError> {
5788 built(&self.client, || {
5789 Client::builder()
5790 .user_agent("onetaskgraph")
5791 .build()
5792 .map_err(|e| SourceError::Config {
5793 message: format!("cannot build HTTP client: {e}"),
5794 })
5795 })
5796 }
5797
5798 /// The client attachment requests go through, with its own redirect policy, built on the
5799 /// first one for the reason [`Self::client`] gives.
5800 fn asset_client(&self) -> Result<&Client, SourceError> {
5801 built(&self.asset_client, || assets::client(&self.endpoint))
5802 }
5803
5804 /// A snapshot of every request this source has sent, and what each cost.
5805 ///
5806 /// A value to hold and compare rather than a borrow of the accounting itself, so two
5807 /// of them can sit side by side. When this source was built with
5808 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
5809 /// point of building it that way.
5810 #[must_use]
5811 pub fn accounting(&self) -> accounting::Session {
5812 self.ledger.snapshot()
5813 }
5814
5815 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
5816 /// rate limit rather than handing it straight back as an error.
5817 ///
5818 /// Retrying is safe for every document here, including the mutations, and the reason
5819 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
5820 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
5821 /// this replays has already taken effect. An outcome this source cannot know — the
5822 /// send failed, or the body could not be read, so the mutation may well have landed —
5823 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
5824 /// attempt. A duplicate write would come from replaying one of those, and none is
5825 /// replayed.
5826 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5827 // Updates held back go out before any other request, so whatever it reads or writes
5828 // sees the board as though each had been sent when it was made.
5829 Box::pin(self.send_held_updates()).await?;
5830 // Origins held back for a read ahead that never came are sent before anything else.
5831 if query != graphql::ORIGIN_LOOKUPS && query != graphql::ORIGIN_LOOKUPS_WITH_MEMBERS {
5832 Box::pin(self.send_pending_origins(None)).await?;
5833 }
5834 let journaling = self.expected.journaling();
5835 let spelling = self.spelling();
5836 if !journaling && !spelling {
5837 return self.graphql_sent(query, variables).await;
5838 }
5839 // While a write the store's cache expects runs, its mutations select the whole issue
5840 // they leave behind, so the response vouches for what the cache keeps — and every
5841 // request is recorded, so the write's parts are reported from their own responses.
5842 // A targeted update sends a twin of each selecting the issue's token parts inline, at
5843 // the same price, so the version it answers is the one its own last response spells.
5844 let (query, variables) = match (journaling, graphql::whole_twin(query)) {
5845 (true, Some(twin)) => (twin, expected::whole_variables(variables)),
5846 _ => match graphql::token_twin(query).filter(|_| spelling) {
5847 Some(twin) => (twin, token_variables(variables)),
5848 None => (query, variables),
5849 },
5850 };
5851 let sent = self.graphql_sent(query, variables.clone()).await;
5852 if spelling && let Ok(data) = &sent {
5853 self.record_written_token(query, &variables, data);
5854 }
5855 if journaling {
5856 self.journal(query, &variables, &sent).await;
5857 }
5858 sent
5859 }
5860
5861 /// Whether a targeted update of this source is running, whose mutations are each sent as
5862 /// the twin selecting the whole issue (see [`Self::written_tokens`]).
5863 fn spelling(&self) -> bool {
5864 SPELLING
5865 .try_with(|source| *source == std::ptr::from_ref(self) as usize)
5866 .unwrap_or(false)
5867 }
5868
5869 /// Record the change token the issue a mutation's response shows whole holds now, as
5870 /// [`TaskSource::written_task`] answers it; a response showing no whole issue records
5871 /// nothing.
5872 fn record_written_token(&self, document: &str, variables: &Value, data: &Value) {
5873 let (issue, _) = expected::read_back(document, variables, data);
5874 if let Some(issue) = issue {
5875 self.record_written_issue(issue);
5876 }
5877 }
5878
5879 /// Record the change token `issue`, as a write's own answer shows it whole, holds now — or,
5880 /// where that answer lacks a part of one, that it was shown and spells none.
5881 fn record_written_issue(&self, issue: &Value) {
5882 // An answer naming the issue by its id alone did not show it, so it says nothing.
5883 if issue.get("updatedAt").is_none() {
5884 return;
5885 }
5886 let Some(id) = issue.get("id").and_then(Value::as_str) else {
5887 return;
5888 };
5889 let (item, token) = match self.written_token_of(issue) {
5890 Some((item, token)) => (item, Some(token)),
5891 None => {
5892 // Answered whole, but without every part of a token: its board item's id, where
5893 // it says one, so a later write naming that item still drops the entry.
5894 let item = issue
5895 .pointer("/projectItems/nodes")
5896 .and_then(Value::as_array)
5897 .and_then(|nodes| self.board_entry(nodes))
5898 .and_then(|entry| entry.get("id"))
5899 .and_then(Value::as_str)
5900 .unwrap_or_default()
5901 .to_owned();
5902 (item, None)
5903 }
5904 };
5905 if let Ok(mut tokens) = self.written_tokens.lock() {
5906 tokens.insert(NativeId(id.to_owned()), (item, token));
5907 }
5908 }
5909
5910 /// Record `token` as the change token the task `item` holds after this command's write.
5911 fn remember_written_token(&self, item: &Resolved, token: String) {
5912 if let Ok(mut tokens) = self.written_tokens.lock() {
5913 tokens.insert(item.id.clone(), (item.item_id.clone(), Some(token)));
5914 }
5915 }
5916
5917 /// The change token the task `id` holds after this command's latest write of it, when that
5918 /// write's own response — or the read made right after it — showed one.
5919 fn written_token(&self, id: &NativeId) -> Option<String> {
5920 self.written_answer(id).flatten()
5921 }
5922
5923 /// What this command's latest write of the task `id` showed of it: `Some` once a response
5924 /// showed the issue whole — its token, or `None` where that response could not spell one —
5925 /// and `None` where no response did.
5926 fn written_answer(&self, id: &NativeId) -> Option<Option<String>> {
5927 self.written_tokens
5928 .lock()
5929 .ok()?
5930 .get(id)
5931 .map(|(_, token)| token.clone())
5932 }
5933
5934 /// Drop the written token of every task `variables` names, by its id or its board item's,
5935 /// before a mutation that may move it is sent.
5936 fn forget_written_tokens(&self, variables: &Value) {
5937 let Ok(mut tokens) = self.written_tokens.lock() else {
5938 return;
5939 };
5940 if tokens.is_empty() {
5941 return;
5942 }
5943 let named: Vec<&str> = variables
5944 .as_object()
5945 .into_iter()
5946 .flat_map(|arguments| arguments.values())
5947 .flat_map(|input| {
5948 WRITTEN_NAMES
5949 .iter()
5950 .filter_map(|key| input.get(key).and_then(Value::as_str))
5951 })
5952 .collect();
5953 tokens.retain(|id, (item, _)| {
5954 !named
5955 .iter()
5956 .any(|name| *name == id.0 || *name == item.as_str())
5957 });
5958 }
5959
5960 /// One document sent, retried while GitHub asks for a wait, exactly as asked.
5961 async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
5962 if is_mutation(query) {
5963 self.forget_written_tokens(&variables);
5964 }
5965 if is_mutation(query)
5966 && ![
5967 graphql::ADD_COMMENT,
5968 graphql::UPDATE_COMMENT,
5969 graphql::DELETE_COMMENT,
5970 graphql::ADD_COMMENT_WHOLE,
5971 graphql::UPDATE_COMMENT_WHOLE,
5972 ]
5973 .contains(&query)
5974 {
5975 let mut cache = self.resolved_cache()?;
5976 let mut ahead = self.read_ahead()?;
5977 let mut carried = self.carried()?;
5978 for argument in ["input", "second", "third", "clear"] {
5979 if let Some(input) = variables.get(argument) {
5980 let names = |id: &NativeId, item_id: Option<&str>| {
5981 ["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
5982 input
5983 .get(key)
5984 .and_then(Value::as_str)
5985 .is_some_and(|value| value == id.0 || Some(value) == item_id)
5986 })
5987 };
5988 cache.retain(|id, item| !names(id, Some(&item.item_id)));
5989 ahead.retain(|id, item| {
5990 !names(id, item.as_ref().map(|item| item.item_id.as_str()))
5991 });
5992 carried.retain(|id, item| !names(id, Some(&item.item_id)));
5993 }
5994 }
5995 }
5996 let doing = operation_description(query);
5997 let mutation = is_mutation(query);
5998 let charge = pacing::Charge::graphql(query, &variables);
5999 let mut waited = Duration::ZERO;
6000 let mut waits = 0_u32;
6001 let mut backoff = self.pacing.retry_backoff;
6002 loop {
6003 let ticket = self.pace(charge, mutation).await;
6004 // Every write but a create is asked for immediately before it is sent — a create's
6005 // fields, its relationships, its content and the delete that takes it back included
6006 // — so a copy that owns the create renews its hold first, and one that has lost it
6007 // sends nothing more. Asked again before a retry, which is sent later.
6008 if mutation
6009 && !query.contains("createIssue(")
6010 && let Err(refused) = <dyn onetaskgraph_plugin_api::WriteGuard>::check().await
6011 {
6012 self.paced(ticket, mutation);
6013 return Err(refused);
6014 }
6015 let attempt = self.send_once(query, &variables, waits + 1).await;
6016 self.paced(ticket, mutation);
6017 let remaining = self.pacing.retry_budget.saturating_sub(waited);
6018 let (wait, cause) = match attempt {
6019 Ok(data) => return Ok(data),
6020 Err(Attempt::Failed(error)) => return Err(error),
6021 Err(Attempt::Conflicted(said)) => {
6022 // A create is never replayed: see `CONFLICT_WORDINGS`.
6023 if query.contains("createIssue(") || backoff.is_zero() || backoff > remaining {
6024 return Err(SourceError::Unavailable {
6025 message: format!(
6026 "GitHub refused this source while {doing} as a temporary \
6027 conflict ({said}); it retried {} over {} and the conflict \
6028 did not clear within the {} one call may spend waiting. \
6029 next: run the command again once nothing else is writing to \
6030 the same items",
6031 plural(waits, "time"),
6032 seconds(waited),
6033 seconds(self.pacing.retry_budget),
6034 ),
6035 });
6036 }
6037 (backoff, pacing::cause::BACKOFF)
6038 }
6039 Err(Attempt::Limited(limited)) => {
6040 let (wait, cause) = limited.wait(backoff);
6041 // A wait of nothing spends none of the budget, so it is exhaustion rather
6042 // than a retry. `Pacing::resolve` rules out every way of configuring one
6043 // except a budget of zero, where reporting the first refusal is the ask.
6044 if wait.is_zero() || wait > remaining {
6045 return Err(limited.exhausted(
6046 doing,
6047 waits,
6048 waited,
6049 wait,
6050 self.pacing.retry_budget,
6051 ));
6052 }
6053 (wait, cause)
6054 }
6055 };
6056 self.clock.sleep_because(wait, cause).await;
6057 waited += wait;
6058 waits += 1;
6059 backoff = backoff.saturating_mul(2);
6060 }
6061 }
6062
6063 /// Hold one request charging `charge` until every secondary window it charges has room for
6064 /// the whole of it — and, for a mutation of an instance configured with
6065 /// `pacing.min_mutation_interval_ms`, until that interval has passed since the last one
6066 /// finished — then count it, answering the ticket [`Self::paced`] finishes it with.
6067 ///
6068 /// The moment is decided and counted under the lock and waited out outside it, so two
6069 /// callers take two places rather than the same one, and no lock is held across an await.
6070 /// A request with room goes out at once: nothing here waits that a window does not ask for.
6071 async fn pace(&self, charge: pacing::Charge, mutation: bool) -> pacing::Ticket {
6072 let (wait, cause, ticket) = {
6073 // A poisoned lock here costs pacing, not correctness, and refusing the write over
6074 // it would turn an earlier failure into a second one for no gain.
6075 let mut windows = self
6076 .windows
6077 .lock()
6078 .unwrap_or_else(std::sync::PoisonError::into_inner);
6079 let now = self.clock.now();
6080 let (mut at, mut cause) = if self.pacing.windows {
6081 windows.room(now, charge)
6082 } else {
6083 (now, None)
6084 };
6085 if mutation && !self.pacing.min_mutation_interval.is_zero() {
6086 let mut last = self
6087 .last_mutation
6088 .lock()
6089 .unwrap_or_else(std::sync::PoisonError::into_inner);
6090 // `checked_add` rather than `+`: adding durations can panic on overflow, and
6091 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
6092 if let Some(earliest) = last
6093 .and_then(|previous| previous.checked_add(self.pacing.min_mutation_interval))
6094 && earliest > at
6095 {
6096 at = earliest;
6097 cause = Some(pacing::cause::MIN_INTERVAL);
6098 }
6099 *last = Some(at);
6100 }
6101 let ticket = windows.charge(at, charge);
6102 (at.saturating_sub(now), cause, ticket)
6103 };
6104 if let (false, Some(cause)) = (wait.is_zero(), cause) {
6105 self.clock.sleep_because(wait, cause).await;
6106 }
6107 ticket
6108 }
6109
6110 /// Record that the request `ticket` was charged for has finished, so each window — and
6111 /// the configured spacing — counts it from here rather than from the moment it left.
6112 ///
6113 /// This source can only choose when a request *departs*; GitHub's limiter counts when it
6114 /// *arrives*, and the two differ by whatever the request spent in transit. Counting from
6115 /// departure would hand the limiter a window that looked roomier than it is, so a source
6116 /// pacing exactly at a limit could still be seen arriving over it — which is how a copy
6117 /// paced well inside a board's threshold was once refused by it on a slower machine while
6118 /// passing on a quick one. The previous request had arrived before its response came back,
6119 /// so counting from here is never earlier than the limiter does, whatever transit costs
6120 /// and on whatever platform: the safe side of a limit that punishes being wrong by refusing
6121 /// reads for the next fifty minutes.
6122 ///
6123 /// A failed attempt is finished too: a request refused by the limiter still arrived, and
6124 /// one that never left costs only room nobody needed.
6125 fn paced(&self, ticket: pacing::Ticket, mutation: bool) {
6126 let now = self.clock.now();
6127 self.windows
6128 .lock()
6129 .unwrap_or_else(std::sync::PoisonError::into_inner)
6130 .finish(ticket, now);
6131 if mutation && !self.pacing.min_mutation_interval.is_zero() {
6132 let mut last = self
6133 .last_mutation
6134 .lock()
6135 .unwrap_or_else(std::sync::PoisonError::into_inner);
6136 // `max` rather than an assignment: a concurrent caller may already have reserved a
6137 // slot further out, and completing this request must never pull that slot back in.
6138 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
6139 }
6140 }
6141
6142 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
6143 /// failure that waiting cannot help — and recorded, whichever of the three it was.
6144 ///
6145 /// This is the one place a request leaves this crate, which is why the accounting is
6146 /// here rather than at each of the callers: a read path added later is counted without
6147 /// anybody remembering to count it, and
6148 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
6149 /// when one is not.
6150 async fn send_once(
6151 &self,
6152 query: &str,
6153 variables: &Value,
6154 number: u32,
6155 ) -> Result<Value, Attempt> {
6156 let executed = self.ledger.priced_document(query);
6157 let Attempted {
6158 result,
6159 limits,
6160 reported_cost,
6161 response_outcome,
6162 } = self.attempt(executed.as_ref(), variables).await;
6163 // No `otherwise` name: every document this source sends is one of its own, and the
6164 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
6165 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
6166 let outcome = match &result {
6167 Ok(_) => accounting::Outcome::Answered,
6168 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
6169 Err(Attempt::Failed(_) | Attempt::Conflicted(_)) => accounting::Outcome::Refused,
6170 };
6171 self.ledger
6172 .record(sending.finished(outcome, limits).with_execution(
6173 Some(executed.into_owned()),
6174 response_outcome,
6175 number,
6176 ));
6177 result
6178 }
6179
6180 /// The attempt itself, with what its response said about the rate limit alongside.
6181 ///
6182 /// The two are returned together rather than recorded here because every one of the
6183 /// early exits below is a different outcome, and a record written at each of them is a
6184 /// record one of them can be added without.
6185 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
6186 let mut limits = accounting::RateLimit::default();
6187 let mut reported_cost = None;
6188 let mut response_outcome = accounting::ResponseOutcome::TransportError;
6189 let result = self
6190 .attempted(
6191 query,
6192 variables,
6193 &mut limits,
6194 &mut reported_cost,
6195 &mut response_outcome,
6196 )
6197 .await;
6198 Attempted {
6199 result,
6200 limits,
6201 reported_cost,
6202 response_outcome,
6203 }
6204 }
6205
6206 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
6207 async fn attempted(
6208 &self,
6209 query: &str,
6210 variables: &Value,
6211 limits: &mut accounting::RateLimit,
6212 reported_cost: &mut Option<u64>,
6213 response_outcome: &mut accounting::ResponseOutcome,
6214 ) -> Result<Value, Attempt> {
6215 let response = self
6216 .client()
6217 .map_err(Attempt::Failed)?
6218 .post(self.endpoint.clone())
6219 .bearer_auth(self.token.expose_secret())
6220 .json(&json!({"query": query, "variables": variables}))
6221 .send()
6222 .await
6223 .map_err(|e| {
6224 Attempt::Failed(SourceError::Unavailable {
6225 message: format!("GitHub GraphQL request failed: {}", transport_error(&e)),
6226 })
6227 })?;
6228 let status = response.status();
6229 if !status.is_success() {
6230 *response_outcome = accounting::ResponseOutcome::HttpError;
6231 }
6232 let header = |name: &str| whole_seconds(response.headers().get(name));
6233 let served = response
6234 .headers()
6235 .get(reqwest::header::DATE)
6236 .and_then(|value| value.to_str().ok())
6237 .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
6238 .map(|at| at.with_timezone(&Utc));
6239 *limits = accounting::RateLimit::read(|name| {
6240 response
6241 .headers()
6242 .get(name)
6243 .and_then(|value| value.to_str().ok())
6244 .map(str::to_owned)
6245 });
6246 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
6247 // that are not text at all — is "not known to be exhausted". This never makes a
6248 // response a refusal on its own: it says which limiter a refusal is attributed to
6249 // and where its hint comes from, so a value this cannot read costs a hint rather
6250 // than an answer.
6251 let exhausted = response
6252 .headers()
6253 .get("x-ratelimit-remaining")
6254 .and_then(|value| value.to_str().ok())
6255 == Some("0");
6256 // `retry-after` is what GitHub asks for when it asks; when it does not and the
6257 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
6258 // which is the same question answered as an absolute time. Nothing else here is a
6259 // hint, and a schedule is what answers a refusal that carries none.
6260 let retry_after = header("retry-after");
6261 let hinted_by = if retry_after.is_some() || !exhausted {
6262 Hint::RetryAfter
6263 } else {
6264 Hint::PrimaryReset
6265 };
6266 let hint = retry_after.or_else(|| {
6267 exhausted
6268 .then(|| header("x-ratelimit-reset"))
6269 .flatten()
6270 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
6271 });
6272 // Read before it is parsed, because the evidence which tells a secondary rate
6273 // limit from a rejected credential is in the body of a response whose status says
6274 // only "forbidden" — and a non-success response was never parsed at all.
6275 let body = response.text().await.map_err(|e| {
6276 Attempt::Failed(SourceError::Unavailable {
6277 message: format!(
6278 "GitHub GraphQL response could not be read: {}",
6279 transport_error(&e)
6280 ),
6281 })
6282 })?;
6283 *reported_cost = serde_json::from_str::<Value>(&body)
6284 .ok()
6285 .as_ref()
6286 .and_then(|body| body.pointer("/data/rateLimit/cost"))
6287 .and_then(Value::as_u64);
6288 let parsed = serde_json::from_str::<Value>(&body).ok();
6289 *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
6290 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
6291 return Err(Attempt::Limited(Limited {
6292 limiter,
6293 hint,
6294 hinted_by,
6295 }));
6296 }
6297 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
6298 return Err(Attempt::Failed(SourceError::Auth {
6299 message: format!(
6300 "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"
6301 ),
6302 }));
6303 }
6304 if !status.is_success() {
6305 return Err(Attempt::Failed(SourceError::Unavailable {
6306 message: format!("GitHub GraphQL returned HTTP {status}"),
6307 }));
6308 }
6309 let said = refusal_wording(status, &body);
6310 let normalized = said.to_ascii_lowercase();
6311 if CONFLICT_WORDINGS
6312 .iter()
6313 .any(|wording| normalized.contains(wording))
6314 {
6315 return Err(Attempt::Conflicted(said));
6316 }
6317 let answered = if query == graphql::ISSUES_BY_REFERENCE {
6318 self.answer_unresolved(&body)
6319 } else if [
6320 graphql::CREATE_ISSUES,
6321 graphql::SEARCH_BATCH,
6322 graphql::ADD_COMMENTS,
6323 graphql::WRITE_TASKS,
6324 graphql::SET_PARENTS,
6325 graphql::ADD_RELATES_TO,
6326 graphql::REMOVE_RELATES_TO,
6327 ]
6328 .contains(&query)
6329 {
6330 answer_with_errors(&body).map_or_else(|| self.answer(&body), Ok)
6331 } else {
6332 self.answer(&body)
6333 };
6334 let mut data = answered.map_err(Attempt::Failed)?;
6335 if let Some(served) = served {
6336 *self
6337 .last_served
6338 .lock()
6339 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(served);
6340 stamp_served(&mut data, &served.to_rfc3339());
6341 }
6342 Ok(data)
6343 }
6344
6345 /// What one successful HTTP response says, once its GraphQL errors are read.
6346 /// [`Self::answer`], for a read of issues by reference: a reference GitHub cannot resolve
6347 /// for this credential — a repository it cannot see, an issue number it does not hold —
6348 /// is answered `null` beside a `NOT_FOUND` error naming it, and that is an answer about
6349 /// that reference rather than a failure of the others. Any other error refuses the read,
6350 /// as there.
6351 fn answer_unresolved(&self, body: &str) -> Result<Value, SourceError> {
6352 let parsed: Option<Value> = serde_json::from_str(body).ok();
6353 // Each error is a `NOT_FOUND` whose path names one slot of the batch, `r<n>` or its
6354 // `issue`, and the answer holds `null` at exactly that path: anything else — another
6355 // kind of error, one naming no slot, or one beside a value it says is not there — is a
6356 // refusal of the whole read.
6357 let unresolved = |data: &Value, error: &Value| {
6358 error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
6359 && error
6360 .get("path")
6361 .and_then(Value::as_array)
6362 .is_some_and(|path| {
6363 let alias = path.first().and_then(Value::as_str).and_then(|alias| {
6364 alias
6365 .strip_prefix('r')
6366 .and_then(|slot| slot.parse::<usize>().ok())
6367 .filter(|slot| *slot < REFERENCE_BATCH)
6368 .map(|_| alias)
6369 });
6370 match (alias, path.get(1).and_then(Value::as_str), path.len()) {
6371 (Some(alias), None, 1) => data.get(alias).is_some_and(Value::is_null),
6372 (Some(alias), Some("issue"), 2) => data
6373 .get(alias)
6374 .and_then(|repository| repository.get("issue"))
6375 .is_some_and(Value::is_null),
6376 _ => false,
6377 }
6378 })
6379 };
6380 let unresolved_only = parsed.as_ref().is_some_and(|parsed| {
6381 parsed.get("data").is_some_and(|data| {
6382 data.is_object()
6383 && parsed
6384 .get("errors")
6385 .and_then(Value::as_array)
6386 .is_some_and(|errors| errors.iter().all(|error| unresolved(data, error)))
6387 })
6388 });
6389 match parsed {
6390 Some(parsed) if unresolved_only => Ok(parsed["data"].clone()),
6391 _ => self.answer(body),
6392 }
6393 }
6394
6395 fn answer(&self, body: &str) -> Result<Value, SourceError> {
6396 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
6397 message: format!("GitHub returned invalid JSON: {e}"),
6398 })?;
6399 let errors = body
6400 .get("errors")
6401 .map(|value| {
6402 value.as_array().ok_or_else(|| SourceError::Malformed {
6403 message: "GitHub response errors is not an array".into(),
6404 })
6405 })
6406 .transpose()?;
6407 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
6408 let messages = errors
6409 .iter()
6410 .filter_map(|e| e.get("message").and_then(Value::as_str))
6411 .collect::<Vec<_>>()
6412 .join("; ");
6413 let message = if messages.is_empty() {
6414 "GitHub returned GraphQL errors".into()
6415 } else {
6416 messages
6417 };
6418 let normalized = message.to_ascii_lowercase();
6419 if normalized.contains("resource not accessible") || normalized.contains("scope") {
6420 return Err(SourceError::Auth {
6421 message: format!(
6422 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
6423 self.credential_name
6424 ),
6425 });
6426 }
6427 return Err(SourceError::Refused { message });
6428 }
6429 body.get("data")
6430 .filter(|data| data.is_object())
6431 .cloned()
6432 .ok_or_else(|| SourceError::Malformed {
6433 message: "GitHub response has no data object".into(),
6434 })
6435 }
6436
6437 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
6438 // GraphQL cannot independently page them inside the outer item page. This source page is
6439 // deliberately bounded at that published maximum; the live drift journey exercises it.
6440 async fn board_page(
6441 &self,
6442 items_after: Option<&str>,
6443 items_first: u32,
6444 ) -> Result<Value, SourceError> {
6445 let data = self
6446 .graphql(
6447 graphql::BOARD,
6448 json!({"owner":self.owner,"number":self.project_number,
6449 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
6450 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
6451 )
6452 .await?;
6453 data.pointer("/owner/projectV2")
6454 .filter(|v| !v.is_null())
6455 .cloned()
6456 .ok_or_else(|| SourceError::Refused {
6457 message: format!(
6458 "GitHub project {}/{} was not found or is not visible to the token",
6459 self.owner, self.project_number
6460 ),
6461 })
6462 }
6463
6464 /// The search that finds the issues of this board, narrowed by `also` when it is
6465 /// given.
6466 ///
6467 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
6468 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
6469 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
6470 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
6471 /// from a task by the `parent` field each issue carries rather than by the search.
6472 fn board_search(&self, also: Option<&str>) -> String {
6473 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
6474 match also {
6475 Some(also) => format!("{scope} {also}"),
6476 None => scope,
6477 }
6478 }
6479
6480 /// One issue this source reached directly, as the board item a read of the board would
6481 /// have produced — or `None` when this board does not hold it.
6482 ///
6483 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
6484 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
6485 /// item's own id, that item's field values, and the issue as its content. One resolver
6486 /// for both routes is what makes an issue read through a search, through its own node
6487 /// id, or through its project's sub-issues report the same title, the same status, the
6488 /// same labels and the same qualified id.
6489 ///
6490 /// An issue with no entry for *this* board is not this source's to report, which is
6491 /// what keeps an id naming some other repository's issue from being answered as an item
6492 /// of this board. That answer is given about an **exhausted** connection and never
6493 /// about an unread page: the entry is looked for on the page in hand, and only if that
6494 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
6495 /// rest of it.
6496 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
6497 self.resolve_issue_as(issue, true).await
6498 }
6499
6500 /// [`Self::resolve_issue`], remembering what it resolves for this command's later reads
6501 /// and writes only when `remember` says to.
6502 async fn resolve_issue_as(
6503 &self,
6504 issue: &Value,
6505 remember: bool,
6506 ) -> Result<Option<Resolved>, SourceError> {
6507 if optional_str(issue, "__typename")? != Some("Issue") {
6508 return Ok(None);
6509 }
6510 let memberships = issue
6511 .get("projectItems")
6512 .ok_or_else(|| SourceError::Malformed {
6513 message: "GitHub issue is missing projectItems".into(),
6514 })?;
6515 let nodes = memberships
6516 .get("nodes")
6517 .and_then(Value::as_array)
6518 .ok_or_else(|| SourceError::Malformed {
6519 message: "GitHub issue projectItems.nodes is not an array".into(),
6520 })?;
6521 let held = match self.board_entry(nodes) {
6522 Some(held) => held.clone(),
6523 None => {
6524 let info = memberships
6525 .get("pageInfo")
6526 .ok_or_else(|| SourceError::Malformed {
6527 message: "GitHub issue projectItems has no pageInfo".into(),
6528 })?;
6529 // The page held no entry for this board. Whether that means the issue is
6530 // not on it is a question about the rest of the connection, and only a
6531 // connection with no rest answers it here.
6532 if !required_bool(info, "hasNextPage")? {
6533 return Ok(None);
6534 }
6535 let cursor = required_str(info, "endCursor")?;
6536 validate_cursor_progress(None, cursor)?;
6537 let issue_id = required_str(issue, "id")?;
6538 match self.board_membership(issue_id, cursor).await? {
6539 Some(held) => held,
6540 None => return Ok(None),
6541 }
6542 }
6543 };
6544 // The membership's own `updatedAt` rides along: it is the board half of the item's change
6545 // token, which a field change moves where the issue's own time does not.
6546 let item = json!({
6547 "id": required_str(&held, "id")?,
6548 "updatedAt": held.get("updatedAt"),
6549 "project": held.get("project"),
6550 "fieldValues": held.get("fieldValues"),
6551 "content": issue,
6552 });
6553 if remember {
6554 self.resolve(&item)
6555 } else {
6556 self.resolve_unremembered(&item)
6557 }
6558 }
6559
6560 /// This board's own entry among one page of an issue's `Issue.projectItems`.
6561 ///
6562 /// One spelling of *which membership is this board's*, so the page a read carries and
6563 /// the pages [`Self::board_membership`] walks are searched by the same rule.
6564 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
6565 nodes.iter().find(|node| {
6566 node.pointer("/project/number").and_then(Value::as_u64)
6567 == Some(u64::from(self.project_number))
6568 })
6569 }
6570
6571 /// The rest of one issue's board memberships, from `after`, for this board's entry.
6572 ///
6573 /// The recovery read: a page of memberships that holds no entry for this board says
6574 /// nothing about the memberships past it, so the connection is walked to exhaustion
6575 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
6576 /// positive answer — the whole connection was read and no entry named this board —
6577 /// rather than a failure, and the walk is held to
6578 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
6579 /// with a cursor that does not advance is refused instead of spun on.
6580 async fn board_membership(
6581 &self,
6582 issue: &str,
6583 after: &str,
6584 ) -> Result<Option<Value>, SourceError> {
6585 let mut after = after.to_owned();
6586 loop {
6587 let data = self
6588 .graphql(
6589 graphql::ISSUE_BOARD_ITEMS,
6590 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
6591 "nestedFirst":NESTED_PAGE_SIZE}),
6592 )
6593 .await?;
6594 let Some(connection) = data
6595 .pointer("/node/projectItems")
6596 .filter(|value| !value.is_null())
6597 else {
6598 // The id resolved to nothing, or to something with no memberships to walk —
6599 // which is the same answer as a connection holding no entry for this board.
6600 return Ok(None);
6601 };
6602 let nodes = connection
6603 .get("nodes")
6604 .and_then(Value::as_array)
6605 .ok_or_else(|| SourceError::Malformed {
6606 message: "GitHub issue projectItems.nodes is not an array".into(),
6607 })?;
6608 if let Some(held) = self.board_entry(nodes) {
6609 return Ok(Some(held.clone()));
6610 }
6611 let info = connection
6612 .get("pageInfo")
6613 .ok_or_else(|| SourceError::Malformed {
6614 message: "GitHub issue projectItems has no pageInfo".into(),
6615 })?;
6616 let next = required_bool(info, "hasNextPage")?
6617 .then(|| required_str(info, "endCursor"))
6618 .transpose()?;
6619 match next {
6620 Some(next) => {
6621 validate_cursor_progress(Some(&after), next)?;
6622 after = next.to_owned();
6623 }
6624 None => return Ok(None),
6625 }
6626 }
6627 }
6628
6629 /// One page of a board-scoped issue search, and where the next page resumes.
6630 async fn search_page(
6631 &self,
6632 search: &str,
6633 first: u32,
6634 after: Option<&str>,
6635 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
6636 let data = self
6637 .graphql(
6638 graphql::SEARCH_ISSUES,
6639 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
6640 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
6641 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6642 )
6643 .await?;
6644 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
6645 message: "GitHub search response has no search connection".into(),
6646 })?;
6647 let mut found = Vec::new();
6648 for node in connection
6649 .get("nodes")
6650 .and_then(Value::as_array)
6651 .ok_or_else(|| SourceError::Malformed {
6652 message: "GitHub search nodes is not an array".into(),
6653 })?
6654 {
6655 if let Some(resolved) = self.resolve_issue(node).await? {
6656 found.push(resolved);
6657 }
6658 }
6659 let info = connection
6660 .get("pageInfo")
6661 .ok_or_else(|| SourceError::Malformed {
6662 message: "GitHub search connection has no pageInfo".into(),
6663 })?;
6664 let next = required_bool(info, "hasNextPage")?
6665 .then(|| required_str(info, "endCursor"))
6666 .transpose()?
6667 .map(str::to_owned);
6668 if let Some(next) = &next {
6669 validate_cursor_progress(after, next)?;
6670 }
6671 Ok((found, next))
6672 }
6673
6674 /// Every issue this board holds, completed with what this run wrote.
6675 ///
6676 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
6677 /// is an index and is eventually consistent, so an issue this run created seconds ago
6678 /// can be absent from it, and a project listed straight after being written would
6679 /// otherwise be missing from its own board. What is added back is only what this
6680 /// process itself wrote, out of [`Self::created`], which lives and dies with the
6681 /// process.
6682 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6683 let found = self.searched_issues().await?;
6684 self.completed_with_written(found, |_| true)
6685 }
6686
6687 /// Every issue this board's own search reports, walked to exhaustion, read once per
6688 /// source.
6689 ///
6690 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
6691 /// needs it too and the two would otherwise walk the same search twice in one command.
6692 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
6693 /// is.
6694 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
6695 let cached = self.search_cache()?.clone();
6696 if let Some(held) = cached {
6697 return Ok(held);
6698 }
6699 let mut after: Option<String> = None;
6700 let mut found = Vec::new();
6701 let search = self.board_search(None);
6702 loop {
6703 let (page, next) = self
6704 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6705 .await?;
6706 found.extend(page);
6707 match next {
6708 Some(next) => after = Some(next),
6709 None => break,
6710 }
6711 }
6712 *self.search_cache()? = Some(found.clone());
6713 Ok(found)
6714 }
6715
6716 /// Every issue this board's own search reports with `also` beside its scope, walked to
6717 /// exhaustion — held for no later read, which may not be narrowed alike.
6718 async fn searched_narrowly(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
6719 let mut after: Option<String> = None;
6720 let mut found = Vec::new();
6721 let search = self.board_search(Some(also));
6722 loop {
6723 let (page, next) = self
6724 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
6725 .await?;
6726 found.extend(page);
6727 match next {
6728 Some(next) => after = Some(next),
6729 None => return Ok(found),
6730 }
6731 }
6732 }
6733
6734 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
6735 fn search_cache(
6736 &self,
6737 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
6738 self.search_cache
6739 .lock()
6740 .map_err(|_| SourceError::Unavailable {
6741 message: "this source's view of the board's issues was left inconsistent by an \
6742 earlier failure; next: run the command again"
6743 .into(),
6744 })
6745 }
6746
6747 /// `found`, with everything this run wrote that `keep` accepts and the read did not
6748 /// report.
6749 ///
6750 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
6751 /// at all: the search index is behind, and a node read of an item filed moments ago can
6752 /// be too.
6753 fn completed_with_written(
6754 &self,
6755 mut found: Vec<Resolved>,
6756 keep: impl Fn(&Resolved) -> bool,
6757 ) -> Result<Vec<Resolved>, SourceError> {
6758 for own in self.created()?.iter().filter(|own| keep(own)) {
6759 if !found.iter().any(|item| item.id == own.id) {
6760 found.push(own.clone());
6761 }
6762 }
6763 Ok(found)
6764 }
6765
6766 /// What resolving one node id reached.
6767 ///
6768 /// Three answers rather than an `Option`, because a board *draft* is none of the other
6769 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
6770 /// is completed by a read of the draft itself rather than reported as nothing.
6771 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
6772 let asked = self
6773 .graphql(
6774 graphql::ISSUE,
6775 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
6776 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
6777 )
6778 .await;
6779 let data = match asked {
6780 Ok(data) => data,
6781 // A string that is not a node id at all is not a failure to report: it is an id
6782 // this board does not hold, which is what every read of one already answers.
6783 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
6784 Err(error) => return Err(error),
6785 };
6786 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6787 return Ok(Reached::Nothing);
6788 };
6789 if optional_str(node, "__typename")? == Some("DraftIssue") {
6790 return Ok(Reached::Draft);
6791 }
6792 Ok(match self.resolve_issue(node).await? {
6793 Some(item) => Reached::Held(Box::new(item)),
6794 None => Reached::Nothing,
6795 })
6796 }
6797
6798 /// One item of this board by its own id, whatever kind it is.
6799 ///
6800 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
6801 /// run wrote is read first, because a node read of an item created moments ago can
6802 /// still be behind the board field values written onto it — see [`Self::created`].
6803 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6804 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
6805 return Ok(Some(own.clone()));
6806 }
6807 if let Some(held) = self.read_ahead()?.remove(id) {
6808 return Ok(held);
6809 }
6810 match self.reach(id).await? {
6811 Reached::Held(item) => Ok(Some(*item)),
6812 Reached::Nothing => Ok(None),
6813 Reached::Draft => self.draft_by_id(id).await,
6814 }
6815 }
6816
6817 /// [`Self::item_by_id`] for a document: one [`graphql::DOCUMENT_IN_PROJECT`] read, which
6818 /// answers the project it is filed under and that project's members beside it.
6819 ///
6820 /// Each is held for this command as a read of it would have been: the project for the
6821 /// next read of it by its id, and — when its page of members is the whole of them — the
6822 /// members for the next listing of that project. So a write held to who may read the
6823 /// document, which reads all three, is answered by this one request.
6824 async fn document_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
6825 if self.created()?.iter().any(|own| own.id == *id) || self.read_ahead()?.contains_key(id) {
6826 return self.item_by_id(id).await;
6827 }
6828 let asked = self
6829 .graphql(
6830 graphql::DOCUMENT_IN_PROJECT,
6831 json!({"id":id.0,"first":MAX_PAGE_SIZE,"memberFirst":MEMBER_PAGE_SIZE,
6832 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
6833 "duplicates":true}),
6834 )
6835 .await;
6836 let data = match asked {
6837 Ok(data) => data,
6838 Err(error) if unresolvable_node(&error) => return Ok(None),
6839 Err(error) => return Err(error),
6840 };
6841 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6842 return Ok(None);
6843 };
6844 if optional_str(node, "__typename")? == Some("DraftIssue") {
6845 return self.draft_by_id(id).await;
6846 }
6847 let Some(item) = self.resolve_issue(node).await? else {
6848 return Ok(None);
6849 };
6850 // An answer for another issue than the one asked about says nothing of that one.
6851 if item.id != *id {
6852 return Err(SourceError::Malformed {
6853 message: format!(
6854 "GitHub answered issue {} when asked for document {}",
6855 item.id.0, id.0
6856 ),
6857 });
6858 }
6859 if let Some(project) = node.get("project").filter(|value| !value.is_null())
6860 && let Some(parent) = self
6861 .resolve_issue(project)
6862 .await?
6863 .filter(|parent| Some(&parent.id) == item.parent.as_ref())
6864 {
6865 let members = project.get("subIssues").filter(|value| !value.is_null());
6866 let whole = members
6867 .and_then(|members| members.pointer("/pageInfo/hasNextPage"))
6868 .and_then(Value::as_bool)
6869 == Some(false);
6870 if whole {
6871 let nodes = members
6872 .and_then(|members| members.get("nodes"))
6873 .and_then(Value::as_array)
6874 .ok_or_else(|| SourceError::Malformed {
6875 message: "GitHub subIssues.nodes is not an array".into(),
6876 })?;
6877 let mut children = Vec::new();
6878 for member in nodes {
6879 if let Some(resolved) = self.resolve_issue(member).await? {
6880 children.push(resolved);
6881 }
6882 }
6883 // Held as the project's members only when every one resolved and says it is
6884 // filed under it: a page with any other answers nothing of all the members.
6885 if children.len() == nodes.len()
6886 && children
6887 .iter()
6888 .all(|child| child.parent.as_ref() == Some(&parent.id))
6889 {
6890 self.children_cache()?
6891 .insert(parent.id.clone(), (parent.id.clone(), children));
6892 }
6893 }
6894 self.read_ahead()?.insert(parent.id.clone(), Some(parent));
6895 }
6896 Ok(Some(item))
6897 }
6898
6899 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
6900 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
6901 /// than one request per id.
6902 ///
6903 /// What this run wrote answers first, as it does there, and only the rest is read. One id
6904 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
6905 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
6906 /// id at a time, so that id is answered as not held and the others as themselves; a draft
6907 /// is completed by a read of the draft, exactly as there.
6908 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
6909 let mut found: Vec<Option<Option<Resolved>>> = {
6910 let created = self.created()?;
6911 ids.iter()
6912 .map(|id| {
6913 created
6914 .iter()
6915 .find(|own| own.id == *id)
6916 .map(|own| Some(own.clone()))
6917 })
6918 .collect()
6919 };
6920 let unread: Vec<NativeId> = ids
6921 .iter()
6922 .zip(&found)
6923 .filter(|(_, found)| found.is_none())
6924 .map(|(id, _)| id.clone())
6925 .collect();
6926 let mut read = Vec::with_capacity(unread.len());
6927 if let [one] = unread.as_slice() {
6928 read.push(self.item_by_id(one).await?);
6929 } else {
6930 for batch in unread.chunks(DETAIL_BATCH) {
6931 let data = match self
6932 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
6933 .await
6934 {
6935 Ok(data) => data,
6936 Err(error) if unresolvable_node(&error) => {
6937 for id in batch {
6938 read.push(self.item_by_id(id).await?);
6939 }
6940 continue;
6941 }
6942 Err(error) => return Err(error),
6943 };
6944 for (slot, id) in batch.iter().enumerate() {
6945 let node =
6946 data.get(format!("i{slot}"))
6947 .ok_or_else(|| SourceError::Malformed {
6948 message: format!(
6949 "GitHub answered a batch read with no item for {}",
6950 id.0
6951 ),
6952 })?;
6953 read.push(if node.is_null() {
6954 None
6955 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
6956 self.draft_by_id(id).await?
6957 } else {
6958 if optional_str(node, "__typename")? == Some("Issue")
6959 && required_str(node, "id")? != id.0
6960 {
6961 return Err(SourceError::Malformed {
6962 message: format!(
6963 "GitHub answered the read of {} with issue {}",
6964 id.0,
6965 required_str(node, "id")?
6966 ),
6967 });
6968 }
6969 self.resolve_issue(node).await?
6970 });
6971 }
6972 }
6973 }
6974 let mut read = read.into_iter();
6975 Ok(found
6976 .iter_mut()
6977 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
6978 .collect())
6979 }
6980
6981 fn read_ahead(
6982 &self,
6983 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Option<Resolved>>>, SourceError> {
6984 self.read_ahead
6985 .lock()
6986 .map_err(|_| SourceError::Unavailable {
6987 message:
6988 "items read ahead for a copy were left inconsistent; run the command again"
6989 .into(),
6990 })
6991 }
6992
6993 fn resolved_cache(
6994 &self,
6995 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
6996 self.resolved_cache
6997 .lock()
6998 .map_err(|_| SourceError::Unavailable {
6999 message: "resolved item records were left inconsistent; run the command again"
7000 .into(),
7001 })
7002 }
7003
7004 /// Reuse a record this invocation already resolved. The mutation sender invalidates
7005 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
7006 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7007 let cached = self.resolved_cache()?.get(id).cloned();
7008 if let Some(item) = cached {
7009 return Ok(Some(item));
7010 }
7011 self.item_by_id(id).await
7012 }
7013
7014 /// Take out the item an issue read by reference reached for `id`, when one is held: the
7015 /// status write it was read for is the one caller, so no other read or write is ever
7016 /// answered from a read that selected only what that write needs.
7017 fn take_held(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7018 Ok(self
7019 .held_cache
7020 .lock()
7021 .map_err(|_| SourceError::Unavailable {
7022 message: "items read by issue reference were left inconsistent; run the \
7023 command again"
7024 .into(),
7025 })?
7026 .remove(id))
7027 }
7028
7029 /// One board draft by its own id, with the board item it sits in — or `None` when no
7030 /// item of this board is that draft's.
7031 ///
7032 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
7033 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
7034 /// links a draft to one board item, so the page this read carries is the whole of that
7035 /// connection, and a page that reports more than it holds is refused rather than read
7036 /// as an answer about memberships nobody read.
7037 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
7038 let data = self
7039 .graphql(
7040 graphql::DRAFT,
7041 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
7042 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
7043 )
7044 .await?;
7045 // Gone between the two reads is an answer — the draft is no longer there. Anything
7046 // else than the draft [`Self::reach`] was just told this id is, is not one.
7047 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
7048 return Ok(None);
7049 };
7050 if optional_str(draft, "__typename")? != Some("DraftIssue") {
7051 return Err(SourceError::Malformed {
7052 message: format!(
7053 "GitHub answered {} as a draft and then as something else",
7054 id.0
7055 ),
7056 });
7057 }
7058 if required_str(draft, "id")? != id.0 {
7059 return Err(SourceError::Malformed {
7060 message: format!("GitHub answered a different draft for {}", id.0),
7061 });
7062 }
7063 let memberships = draft
7064 .get("projectV2Items")
7065 .ok_or_else(|| SourceError::Malformed {
7066 message: format!("GitHub draft {} is missing projectV2Items", id.0),
7067 })?;
7068 let nodes = memberships
7069 .get("nodes")
7070 .and_then(Value::as_array)
7071 .ok_or_else(|| SourceError::Malformed {
7072 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
7073 })?;
7074 let info = memberships
7075 .get("pageInfo")
7076 .ok_or_else(|| SourceError::Malformed {
7077 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
7078 })?;
7079 // Read whether or not this board's entry is on the page: a page claiming more than
7080 // the one item GitHub links a draft to is a malformed answer either way.
7081 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
7082 return Err(SourceError::Malformed {
7083 message: format!(
7084 "GitHub draft {} reports more board items than the one GitHub links a draft \
7085 to",
7086 id.0
7087 ),
7088 });
7089 }
7090 if let Some(node) = nodes.first()
7091 && node
7092 .pointer("/project/number")
7093 .and_then(Value::as_u64)
7094 .is_none()
7095 {
7096 return Err(SourceError::Malformed {
7097 message: format!(
7098 "GitHub draft {} board item has no numeric project number",
7099 id.0
7100 ),
7101 });
7102 }
7103 let Some(held) = self.board_entry(nodes) else {
7104 return Ok(None);
7105 };
7106 if required_str(
7107 held.get("project").ok_or_else(|| SourceError::Malformed {
7108 message: format!("GitHub draft {} board item has no project", id.0),
7109 })?,
7110 "id",
7111 )? != self.board_fields().await?.id.as_str()
7112 {
7113 return Ok(None);
7114 }
7115 let item = json!({
7116 "id": required_str(held, "id")?,
7117 "project": held.get("project"),
7118 "fieldValues": held.get("fieldValues"),
7119 "content": draft,
7120 });
7121 self.resolve(&item)
7122 }
7123
7124 /// The board's own id and field definitions, for a write whose item does not carry
7125 /// them — never its items.
7126 ///
7127 /// A board this command has already listed supplies them, since it read them beside its
7128 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
7129 /// is consulted about which items the board holds: see the module documentation for
7130 /// why a question about one known item is answered by reading that item.
7131 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
7132 if let Some(board) = self.board_cache()?.as_ref() {
7133 return Ok(BoardFields {
7134 id: BoardId::parse(&board.id)?,
7135 fields: board.fields.clone(),
7136 });
7137 }
7138 if let Some(held) = self.fields_cache()?.clone() {
7139 return Ok(held);
7140 }
7141 let data = self
7142 .graphql(
7143 graphql::BOARD_FIELDS,
7144 json!({"owner":self.owner,"number":self.project_number,
7145 "nestedFirst":NESTED_PAGE_SIZE}),
7146 )
7147 .await?;
7148 self.fields_read(&data)
7149 }
7150
7151 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
7152 /// the rest of this command.
7153 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
7154 let board = data
7155 .pointer("/boardFields/projectV2")
7156 .filter(|value| !value.is_null())
7157 .ok_or_else(|| SourceError::Refused {
7158 message: format!(
7159 "GitHub project {}/{} was not found or is not visible to the token",
7160 self.owner, self.project_number
7161 ),
7162 })?;
7163 let read = BoardFields {
7164 id: BoardId::parse(required_str(board, "id")?)?,
7165 fields: board.get("fields").cloned().unwrap_or(Value::Null),
7166 };
7167 *self.fields_cache()? = Some(read.clone());
7168 Ok(read)
7169 }
7170
7171 /// Read what creating an issue in `repository` needs and this command has not read yet —
7172 /// the board's fields and the repository's node id — in one request when it needs both.
7173 ///
7174 /// When either is already known this sends nothing, and the other is read by its own
7175 /// document where it is asked for, so no create reads anything twice.
7176 async fn creation_context(
7177 &self,
7178 repository: &RepositoryTarget,
7179 incoming: &Incoming<'_>,
7180 ) -> Result<(), SourceError> {
7181 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
7182 if fields_known || self.repository_cache()?.contains_key(repository) {
7183 return Ok(());
7184 }
7185 // A placement an earlier process kept is taken only while the project issue this create
7186 // has just read shows its setup-context token unchanged; see `projection`.
7187 if self.placed_by_kept(repository, incoming.parent) {
7188 return Ok(());
7189 }
7190 let data = self
7191 .graphql(
7192 graphql::CREATION_CONTEXT,
7193 json!({"owner":self.owner,"number":self.project_number,
7194 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
7195 "repositoryName":repository.name}),
7196 )
7197 .await?;
7198 self.fields_read(&data)?;
7199 self.repository_read(&data, repository, incoming)?;
7200 Ok(())
7201 }
7202
7203 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
7204 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
7205 self.fields_cache
7206 .lock()
7207 .map_err(|_| SourceError::Unavailable {
7208 message: "this source's view of the board's fields was left inconsistent by an \
7209 earlier failure; next: run the command again"
7210 .into(),
7211 })
7212 }
7213
7214 /// What a write to `item` needs of the board, read off that item when it says enough and
7215 /// off [`Self::board_fields`] when it does not.
7216 ///
7217 /// A node read of an item names its board and carries the definition of every field it
7218 /// holds a value of — so an item naming its board, holding a value of the origin field
7219 /// when the write carries an origin, and, when the write carries a status, holding a
7220 /// `Status` value, needs no read of the board at all. **Nothing the item does not say is
7221 /// guessed:** a field it holds no value of may still be on the board, and a view reading it
7222 /// as absent would refuse a write the board can take or skip a field write the board
7223 /// needs, so such an item — and a create, which has no item yet — takes the board's fields
7224 /// from their own read instead. A write carrying no origin to an item holding none — every
7225 /// write out of a source declared private — writes no origin whatever the board defines,
7226 /// so it needs no definition of that field.
7227 async fn fields_for(
7228 &self,
7229 item: Option<&Resolved>,
7230 writes_origin: bool,
7231 writes_status: bool,
7232 selects_priority: bool,
7233 ) -> Result<BoardFields, SourceError> {
7234 if let Some(board) = item.and_then(Resolved::carried_board) {
7235 return Ok(board);
7236 }
7237 if let Some(item) = item
7238 && let Some(board_id) = item.named_board()
7239 && (!writes_origin || item.defines(ORIGIN_FIELD))
7240 && (!writes_status || item.defines("Status"))
7241 && (!selects_priority || item.defines(PRIORITY_FIELD))
7242 && self
7243 .metadata_fields
7244 .iter()
7245 .all(|projection| item.defines(&projection.field))
7246 {
7247 return Ok(BoardFields {
7248 id: board_id,
7249 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7250 });
7251 }
7252 self.board_fields().await
7253 }
7254
7255 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
7256 /// when that id names nothing here with a sub-issue relationship to walk.
7257 ///
7258 /// `None` and an empty answer are different: `None` is *this is not an issue of this
7259 /// GitHub*, which is what sends a project selector on to be read as a name, and an
7260 /// empty vector is a project that holds nothing.
7261 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
7262 let mut after: Option<String> = None;
7263 let mut children = Vec::new();
7264 // The project issue as the first page carried it, held once the walk has ended.
7265 let mut parent: Option<Resolved> = None;
7266 loop {
7267 let asked = self
7268 .graphql(
7269 graphql::SUB_ISSUES,
7270 json!({"id":id.0,"memberFirst":MEMBER_PAGE_SIZE,"after":after,
7271 "nestedFirst":NESTED_PAGE_SIZE,
7272 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
7273 )
7274 .await;
7275 let data = match asked {
7276 Ok(data) => data,
7277 // A string that is not a node id at all is not a failure to report: it is
7278 // the ordinary answer to a selector naming a project by its name.
7279 Err(error) if unresolvable_node(&error) => return Ok(None),
7280 Err(error) => return Err(error),
7281 };
7282 let Some(connection) = data
7283 .pointer("/node/subIssues")
7284 .filter(|value| !value.is_null())
7285 else {
7286 // No such node, or one with no sub-issue relationship — a board draft is
7287 // the one this board can really hold.
7288 return Ok(None);
7289 };
7290 if after.is_none()
7291 && let Some(node) = data.get("node").filter(|node| node.get("id").is_some())
7292 {
7293 parent = self
7294 .resolve_issue(node)
7295 .await?
7296 .filter(|resolved| resolved.id == *id);
7297 }
7298 for node in connection
7299 .get("nodes")
7300 .and_then(Value::as_array)
7301 .ok_or_else(|| SourceError::Malformed {
7302 message: "GitHub subIssues.nodes is not an array".into(),
7303 })?
7304 {
7305 if let Some(resolved) = self.resolve_issue(node).await? {
7306 children.push(resolved);
7307 }
7308 }
7309 let info = connection
7310 .get("pageInfo")
7311 .ok_or_else(|| SourceError::Malformed {
7312 message: "GitHub subIssues connection has no pageInfo".into(),
7313 })?;
7314 let next = required_bool(info, "hasNextPage")?
7315 .then(|| required_str(info, "endCursor"))
7316 .transpose()?;
7317 match next {
7318 Some(next) => {
7319 validate_cursor_progress(after.as_deref(), next)?;
7320 after = Some(next.to_owned());
7321 }
7322 None => {
7323 // Held only once the walk has ended, so one failing part way holds
7324 // nothing and every read it would have answered is made.
7325 // The project issue the first page carried answers the next read of the
7326 // project by its id in this command, as a read of it made now would: what
7327 // classifying a project for a write reads straight after its members.
7328 if let Some(parent) = &parent {
7329 self.read_ahead()?
7330 .entry(parent.id.clone())
7331 .or_insert_with(|| Some(parent.clone()));
7332 }
7333 let mut carried = self.carried()?;
7334 for item in parent.into_iter().chain(children.iter().cloned()) {
7335 carried.insert(item.id.clone(), item);
7336 }
7337 return Ok(Some(children));
7338 }
7339 }
7340 }
7341 }
7342
7343 fn carried(
7344 &self,
7345 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
7346 self.carried.lock().map_err(|_| SourceError::Unavailable {
7347 message: "items a project's listing carried were left inconsistent; run the \
7348 command again"
7349 .into(),
7350 })
7351 }
7352
7353 /// Which issue of this board a project *name* is, or `None` when none is.
7354 ///
7355 /// One bounded query which filters on that name at the server, rather than a walk of
7356 /// every issue the board holds. The name is compared again here: the qualifier narrows
7357 /// what GitHub sends, and this source decides what it names.
7358 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
7359 let search = self.board_search(Some(&title_qualifier(name)));
7360 let mut after = None;
7361 loop {
7362 let (candidates, next) = self
7363 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7364 .await?;
7365 if let Some(item) = candidates.into_iter().find(|item| {
7366 item.kind == BoardKind::Work(ItemKind::Project)
7367 && item.title.eq_ignore_ascii_case(name)
7368 }) {
7369 return Ok(Some(item.id));
7370 }
7371 match next {
7372 Some(next) => after = Some(next),
7373 None => return Ok(None),
7374 }
7375 }
7376 }
7377
7378 /// Everything filed under one project of this board: the sub-issues of the issue that
7379 /// project is.
7380 ///
7381 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
7382 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
7383 /// gains projects, or as another project gains tasks.
7384 ///
7385 /// A qualified id names the issue and is asked for its sub-issues directly: one
7386 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
7387 /// read as a project *name*, which costs the one bounded search
7388 /// [`Self::project_by_name`] makes.
7389 ///
7390 /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
7391 /// — and each answer is still cut to the items whose parent is this project, so one this
7392 /// process has since filed elsewhere is not reported here, and completed with what this
7393 /// process filed under it.
7394 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
7395 let held = self.children_cache()?.get(selector).cloned();
7396 let (project, children) = match held {
7397 Some(held) => held,
7398 None => {
7399 let answered = match self.sub_issues(selector).await? {
7400 Some(children) => (selector.clone(), children),
7401 None => match self.project_by_name(&selector.0).await? {
7402 Some(project) => {
7403 let children = self.sub_issues(&project).await?.unwrap_or_default();
7404 (project, children)
7405 }
7406 None => return Ok(Vec::new()),
7407 },
7408 };
7409 self.children_cache()?
7410 .insert(selector.clone(), answered.clone());
7411 answered
7412 }
7413 };
7414 let mut children: Vec<Resolved> = children
7415 .into_iter()
7416 .filter(|child| child.parent.as_ref() == Some(&project))
7417 .collect();
7418 for own in self.updated()?.iter() {
7419 if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
7420 children.push(own.clone());
7421 }
7422 }
7423 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
7424 }
7425
7426 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
7427 /// completed with what this run wrote — the candidates a comment-activity read confirms.
7428 ///
7429 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
7430 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
7431 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
7432 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
7433 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
7434 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
7435 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
7436 /// rather than silently narrowing a caller's answer.
7437 ///
7438 /// The instant is written to the second, rounded down, which can only widen what the
7439 /// search returns; confirmation against each candidate's own comments is what makes the
7440 /// answer exact. The search is an index that lags a write by a second or two — the module
7441 /// documentation records it — so a caller that asks again from its last instant should
7442 /// overlap the two by more than that.
7443 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
7444 let found = self.searched(&updated_qualifier(since)).await?;
7445 self.completed_with_written(found, |_| true)
7446 }
7447
7448 /// Every issue of this board GitHub's issue search reports for the board-scoped search
7449 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
7450 ///
7451 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
7452 /// own record is the fresher of the two.
7453 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
7454 let search = self.board_search(Some(also));
7455 let mut after: Option<String> = None;
7456 let mut found = Vec::new();
7457 loop {
7458 let (page, next) = self
7459 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
7460 .await?;
7461 found.extend(page);
7462 match next {
7463 Some(next) => after = Some(next),
7464 None => return Ok(found),
7465 }
7466 }
7467 }
7468
7469 /// A bounded task answer; the versioned cursor carries the connection position, how
7470 /// many rows of the page starting there were already handed out, and the own-write ids
7471 /// already observed, including across a new source instance.
7472 ///
7473 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
7474 /// sliced from the pages it needs; why is the module documentation's paging contract.
7475 async fn search_tasks(
7476 &self,
7477 query: &TaskQuery,
7478 page: &PageRequest,
7479 also: &str,
7480 ) -> Result<Page<Task>, SourceError> {
7481 let mut position = match &page.cursor {
7482 None => SearchPosition::default(),
7483 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
7484 .ok()
7485 .filter(|position| {
7486 position.version == SEARCH_CURSOR_VERSION
7487 && position.connection.valid_resume(position.offset)
7488 })
7489 .ok_or_else(|| SourceError::Config {
7490 message: "page cursor is invalid".into(),
7491 })?,
7492 };
7493 let search = self.board_search(Some(also));
7494 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
7495 let own = self.with_own_writes(Vec::new())?;
7496 // An issue this process commented on is a candidate of a comment-activity read
7497 // whether or not the search has caught up with the comment; see `Self::commented`.
7498 let commented = match query.commented_since {
7499 Some(_) => self.commented()?.clone(),
7500 None => Vec::new(),
7501 };
7502 for id in own.iter().map(|item| &item.id).chain(&commented) {
7503 if !position.own.contains(id) {
7504 position.own.push(id.clone());
7505 }
7506 }
7507 let mut tasks = Vec::new();
7508 while !position.connection.exhausted() && tasks.len() < limit {
7509 let first = SEARCH_PAGE_SIZE;
7510 // Page size is part of the key: a short cached answer cannot answer a wider ask.
7511 let key =
7512 serde_json::to_string(&("page", &search, &position.connection.after(), first))
7513 .expect("search page key is serializable");
7514 let cached = if query.commented_since.is_none() {
7515 self.narrowed_cache()?.get(&key).cloned()
7516 } else {
7517 None
7518 };
7519 let (found, next) = match cached {
7520 Some(found) => {
7521 let next = self
7522 .search_next
7523 .lock()
7524 .map_err(|_| SourceError::Unavailable {
7525 message:
7526 "search pagination was left inconsistent; run the command again"
7527 .into(),
7528 })?
7529 .get(&key)
7530 .cloned()
7531 .flatten();
7532 (found, next)
7533 }
7534 None => {
7535 let (found, next) = self
7536 .search_page(&search, first, position.connection.after())
7537 .await?;
7538 if query.commented_since.is_none() {
7539 self.search_next
7540 .lock()
7541 .map_err(|_| SourceError::Unavailable {
7542 message:
7543 "search pagination was left inconsistent; run the command again"
7544 .into(),
7545 })?
7546 .insert(key.clone(), next.clone());
7547 self.narrowed_cache()?.insert(key, found.clone());
7548 }
7549 (found, next)
7550 }
7551 };
7552 let rows = found.len();
7553 // Every candidate of the page this read confirms by its comments is read together.
7554 let candidates =
7555 self.comment_candidates(found.iter().skip(position.offset), query, &query.project)?;
7556 self.hold_comment_pages(&candidates).await?;
7557 for mut item in found.into_iter().skip(position.offset) {
7558 if tasks.len() == limit {
7559 break;
7560 }
7561 position.offset += 1;
7562 if position.own.contains(&item.id) {
7563 if position.seen.contains(&item.id) {
7564 continue;
7565 }
7566 position.seen.push(item.id.clone());
7567 // The search's own copy of an issue this process only commented on is as
7568 // good as a node read of it, since its comments are read either way.
7569 let only_commented = commented.contains(&item.id)
7570 && !own.iter().any(|written| written.id == item.id);
7571 if !only_commented {
7572 let updated_at = item.updated_at;
7573 let Some(written) = self.search_written(&own, &item.id).await? else {
7574 continue;
7575 };
7576 item = written;
7577 item.updated_at = item.updated_at.max(updated_at);
7578 self.resolved_cache()?.insert(item.id.clone(), item.clone());
7579 }
7580 }
7581 if item.kind == BoardKind::Work(ItemKind::Task) {
7582 let task = item.task()?;
7583 if task_matches(&task, query, &query.project)
7584 && self.commented_since(&item, query.commented_since).await?
7585 {
7586 tasks.push(task);
7587 }
7588 }
7589 }
7590 if position.offset < rows {
7591 continue;
7592 }
7593 position.offset = 0;
7594 position.connection = match next {
7595 Some(after) => SearchConnection::Continuing {
7596 after: Cursor(after),
7597 },
7598 None => SearchConnection::Exhausted {},
7599 };
7600 }
7601 if position.connection.exhausted() {
7602 for id in position.own.clone() {
7603 if position.seen.contains(&id) {
7604 continue;
7605 }
7606 if tasks.len() == limit {
7607 break;
7608 }
7609 position.seen.push(id.clone());
7610 let Some(item) = self.search_written(&own, &id).await? else {
7611 continue;
7612 };
7613 if item.kind == BoardKind::Work(ItemKind::Task) {
7614 let task = item.task()?;
7615 if task_matches(&task, query, &query.project)
7616 && self.commented_since(&item, query.commented_since).await?
7617 {
7618 tasks.push(task);
7619 }
7620 }
7621 }
7622 }
7623 let more = !position.connection.exhausted()
7624 || position.own.iter().any(|id| !position.seen.contains(id));
7625 Ok(Page {
7626 items: tasks,
7627 next: more.then(|| {
7628 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
7629 }),
7630 })
7631 }
7632
7633 /// A resumed process has the ids but no write records; resolve only a record the
7634 /// current page needs, by its uncached node read rather than the lagging search index.
7635 async fn search_written(
7636 &self,
7637 own: &[Resolved],
7638 id: &NativeId,
7639 ) -> Result<Option<Resolved>, SourceError> {
7640 match own.iter().find(|item| item.id == *id) {
7641 Some(item) => Ok(Some(item.clone())),
7642 None => self.item_by_id(id).await,
7643 }
7644 }
7645
7646 /// The candidates for a task query carrying a text, metadata or origin predicate, read
7647 /// without enumerating the board — or `None` for a query carrying none of the three, which
7648 /// keeps the reads it always had.
7649 ///
7650 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
7651 /// because it names at most a handful of items. Text and metadata are answered by one
7652 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
7653 /// further by `updated:>=` when the query also asks for comment activity, since both
7654 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
7655 /// process afterwards by the same predicates [`task_matches`] applies to every read.
7656 ///
7657 /// Completed with what this process wrote, its own record winning over the index's copy
7658 /// of the same item: see [`Self::with_own_writes`].
7659 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
7660 self.send_pending_origins(None).await?;
7661 let asked = match (&query.origin, narrowing_qualifiers(query)) {
7662 (Some(origin), _) => Narrowing::Origin(origin.clone()),
7663 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
7664 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
7665 None => qualifiers,
7666 }),
7667 (None, None) => return Ok(None),
7668 };
7669 // A question about comment activity is asked afresh every time, as it always was: it
7670 // is the one a caller polls from one source while waiting for the index, and an
7671 // answer held from the first poll would be the answer to every later one.
7672 let key = query.commented_since.is_none().then(|| asked.key());
7673 let cached = match &key {
7674 Some(key) => self.narrowed_cache()?.get(key).cloned(),
7675 None => None,
7676 };
7677 let found = match cached {
7678 Some(found) => found,
7679 None => {
7680 let found = match &asked {
7681 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
7682 Narrowing::Search(also) => self.searched(also).await?,
7683 };
7684 if let Some(key) = key {
7685 self.narrowed_cache()?.insert(key, found.clone());
7686 }
7687 found
7688 }
7689 };
7690 self.with_own_writes(found).map(Some)
7691 }
7692
7693 /// The candidates for a project or unscoped document query carrying a searchable text,
7694 /// read without enumerating the board — or `None` for a query with no text or a blank one,
7695 /// which keeps the read it always had.
7696 ///
7697 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
7698 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
7699 /// costs is the issues that match and never the board. Its answer is held for the command
7700 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
7701 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
7702 /// substring rule, exactly as an item of the wider read was, and is completed with what this
7703 /// process wrote: see [`Self::with_own_writes`].
7704 async fn text_searched(
7705 &self,
7706 text: Option<&TextQuery>,
7707 ) -> Result<Option<Vec<Resolved>>, SourceError> {
7708 let Some(also) = text_qualifiers(text) else {
7709 return Ok(None);
7710 };
7711 let key = Narrowing::Search(also.clone()).key();
7712 let cached = self.narrowed_cache()?.get(&key).cloned();
7713 let found = match cached {
7714 Some(found) => found,
7715 None => {
7716 let found = self.searched(&also).await?;
7717 self.narrowed_cache()?.insert(key, found.clone());
7718 found
7719 }
7720 };
7721 self.with_own_writes(found).map(Some)
7722 }
7723
7724 /// Every `kind` item of this board whose own origin is exactly `origin`: the candidates
7725 /// [`Self::narrowed`] reads for a task query carrying that origin — one
7726 /// [`graphql::ORIGIN_LOOKUP`] walk, held for the command under the same key, completed with
7727 /// what this process wrote — each confirmed against its own origin entry.
7728 ///
7729 /// What answers a project or document listing by origin, so finding every copy of one plan
7730 /// costs the items that carry its origin and never a page of the board.
7731 async fn copied_from(
7732 &self,
7733 origin: &str,
7734 kind: BoardKind,
7735 ) -> Result<Vec<Resolved>, SourceError> {
7736 let wanted = TaskQuery {
7737 origin: Some(origin.to_owned()),
7738 ..TaskQuery::default()
7739 };
7740 Ok(self
7741 .narrowed(&wanted)
7742 .await?
7743 .unwrap_or_default()
7744 .into_iter()
7745 .filter(|item| item.kind == kind && wanted.origin_matches(&item.metadata()))
7746 .collect())
7747 }
7748
7749 /// Every item of this board that may carry `origin` — a superset of those that do — found
7750 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
7751 ///
7752 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
7753 /// which reads the field every carrier holds, whichever release wrote it — and the
7754 /// board-scoped issue search for the same id as a phrase in the body, where this source
7755 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
7756 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
7757 /// query's, exactly.
7758 ///
7759 /// Both connections are walked to exhaustion, each from its own cursor. One that has
7760 /// already ended is sent its last cursor again, which answers an empty page, so the one
7761 /// document serves every page of either. What the two leave is stated in the module
7762 /// documentation: a carrier another process added within the last second or two, before
7763 /// either index has it.
7764 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
7765 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
7766 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
7767 let mut items_after: Option<String> = None;
7768 let mut search_after: Option<String> = None;
7769 let mut found: Vec<Resolved> = Vec::new();
7770 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
7771 if !found.iter().any(|held| held.id == resolved.id) {
7772 found.push(resolved);
7773 }
7774 };
7775 loop {
7776 let data = self
7777 .graphql(
7778 graphql::ORIGIN_LOOKUP,
7779 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
7780 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
7781 "itemsAfter":items_after,"searchAfter":search_after,
7782 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7783 "duplicates":true}),
7784 )
7785 .await?;
7786 let items = data
7787 .pointer("/originItems/projectV2/items")
7788 .filter(|value| !value.is_null())
7789 .ok_or_else(|| SourceError::Refused {
7790 message: format!(
7791 "GitHub project {}/{} was not found or is not visible to the token",
7792 self.owner, self.project_number
7793 ),
7794 })?;
7795 for item in optional_nodes(Some(items), "project items")?
7796 .into_iter()
7797 .flatten()
7798 {
7799 // The board's own items list its drafts too, and a draft is not an issue: no
7800 // narrowed read answers with one, whatever its origin field holds.
7801 if let Some(resolved) = self.resolve(item)?
7802 && resolved.content_kind == ContentKind::Issue
7803 {
7804 keep(resolved, &mut found);
7805 }
7806 }
7807 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
7808 message: "GitHub search response has no search connection".into(),
7809 })?;
7810 for node in optional_nodes(Some(searched), "search")?
7811 .into_iter()
7812 .flatten()
7813 {
7814 if let Some(resolved) = self.resolve_issue(node).await? {
7815 keep(resolved, &mut found);
7816 }
7817 }
7818 let items_next = resumed(items, items_after.as_deref())?;
7819 let search_next = resumed(searched, search_after.as_deref())?;
7820 if !items_next.has_more() && !search_next.has_more() {
7821 return Ok(found);
7822 }
7823 items_after = items_next.cursor();
7824 search_after = search_next.cursor();
7825 }
7826 }
7827
7828 /// `found`, with every item this process created or wrote in its place, and every one of
7829 /// them the read did not report added.
7830 ///
7831 /// This process's own record wins over the read's copy of the same item, because a read
7832 /// of an item written moments ago can still be behind what was written onto it — the
7833 /// origin field included, which is the one a narrowed read is confirmed against — and a
7834 /// read that still names an item under a predicate this process's write moved it out of
7835 /// must not return it. The one thing the read knows that the record cannot is when GitHub
7836 /// last saw the item change, which is what a comment-activity read rules a candidate out
7837 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
7838 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
7839 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
7840 // A board draft is not an issue, so no narrowed read returns one, and this process
7841 // having written one does not make it an answer either.
7842 let own: Vec<Resolved> = self
7843 .created()?
7844 .iter()
7845 .chain(self.updated()?.iter())
7846 .filter(|own| own.content_kind == ContentKind::Issue)
7847 .cloned()
7848 .collect();
7849 for mut own in own {
7850 self.resolved_cache()?.insert(own.id.clone(), own.clone());
7851 match found.iter_mut().find(|read| read.id == own.id) {
7852 Some(read) => {
7853 own.updated_at = own.updated_at.max(read.updated_at);
7854 *read = own;
7855 }
7856 None => found.push(own),
7857 }
7858 }
7859 Ok(found)
7860 }
7861
7862 /// Whether `item` has a comment created or last edited at or after `since` — always, when
7863 /// there is no instant to hold it to.
7864 ///
7865 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
7866 /// or after the instant moved it there: an issue not updated since holds no such comment,
7867 /// and its comments are never asked for — unless this process commented on it in this
7868 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
7869 /// only as far as the first that matches. A board draft is not an issue and has no
7870 /// comments, so it never matches.
7871 async fn commented_since(
7872 &self,
7873 item: &Resolved,
7874 since: Option<DateTime<Utc>>,
7875 ) -> Result<bool, SourceError> {
7876 let Some(since) = since else {
7877 return Ok(true);
7878 };
7879 if item.content_kind == ContentKind::DraftIssue {
7880 return Ok(false);
7881 }
7882 // An `updatedAt` this process's own record or a lagging index holds can predate a
7883 // comment this process wrote since, so only an issue it did not comment on is ruled
7884 // out by one.
7885 if item.updated_at.is_some_and(|updated| updated < since)
7886 && !self.commented()?.contains(&item.id)
7887 {
7888 return Ok(false);
7889 }
7890 let query = TaskQuery {
7891 commented_since: Some(since),
7892 ..TaskQuery::default()
7893 };
7894 let mut after: Option<String> = None;
7895 // A first page read beside its fellow candidates answers as this read's own would.
7896 let held = self.held_comments()?.get(&item.id).cloned();
7897 if let Some(first) = held {
7898 if query.comments_match(&first.items) {
7899 return Ok(true);
7900 }
7901 match first.next {
7902 Some(next) => after = Some(next.0),
7903 None => return Ok(false),
7904 }
7905 }
7906 loop {
7907 let data = self
7908 .graphql(
7909 graphql::ISSUE_COMMENTS,
7910 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
7911 )
7912 .await?;
7913 let Some(connection) = data
7914 .get("node")
7915 .filter(|value| !value.is_null())
7916 .and_then(|node| node.get("comments"))
7917 .filter(|value| !value.is_null())
7918 else {
7919 // Removed since the search reported it: no longer an issue with comments.
7920 return Ok(false);
7921 };
7922 let comments = optional_nodes(Some(connection), "issue comments")?
7923 .into_iter()
7924 .flatten()
7925 .map(comment_from)
7926 .collect::<Result<Vec<_>, _>>()?;
7927 if query.comments_match(&comments) {
7928 return Ok(true);
7929 }
7930 match next_cursor(connection)? {
7931 Some(next) => {
7932 validate_cursor_progress(after.as_deref(), &next.0)?;
7933 after = Some(next.0);
7934 }
7935 None => return Ok(false),
7936 }
7937 }
7938 }
7939
7940 /// Every item on the board: the union of both enumerations GitHub offers of one.
7941 ///
7942 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
7943 /// board **draft** and reads the board's own fields beside its items, and only the search
7944 /// reports an item that connection is behind on. The module documentation is where the lag and the
7945 /// measurements behind it are written down.
7946 ///
7947 /// A search result is admitted on the same terms as any other issue this source reaches
7948 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
7949 /// names *this* board — so an issue the index still believes is here after it was taken
7950 /// off is refused rather than reported.
7951 ///
7952 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
7953 /// which is what the cache could otherwise have broken.
7954 async fn board(&self) -> Result<Board, SourceError> {
7955 self.board_completed_by(None).await
7956 }
7957
7958 /// [`Self::board`], its search half narrowed by `also` when it is given — which an issue
7959 /// that half could add is then answered only if it holds.
7960 async fn board_completed_by(&self, also: Option<&str>) -> Result<Board, SourceError> {
7961 let cached = self.board_cache()?.clone();
7962 let mut board = match cached {
7963 Some(board) => board,
7964 None => {
7965 let read = self.read_board().await?;
7966 *self.board_cache()? = Some(read.clone());
7967 read
7968 }
7969 };
7970 let searched = match also {
7971 None => self.searched_issues().await?,
7972 Some(also) => self.searched_narrowly(also).await?,
7973 };
7974 // A complete sweep asks for every page of this view. Reconcile ids once per
7975 // view, rather than scanning the board for every search result on every page.
7976 let mut ids: std::collections::HashSet<_> =
7977 board.items.iter().map(|item| item.id.clone()).collect();
7978 for held in searched {
7979 if ids.insert(held.id.clone()) {
7980 board.items.push(held);
7981 }
7982 }
7983 for own in self.created()?.iter() {
7984 if ids.insert(own.id.clone()) {
7985 board.items.push(own.clone());
7986 }
7987 }
7988 Ok(board)
7989 }
7990
7991 /// This process's own view of the board, or the refusal a poisoned lock is.
7992 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
7993 self.board_cache
7994 .lock()
7995 .map_err(|_| SourceError::Unavailable {
7996 message: "this source's view of the board was left inconsistent by an earlier \
7997 failure; next: run the command again"
7998 .into(),
7999 })
8000 }
8001
8002 /// Bring this process's own view of the board up to an item it has just written.
8003 ///
8004 /// A created item goes to `created`, which is what completes a board read GitHub's own
8005 /// eventual consistency has left behind. An item that was already there is replaced
8006 /// where it sits, so a second write of it in the same command reads its real parent
8007 /// rather than the one it had before the first write.
8008 ///
8009 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
8010 /// that wins: an item this same run created is held in `created` and not in the cached
8011 /// board, and `board` completes the cached board *from* `created`, so replacing only
8012 /// the cached copy of such an item replaces nothing and the read still reports the
8013 /// title it was created with. The search is the third, and it is the one an item the
8014 /// board's own projection is behind on sits in *alone* — which is exactly the item this
8015 /// source is least able to re-read, so leaving it out would put the stale title back on
8016 /// the only items the completion in [`Self::board`] exists for.
8017 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
8018 self.resolved_cache()?.insert(item.id.clone(), item.clone());
8019 if created {
8020 self.created()?.push(item);
8021 return Ok(());
8022 }
8023 {
8024 let mut own = self.created()?;
8025 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
8026 *held = item;
8027 return Ok(());
8028 }
8029 }
8030 {
8031 let mut own = self.updated()?;
8032 match own.iter_mut().find(|held| held.id == item.id) {
8033 Some(held) => *held = item.clone(),
8034 None => own.push(item.clone()),
8035 }
8036 }
8037 if let Some(board) = self.board_cache()?.as_mut()
8038 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
8039 {
8040 *held = item.clone();
8041 }
8042 if let Some(found) = self.search_cache()?.as_mut()
8043 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
8044 {
8045 *held = item.clone();
8046 }
8047 for found in self.narrowed_cache()?.values_mut() {
8048 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8049 *held = item.clone();
8050 }
8051 }
8052 for (_, found) in self.children_cache()?.values_mut() {
8053 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
8054 *held = item.clone();
8055 }
8056 }
8057 Ok(())
8058 }
8059
8060 /// Forget one item this process has just deleted, from every half of its own view.
8061 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
8062 if let Some(Deferred::Held { created, .. }) = self.deferred()?.as_mut() {
8063 created.retain(|held| held.content != *id);
8064 }
8065 self.resolved_cache()?.remove(id);
8066 self.read_ahead()?.remove(id);
8067 self.carried()?.remove(id);
8068 self.created()?.retain(|own| own.id != *id);
8069 self.updated()?.retain(|own| own.id != *id);
8070 self.commented()?.retain(|own| own != id);
8071 if let Some(board) = self.board_cache()?.as_mut() {
8072 board.items.retain(|item| item.id != *id);
8073 }
8074 if let Some(found) = self.search_cache()?.as_mut() {
8075 found.retain(|item| item.id != *id);
8076 }
8077 for found in self.narrowed_cache()?.values_mut() {
8078 found.retain(|item| item.id != *id);
8079 }
8080 for (_, found) in self.children_cache()?.values_mut() {
8081 found.retain(|item| item.id != *id);
8082 }
8083 Ok(())
8084 }
8085
8086 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
8087 fn narrowed_cache(
8088 &self,
8089 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
8090 self.narrowed_cache
8091 .lock()
8092 .map_err(|_| SourceError::Unavailable {
8093 message: "this source's view of a narrowed read was left inconsistent by an \
8094 earlier failure; next: run the command again"
8095 .into(),
8096 })
8097 }
8098
8099 /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
8100 /// is.
8101 /// The tasks `project` holds, as a copy's batched read found them this command, when it did.
8102 fn held_task_children(&self, project: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
8103 Ok(self.task_children()?.get(project).cloned())
8104 }
8105
8106 fn task_children(
8107 &self,
8108 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Vec<Resolved>>>, SourceError> {
8109 self.task_children
8110 .lock()
8111 .map_err(|_| SourceError::Unavailable {
8112 message: "this source's record of a project's tasks was left inconsistent by an \
8113 earlier failure; next: run the command again"
8114 .into(),
8115 })
8116 }
8117
8118 fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
8119 self.children_cache
8120 .lock()
8121 .map_err(|_| SourceError::Unavailable {
8122 message: "this source's view of a project's tasks was left inconsistent by an \
8123 earlier failure; next: run the command again"
8124 .into(),
8125 })
8126 }
8127
8128 /// Every page of the board, read from GitHub.
8129 async fn read_board(&self) -> Result<Board, SourceError> {
8130 let mut after: Option<String> = None;
8131 let mut items = Vec::new();
8132 let mut board;
8133 loop {
8134 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
8135 for item in page
8136 .pointer("/items/nodes")
8137 .and_then(Value::as_array)
8138 .ok_or_else(|| SourceError::Malformed {
8139 message: "GitHub project items.nodes is not an array".into(),
8140 })?
8141 {
8142 if let Some(resolved) = self.resolve(item)? {
8143 items.push(resolved);
8144 }
8145 }
8146 let info = page
8147 .pointer("/items/pageInfo")
8148 .ok_or_else(|| SourceError::Malformed {
8149 message: "GitHub project items have no pageInfo".into(),
8150 })?;
8151 let has_next = required_bool(info, "hasNextPage")?;
8152 let next = has_next
8153 .then(|| required_str(info, "endCursor"))
8154 .transpose()?;
8155 board = page.clone();
8156 match next {
8157 Some(next) => {
8158 validate_cursor_progress(after.as_deref(), next)?;
8159 after = Some(next.to_owned());
8160 }
8161 None => break,
8162 }
8163 }
8164 Ok(Board {
8165 id: required_str(&board, "id")?.to_owned(),
8166 fields: board.get("fields").cloned().unwrap_or(Value::Null),
8167 items,
8168 })
8169 }
8170
8171 /// The existing items this source has written, for completing a narrowed read that is
8172 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
8173 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8174 self.updated.lock().map_err(|_| SourceError::Unavailable {
8175 message: "this source's record of what it wrote in this run was left inconsistent \
8176 by an earlier failure; next: run the command again"
8177 .into(),
8178 })
8179 }
8180
8181 /// The issues this source has commented on in this command; see
8182 /// [`Self::commented`](GitHubProjectsSource::commented).
8183 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
8184 self.commented.lock().map_err(|_| SourceError::Unavailable {
8185 message: "this source's record of what it commented on in this run was left \
8186 inconsistent by an earlier failure; next: run the command again"
8187 .into(),
8188 })
8189 }
8190
8191 /// Called only once GitHub has answered the comment write, so an issue whose comment
8192 /// failed is never made a candidate a later read would pay a node read for.
8193 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
8194 // A comment page held for this command is no longer what the issue holds.
8195 self.held_comments()?.remove(issue);
8196 let mut commented = self.commented()?;
8197 if !commented.contains(issue) {
8198 commented.push(issue.clone());
8199 }
8200 Ok(())
8201 }
8202
8203 /// The items this source has created, for completing a board read that is behind.
8204 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
8205 self.created.lock().map_err(|_| SourceError::Unavailable {
8206 message: "this source's record of what it created in this run was left \
8207 inconsistent by an earlier failure; next: run the command again"
8208 .into(),
8209 })
8210 }
8211
8212 /// One board item as this source reports it, or `None` for content it ignores.
8213 ///
8214 /// A pull request is neither a project nor a task — it is somebody's change, not a
8215 /// unit of plan — and an item whose content the token cannot see has nothing to
8216 /// report at all.
8217 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8218 let resolved = self.resolve_unremembered(item)?;
8219 if let Some(resolved) = &resolved {
8220 self.resolved_cache()?
8221 .insert(resolved.id.clone(), resolved.clone());
8222 }
8223 Ok(resolved)
8224 }
8225
8226 /// [`Self::resolve`], without remembering the item it resolves: what reading a token off a
8227 /// write's own response needs, which says nothing this process should answer a later read
8228 /// with.
8229 fn resolve_unremembered(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
8230 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
8231 message: "GitHub project item is missing content".into(),
8232 })?;
8233 if content.is_null() {
8234 return Ok(None);
8235 }
8236 let content_kind = match required_str(content, "__typename")? {
8237 "Issue" => ContentKind::Issue,
8238 "DraftIssue" => ContentKind::DraftIssue,
8239 _ => return Ok(None),
8240 };
8241 let field_values = item
8242 .get("fieldValues")
8243 .ok_or_else(|| SourceError::Malformed {
8244 message: "GitHub project item is missing fieldValues".into(),
8245 })?;
8246 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
8247 let nodes = field_values
8248 .get("nodes")
8249 .and_then(Value::as_array)
8250 .ok_or_else(|| SourceError::Malformed {
8251 message: "GitHub project item fieldValues.nodes is not an array".into(),
8252 })?;
8253 if let Some(labels) = content.get("labels") {
8254 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
8255 }
8256 let raw_body = optional_str(content, "body")?.map(str::to_owned);
8257 let (body, slot) = self.visible(raw_body.clone())?;
8258 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
8259 .map(|id| NativeId(id.to_owned()));
8260 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
8261 // to read one from; it is a task, and never a project.
8262 let sub_issues = match content_kind {
8263 ContentKind::Issue => sub_issue_total(content)?,
8264 ContentKind::DraftIssue => 0,
8265 };
8266 let content_id = required_str(content, "id")?;
8267 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8268 message: format!("GitHub issue {content_id}: {message}"),
8269 })?;
8270 let raw_title = required_str(content, "title")?;
8271 // Read as children, a sub-issue is filed under no project and nothing is a project: the
8272 // GitHub parent is the parent relation instead.
8273 let (kind, parent, issue_parent) = match self.sub_issues {
8274 SubIssues::Project => (
8275 BoardKind::of(raw_title, parent.is_some(), sub_issues, marked),
8276 parent,
8277 None,
8278 ),
8279 SubIssues::Parent => (
8280 if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
8281 BoardKind::Document
8282 } else {
8283 BoardKind::Work(ItemKind::Task)
8284 },
8285 None,
8286 parent,
8287 ),
8288 };
8289 // The title a person wrote, which for a document is the one without the prefix —
8290 // the same way `content` above is the body without this source's metadata slot.
8291 let title = match kind {
8292 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
8293 BoardKind::Work(_) => raw_title.to_owned(),
8294 };
8295 let own_repository = content
8296 .pointer("/repository/nameWithOwner")
8297 .and_then(Value::as_str)
8298 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
8299 .transpose()
8300 .map_err(|message| SourceError::Malformed { message })?;
8301 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
8302 Repository::from_metadata(&slot)
8303 .map_err(|message| SourceError::Malformed { message })?
8304 } else {
8305 own_repository.clone().into_iter().collect()
8306 };
8307 let classification = Classification::from_metadata(&slot)
8308 .map_err(|message| SourceError::Malformed { message })?;
8309 let id = NativeId(content_id.to_owned());
8310 // Read only for a task, because only a task has either list: a project or a
8311 // document holding one of these keys holds nothing this source reports, and the
8312 // keys are left out of its caller-visible metadata all the same.
8313 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
8314 let listed = |key: &str| {
8315 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
8316 .map_err(|message| SourceError::Malformed { message })
8317 };
8318 (
8319 listed(TaskRef::DELIVERS_KEY)?,
8320 listed(TaskRef::DELIVERED_BY_KEY)?,
8321 )
8322 } else {
8323 (Vec::new(), Vec::new())
8324 };
8325 let (option, closed, reason) = Self::status_parts(nodes, content)?;
8326 let priority = self.held_priority(nodes)?;
8327 let item_labels = labels(content)?;
8328 // Present when the item was reached through its own issue, whose board entry
8329 // names the board; a read of the board's own items has the board already. An
8330 // empty id names nothing a field write could address, so it is read as absent and
8331 // the write goes back to reading the board.
8332 let board_id = item
8333 .pointer("/project/id")
8334 .and_then(Value::as_str)
8335 .filter(|id| !id.is_empty());
8336 let resolved = Resolved {
8337 item_id: required_str(item, "id")?.to_owned(),
8338 id,
8339 content_kind,
8340 kind,
8341 title,
8342 body: body.filter(|value| !value.is_empty()),
8343 raw_body,
8344 status: self
8345 .statuses
8346 .status(kind.status_kind(), option, closed, reason),
8347 option: option.map(str::to_owned),
8348 priority,
8349 closed,
8350 reason: closed.then(|| reason.map(str::to_owned)).flatten(),
8351 delivers,
8352 delivered_by,
8353 labels: item_labels.clone(),
8354 parent,
8355 issue_parent,
8356 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
8357 projected: self.held_projections(nodes)?,
8358 number: match content_kind {
8359 ContentKind::Issue => Some(issue_number(content)?),
8360 // A draft is filed in no repository, so nothing ever numbered it:
8361 // `DraftIssue` declares no `number` at all, exactly as it declares no
8362 // `subIssuesSummary` the branch above reads.
8363 ContentKind::DraftIssue => None,
8364 },
8365 url: optional_str(content, "url")?.map(str::to_owned),
8366 created_at: optional_time(content, "createdAt")?,
8367 updated_at: optional_time(content, "updatedAt")?,
8368 item_updated_at: optional_time(item, "updatedAt")?,
8369 comment_count: content
8370 .pointer("/commentCount/totalCount")
8371 .and_then(Value::as_u64),
8372 relationship_events: content
8373 .pointer("/relationshipEvents/totalCount")
8374 .and_then(Value::as_u64),
8375 // The issue node is stamped wherever it was read; a board item a read of the issue
8376 // put together from its memberships is not.
8377 served_at: match optional_time(content, SERVED_AT)? {
8378 Some(served) => Some(served),
8379 None => optional_time(item, SERVED_AT)?,
8380 },
8381 // Both connections were read to their end above — one reporting a further page is
8382 // refused — so these are every field value and every label the item holds. A
8383 // response that did not select the labels at all says nothing of them, and gives
8384 // no facets.
8385 facets: content.get("labels").is_some().then(|| {
8386 change_token::Facets::new(kind.facet(), facet_fields(nodes), &item_labels).digest()
8387 }),
8388 own_repository,
8389 repositories,
8390 classification,
8391 slot,
8392 board_id: board_id.map(str::to_owned),
8393 fields: field_definitions(nodes),
8394 board_fields: Self::carried_board_fields(content, board_id)?,
8395 blocked_by: carried_blocked_by(content)?,
8396 };
8397 Ok(Some(resolved))
8398 }
8399
8400 /// The field definitions of the board `board_id` names — the project this issue's own
8401 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
8402 /// `None` when the read carried none, carried no entry for that board, or the board item
8403 /// named no board, which a write then answers by reading the board's fields itself.
8404 ///
8405 /// Matched by the board's node id and never by its number alone: a project number is
8406 /// unique only within its owner, so another owner's board numbered alike can sit on the
8407 /// same page, and its field and option ids address nothing on this one.
8408 fn carried_board_fields(
8409 content: &Value,
8410 board_id: Option<&str>,
8411 ) -> Result<Option<Value>, SourceError> {
8412 let (Some(nodes), Some(board_id)) = (
8413 content.pointer("/boards/nodes").and_then(Value::as_array),
8414 board_id,
8415 ) else {
8416 return Ok(None);
8417 };
8418 let Some(board) = nodes.iter().find_map(|node| {
8419 let project = node.get("project")?;
8420 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
8421 }) else {
8422 return Ok(None);
8423 };
8424 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
8425 return Ok(None);
8426 };
8427 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
8428 Ok(Some(fields.clone()))
8429 }
8430
8431 /// What one board item's `Priority` field says, through this instance's mapping.
8432 ///
8433 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
8434 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
8435 /// option the mapping does not name is kept as itself — never read as a level or as
8436 /// `none` — for a read of the task to report by name.
8437 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
8438 let Some(mapping) = &self.priorities else {
8439 return Ok(HeldPriority::Read(Priority::None));
8440 };
8441 // A value of the field that names no option — a text field someone called `Priority` —
8442 // is malformed rather than `none`: reading it as no priority would let the next copy
8443 // clear one a person set.
8444 let Some(option) = field_values
8445 .iter()
8446 .find(|value| {
8447 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
8448 })
8449 .map(|value| required_str(value, "name"))
8450 .transpose()?
8451 else {
8452 return Ok(HeldPriority::Read(Priority::None));
8453 };
8454 Ok(mapping.priority_of(option).map_or_else(
8455 || HeldPriority::Unmapped(option.to_owned()),
8456 HeldPriority::Read,
8457 ))
8458 }
8459
8460 /// What each projected metadata field holds for one board item, by field name — every text
8461 /// value GitHub answers, an empty one included.
8462 fn held_projections(
8463 &self,
8464 field_values: &[Value],
8465 ) -> Result<BTreeMap<String, String>, SourceError> {
8466 let mut held = BTreeMap::new();
8467 for projection in &self.metadata_fields {
8468 if let Some(text) = text_field(field_values, &projection.field)? {
8469 held.insert(projection.field.to_string(), text);
8470 }
8471 }
8472 Ok(held)
8473 }
8474
8475 /// What one board item's status is read from: its `Status` option, whether its issue
8476 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
8477 /// three into the status it reports.
8478 fn status_parts<'a>(
8479 field_values: &'a [Value],
8480 content: &'a Value,
8481 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
8482 let option = field_values
8483 .iter()
8484 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
8485 .map(|value| required_str(value, "name"))
8486 .transpose()?;
8487 let closed = optional_str(content, "state")? == Some("CLOSED");
8488 Ok((option, closed, optional_str(content, "stateReason")?))
8489 }
8490
8491 /// The board Status option this write selects, or the refusal that says why not.
8492 ///
8493 /// The mapped option is required for both open and terminal targets. A terminal write
8494 /// validates it before changing either representation, so it can never fall back to
8495 /// closing an issue whose board cannot display the matching status.
8496 ///
8497 /// Answers the field's id, the option's id, and the option's name as the board spells
8498 /// it — which is the name a read of the item reports once it sits there.
8499 fn column_for(
8500 &self,
8501 fields: &Value,
8502 kind: ItemKind,
8503 category: StatusCategory,
8504 target: &StatusTarget,
8505 ) -> Result<Option<(String, String, String)>, SourceError> {
8506 let Some(wanted) = target.option() else {
8507 return Ok(None);
8508 };
8509 let missing = |detail: &str| SourceError::Refused {
8510 message: format!(
8511 "{} status {} of source {} needs the board Status option {wanted:?}, and \
8512 {detail}; next: add that option to the board, which `onetaskgraph sources \
8513 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
8514 it has",
8515 kind.marker(),
8516 category_name(category),
8517 self.name,
8518 self.name,
8519 category_name(category),
8520 kind.marker()
8521 ),
8522 };
8523 let Some(field) = Board::field(fields, "Status")? else {
8524 return Err(missing("this board has no Status field"));
8525 };
8526 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8527 return Err(missing(
8528 "this board's Status field is not a single-select field",
8529 ));
8530 }
8531 let option = field
8532 .get("options")
8533 .and_then(Value::as_array)
8534 .and_then(|options| {
8535 options.iter().find(|option| {
8536 option
8537 .get("name")
8538 .and_then(Value::as_str)
8539 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8540 })
8541 });
8542 match option {
8543 None => Err(missing("this board does not have it")),
8544 Some(option) => Ok(Some((
8545 required_str(field, "id")?.to_owned(),
8546 required_str(option, "id")?.to_owned(),
8547 required_str(option, "name")?.to_owned(),
8548 ))),
8549 }
8550 }
8551
8552 /// The refusal a status that closes an issue is answered with over a board draft.
8553 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
8554 SourceError::Refused {
8555 message: format!(
8556 "status {} of source {} closes the item's issue, and GitHub draft items have \
8557 no open or closed state",
8558 category_name(category),
8559 self.name
8560 ),
8561 }
8562 }
8563
8564 /// What a status write to one item needs of the board: the board's id and the
8565 /// definition of its `Status` field, read off the item when the item says both.
8566 ///
8567 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
8568 /// and its `Status` value carries that field's definition, options and all. An item that
8569 /// does not say — no board id, or no `Status` value to read the field off — takes them
8570 /// from [`Self::board_fields`], which reads no item.
8571 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8572 if let Some(board) = item.carried_board() {
8573 return Ok(board);
8574 }
8575 if item.defines("Status")
8576 && let Some(board_id) = item.named_board()
8577 {
8578 return Ok(BoardFields {
8579 id: board_id,
8580 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8581 });
8582 }
8583 self.board_fields().await
8584 }
8585
8586 /// Set one task's or one project's status and nothing else; see
8587 /// [`TaskSource::set_task_status`] and [`TaskSource::set_project_status`].
8588 async fn set_status(
8589 &self,
8590 id: &NativeId,
8591 kind: ItemKind,
8592 category: StatusCategory,
8593 ) -> Result<Option<Status>, SourceError> {
8594 // Refused before anything is read, in the words a write of the same status is.
8595 let target = self.resolved_target(kind, category)?;
8596 // An item read by issue reference carries only what this write needs, so what it
8597 // becomes is never remembered for a later read to be answered from.
8598 let held = self.take_held(id)?;
8599 let by_reference = held.is_some();
8600 let bound = match held {
8601 Some(item) => Some(item),
8602 None => self.bound_item(id).await?,
8603 };
8604 let Some(mut item) = bound.filter(|item| item.kind == BoardKind::Work(kind)) else {
8605 return Ok(None);
8606 };
8607 let board = self.status_board(&item).await?;
8608 let (field, option, name) = self
8609 .column_for(&board.fields, kind, category, &target)?
8610 .ok_or_else(|| SourceError::Malformed {
8611 message: format!(
8612 "status {} of source {} names no board Status option",
8613 category_name(category),
8614 self.name
8615 ),
8616 })?;
8617 if item.status.category == category && item.option.as_deref() == Some(&name) {
8618 return Ok(Some(item.status));
8619 }
8620 match &target {
8621 StatusTarget::Terminal(_, reason) => {
8622 if item.content_kind == ContentKind::DraftIssue {
8623 return Err(self.closes_a_draft(category));
8624 }
8625 // The option first, so a refused selection leaves an open issue open rather
8626 // than closed behind a card that still reads open.
8627 self.set_item_field(
8628 board.id.as_str(),
8629 &item.item_id,
8630 &field,
8631 json!({"singleSelectOptionId": option}),
8632 )
8633 .await?;
8634 // An issue already closed in a way that reads as this category keeps its state
8635 // and its reason: only its card was out of line, and closing it again would
8636 // overwrite a reason — a duplicate's — that says more than this write does.
8637 let already = item.closed && item.status.category == category;
8638 if !already {
8639 self.update_content(
8640 ContentKind::Issue,
8641 &item.id,
8642 json!({"stateInput": state_input(Some(&target))}),
8643 )
8644 .await?;
8645 }
8646 let reason = if already {
8647 item.reason.clone()
8648 } else {
8649 Some(reason.reason().to_owned())
8650 };
8651 item.closed = true;
8652 item.status = self
8653 .statuses
8654 .status(kind, Some(&name), true, reason.as_deref());
8655 item.option = Some(name);
8656 item.reason = reason;
8657 }
8658 StatusTarget::Column(_) => {
8659 // An option is what an open item's status is, so a closed issue is reopened
8660 // first — sitting closed in the column, it would read back as closed. A draft has
8661 // no state to reopen.
8662 if item.content_kind == ContentKind::Issue && item.closed {
8663 self.update_content(
8664 ContentKind::Issue,
8665 &item.id,
8666 json!({"stateInput": state_input(Some(&target))}),
8667 )
8668 .await?;
8669 item.closed = false;
8670 }
8671 self.set_item_field(
8672 board.id.as_str(),
8673 &item.item_id,
8674 &field,
8675 json!({"singleSelectOptionId": option}),
8676 )
8677 .await?;
8678 item.status = self.statuses.status(kind, Some(&name), false, None);
8679 item.option = Some(name);
8680 }
8681 StatusTarget::Disabled(_) => {
8682 unreachable!("resolved_target refused a disabled status")
8683 }
8684 }
8685 let status = item.status.clone();
8686 if !by_reference {
8687 self.remember_written(item, false)?;
8688 }
8689 Ok(Some(status))
8690 }
8691
8692 /// Replace one task's `delivered_by` and nothing else; see
8693 /// [`TaskSource::set_delivered_by`].
8694 ///
8695 /// One update of the body, which differs from the body GitHub holds only inside the
8696 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
8697 async fn replace_delivered_by(
8698 &self,
8699 id: &NativeId,
8700 delivered_by: &[TaskRef],
8701 ) -> Result<Option<()>, SourceError> {
8702 let entries = TaskRef::listed(
8703 TaskRef::DELIVERED_BY_KEY,
8704 id,
8705 Some(&self.name),
8706 delivered_by.to_vec(),
8707 )
8708 .map_err(|message| SourceError::Refused { message })?;
8709 let Some(mut item) = self
8710 .bound_item(id)
8711 .await?
8712 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8713 else {
8714 return Ok(None);
8715 };
8716 let mut slot = item.slot.clone();
8717 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
8718 self.write_slot(&mut item, &slot).await?;
8719 item.delivered_by = entries;
8720 self.remember_written(item, false)?;
8721 Ok(Some(()))
8722 }
8723
8724 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
8725 /// see [`TaskSource::set_task_metadata`].
8726 ///
8727 /// `None` when this board holds no item by that id, or holds one of another kind. The
8728 /// answer is the item as this source now reads it, so what a caller is told the key
8729 /// holds is what the slot holds.
8730 ///
8731 /// A key already holding the value is answered without a write, compared as JSON rather
8732 /// than as the body's bytes: a slot a person spelled with other whitespace would
8733 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
8734 async fn set_slot_key(
8735 &self,
8736 id: &NativeId,
8737 kind: BoardKind,
8738 key: &MetadataKey,
8739 value: &Value,
8740 ) -> Result<Option<Resolved>, SourceError> {
8741 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
8742 return Ok(None);
8743 };
8744 if item.slot.get(key.as_str()) == Some(value) {
8745 return Ok(Some(item));
8746 }
8747 let mut slot = item.slot.clone();
8748 slot.insert(key.as_str().to_owned(), value.clone());
8749 // A key a board text field projects moves that field first, so the body — the value's
8750 // one authoritative home — is written last, as every write of an existing item is.
8751 if self
8752 .metadata_fields
8753 .iter()
8754 .any(|projection| &*projection.key == key.as_str())
8755 {
8756 let board = self.projection_board(&item).await?;
8757 let (writes, projected) = self.projection_writes(
8758 &board.fields,
8759 Some(&item.projected),
8760 &slot,
8761 Some(&[key.as_str()]),
8762 )?;
8763 let (mut values, mut clears) = (Vec::new(), Vec::new());
8764 for write in &writes {
8765 write.join(&mut values, &mut clears);
8766 }
8767 self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
8768 .await?;
8769 item.projected = projected;
8770 }
8771 self.write_slot(&mut item, &slot).await?;
8772 self.remember_written(item.clone(), false)?;
8773 Ok(Some(item))
8774 }
8775
8776 /// What a projection write to `item` needs of the board, read off the item when it says
8777 /// enough, as [`Self::fields_for`] reads it.
8778 async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
8779 if let Some(board) = item.carried_board() {
8780 return Ok(board);
8781 }
8782 if let Some(board_id) = item.named_board()
8783 && self
8784 .metadata_fields
8785 .iter()
8786 .all(|projection| item.defines(&projection.field))
8787 {
8788 return Ok(BoardFields {
8789 id: board_id,
8790 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
8791 });
8792 }
8793 self.board_fields().await
8794 }
8795
8796 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
8797 /// `item` up to what that write left.
8798 ///
8799 /// The body sent differs from the body GitHub holds only inside the slot — see
8800 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
8801 /// the mutation the item's content takes, so a board draft's body is written with
8802 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
8803 async fn write_slot(
8804 &self,
8805 item: &mut Resolved,
8806 slot: &BTreeMap<String, Value>,
8807 ) -> Result<(), SourceError> {
8808 let held = item.raw_body.clone().unwrap_or_default();
8809 let body = with_slot(&held, slot)?;
8810 if body != held {
8811 self.update_content(item.content_kind, &item.id, json!({"body": body}))
8812 .await?;
8813 }
8814 let (visible, slot) = self.visible(Some(body.clone()))?;
8815 item.body = visible.filter(|value| !value.is_empty());
8816 item.raw_body = Some(body);
8817 item.slot = slot;
8818 Ok(())
8819 }
8820
8821 /// This instance's target for a category written to an item of `kind`, refusing one
8822 /// that kind has no option for — before anything is read or written.
8823 ///
8824 /// Nothing here mutates the board's option set to make room for a status. GitHub
8825 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
8826 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
8827 /// field and every item's status.
8828 fn resolved_target(
8829 &self,
8830 kind: ItemKind,
8831 category: StatusCategory,
8832 ) -> Result<StatusTarget, SourceError> {
8833 let target = self.statuses.target(kind, category).clone();
8834 let StatusTarget::Disabled(why) = target else {
8835 return Ok(target);
8836 };
8837 let refusal = why.refusal(&self.name, category, kind);
8838 // Why there is no shipped default, which is the question a person meeting this
8839 // refusal on a source that never mentioned the category asks.
8840 let shipped_none = match category {
8841 StatusCategory::Draft => Some(
8842 "draft has no shipped default because GitHub draft issues cannot have \
8843 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
8844 ),
8845 StatusCategory::Unknown => Some(
8846 "unknown has no shipped default because this board keeps no open-ended status \
8847 word: every word classified unknown is written to the one board Status option \
8848 status_mapping.unknown names",
8849 ),
8850 _ => None,
8851 };
8852 Err(match (refusal, shipped_none, why) {
8853 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
8854 SourceError::Refused {
8855 message: format!("{message}; {note}"),
8856 }
8857 }
8858 (refusal, _, _) => refusal,
8859 })
8860 }
8861
8862 /// What writing `priority` does to one item's `Priority` field on this board, or the
8863 /// refusal naming what the board lacks.
8864 ///
8865 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
8866 /// none already, or of an item not created yet. Every other priority selects the option
8867 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
8868 /// without that option, is refused rather than given one: reads and writes never create
8869 /// a field or an option.
8870 fn priority_write(
8871 &self,
8872 fields: &Value,
8873 existing: Option<&Resolved>,
8874 priority: Priority,
8875 ) -> Result<Option<PriorityWrite>, SourceError> {
8876 let Some(mapping) = &self.priorities else {
8877 return Err(self.holds_no_priority());
8878 };
8879 let Some(wanted) = mapping.option(priority) else {
8880 if !existing.is_some_and(Resolved::holds_priority) {
8881 return Ok(None);
8882 }
8883 let field =
8884 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
8885 message: format!(
8886 "an item holding a {PRIORITY_FIELD} value was read without that field"
8887 ),
8888 })?;
8889 return Ok(Some(PriorityWrite::Clear {
8890 field: required_str(field, "id")?.to_owned(),
8891 }));
8892 };
8893 let missing = |detail: &str| SourceError::Refused {
8894 message: format!(
8895 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
8896 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
8897 it, or point priority_mapping.{priority} of this source at an option the board \
8898 has",
8899 self.name, self.name
8900 ),
8901 };
8902 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
8903 return Err(missing(&format!(
8904 "this board has no {PRIORITY_FIELD} field"
8905 )));
8906 };
8907 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
8908 return Err(missing(&format!(
8909 "this board's {PRIORITY_FIELD} field is not a single-select field"
8910 )));
8911 }
8912 // An options list that is absent or not a list is an answer this source cannot read,
8913 // not a board lacking the option: `sources fields --apply` is no remedy for it.
8914 let option = field
8915 .get("options")
8916 .and_then(Value::as_array)
8917 .ok_or_else(|| SourceError::Malformed {
8918 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
8919 })?
8920 .iter()
8921 .find(|option| {
8922 option
8923 .get("name")
8924 .and_then(Value::as_str)
8925 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
8926 })
8927 .ok_or_else(|| missing("this board does not have it"))?;
8928 Ok(Some(PriorityWrite::Select {
8929 field: required_str(field, "id")?.to_owned(),
8930 option: required_str(option, "id")?.to_owned(),
8931 }))
8932 }
8933
8934 /// What writing `metadata` does to each projected text field of one item, and what those
8935 /// fields hold after it — or the refusal, before anything is sent.
8936 ///
8937 /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
8938 /// narrows the work to the entries projecting one of those metadata keys, for a write that
8939 /// touches only them; `None` is every entry. A string the field already holds, and nothing
8940 /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
8941 /// and so is a board without the field, or with a field of that name that is not a text
8942 /// field: reads and writes never create a field.
8943 fn projection_writes(
8944 &self,
8945 fields: &Value,
8946 held: Option<&BTreeMap<String, String>>,
8947 metadata: &BTreeMap<String, Value>,
8948 keys: Option<&[&str]>,
8949 ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
8950 let mut projected = held.cloned().unwrap_or_default();
8951 let mut writes = Vec::new();
8952 for projection in self
8953 .metadata_fields
8954 .iter()
8955 .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
8956 {
8957 let wanted = projection.projected(metadata, &self.name)?;
8958 let field = self.projection_field(fields, projection)?;
8959 let holding = projected.get(&*projection.field);
8960 match wanted {
8961 Projected::Text(text) if holding != Some(&text) => {
8962 writes.push(ProjectionWrite::Set {
8963 field,
8964 text: text.clone(),
8965 });
8966 projected.insert(projection.field.to_string(), text);
8967 }
8968 Projected::Nothing if holding.is_some() => {
8969 writes.push(ProjectionWrite::Clear { field });
8970 projected.remove(&*projection.field);
8971 }
8972 Projected::Text(_) | Projected::Nothing => {}
8973 }
8974 }
8975 Ok((writes, projected))
8976 }
8977
8978 /// The id of the board text field one projection writes, or the refusal naming what the
8979 /// board has instead.
8980 fn projection_field(
8981 &self,
8982 fields: &Value,
8983 projection: &MetadataField,
8984 ) -> Result<String, SourceError> {
8985 let refuse = |detail: String| SourceError::Refused {
8986 message: format!(
8987 "source {} projects metadata key {:?} onto the board text field {:?}, and \
8988 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
8989 text field, or name another field in metadata_fields",
8990 self.name, &*projection.key, &*projection.field, self.name
8991 ),
8992 };
8993 let Some(field) = Board::field(fields, &projection.field)? else {
8994 return Err(refuse("this board has no field of that name".to_owned()));
8995 };
8996 let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
8997 let data_type = optional_str(field, "dataType")?;
8998 if typename != "ProjectV2Field" || data_type != Some("TEXT") {
8999 return Err(refuse(format!(
9000 "this board's field of that name is not a text field (it is a {}), so it is \
9001 left as it is; rename or remove that field first",
9002 data_type.unwrap_or(typename)
9003 )));
9004 }
9005 Ok(required_str(field, "id")?.to_owned())
9006 }
9007
9008 /// Apply one priority write to one board item.
9009 async fn write_priority(
9010 &self,
9011 board_id: &str,
9012 item_id: &str,
9013 write: &PriorityWrite,
9014 ) -> Result<(), SourceError> {
9015 match write {
9016 PriorityWrite::Select { field, option } => {
9017 self.set_item_field(
9018 board_id,
9019 item_id,
9020 field,
9021 json!({"singleSelectOptionId": option}),
9022 )
9023 .await
9024 }
9025 PriorityWrite::Clear { field } => {
9026 let data = self
9027 .graphql(
9028 graphql::CLEAR_FIELD,
9029 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
9030 "readPriority":false,"priorityName":PRIORITY_FIELD}),
9031 )
9032 .await?;
9033 let returned = data
9034 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
9035 .ok_or_else(|| SourceError::Malformed {
9036 message: "GitHub field clear returned no project item".into(),
9037 })?;
9038 if required_str(returned, "id")? != item_id {
9039 return Err(SourceError::Malformed {
9040 message: "GitHub field clear returned the wrong project item".into(),
9041 });
9042 }
9043 Ok(())
9044 }
9045 }
9046 }
9047
9048 /// The refusal a priority is answered with by an instance configured with no
9049 /// `priority_mapping`, which holds none.
9050 fn holds_no_priority(&self) -> SourceError {
9051 SourceError::Refused {
9052 message: format!(
9053 "source {} holds no task priority: its configuration sets no priority_mapping; \
9054 next: set priority_mapping on this source, then run `onetaskgraph sources \
9055 fields {} --apply` to set its board up",
9056 self.name, self.name
9057 ),
9058 }
9059 }
9060
9061 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
9062 ///
9063 /// One field write — a select, or a clear for `none` — and no title, body, label, state
9064 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
9065 async fn set_priority(
9066 &self,
9067 id: &NativeId,
9068 priority: Priority,
9069 ) -> Result<Option<Priority>, SourceError> {
9070 if self.priorities.is_none() {
9071 return Err(self.holds_no_priority());
9072 }
9073 let Some(mut item) = self
9074 .bound_item(id)
9075 .await?
9076 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9077 else {
9078 return Ok(None);
9079 };
9080 if priority == Priority::None && !item.holds_priority() {
9081 return Ok(Some(priority));
9082 }
9083 // The item's own read carries the field's definition whenever it holds a value of
9084 // it, which a clear always does; a select onto an item holding none reads the board.
9085 let board = match (item.carried_board(), item.named_board()) {
9086 (Some(board), _) => board,
9087 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
9088 id,
9089 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
9090 },
9091 _ => self.board_fields().await?,
9092 };
9093 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
9094 return Ok(Some(priority));
9095 };
9096 let (document, root, input) = match write {
9097 PriorityWrite::Select { field, option } => (
9098 graphql::UPDATE_FIELD,
9099 "updateProjectV2ItemFieldValue",
9100 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
9101 ),
9102 PriorityWrite::Clear { field } => (
9103 graphql::CLEAR_FIELD,
9104 "clearProjectV2ItemFieldValue",
9105 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
9106 ),
9107 };
9108 let data = self
9109 .graphql(
9110 document,
9111 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
9112 )
9113 .await?;
9114 let returned = data
9115 .get(root)
9116 .and_then(|value| value.get("projectV2Item"))
9117 .ok_or_else(|| SourceError::Malformed {
9118 message: "GitHub priority write returned no project item".into(),
9119 })?;
9120 if required_str(returned, "id")? != item.item_id {
9121 return Err(SourceError::Malformed {
9122 message: "GitHub priority write returned the wrong project item".into(),
9123 });
9124 }
9125 let value = returned
9126 .get("fieldValueByName")
9127 .ok_or_else(|| SourceError::Malformed {
9128 message: "GitHub priority write returned no priority read-back".into(),
9129 })?;
9130 if !value.is_null()
9131 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
9132 {
9133 return Err(SourceError::Malformed {
9134 message: "GitHub priority read-back is not a Priority field value".into(),
9135 });
9136 }
9137 let values = if value.is_null() {
9138 Vec::new()
9139 } else {
9140 vec![value.clone()]
9141 };
9142 item.priority = self.held_priority(&values)?;
9143 let answer = item.task()?.priority;
9144 self.remember_written(item, false)?;
9145 Ok(Some(answer))
9146 }
9147
9148 /// Replace one task's visible body and nothing else; see
9149 /// [`TaskSource::set_task_content`].
9150 ///
9151 /// One update of the body, which differs from the body GitHub holds only outside the
9152 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
9153 /// this source keeps there reads back as it was. A body that would not change is not
9154 /// sent at all.
9155 async fn replace_content(
9156 &self,
9157 id: &NativeId,
9158 content: &str,
9159 ) -> Result<Option<()>, SourceError> {
9160 let Some(mut item) = self
9161 .bound_item(id)
9162 .await?
9163 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9164 else {
9165 return Ok(None);
9166 };
9167 let held = item.raw_body.clone().unwrap_or_default();
9168 // The task's `## Links` section, re-rendered from the links it holds, stays with it.
9169 let content = &onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &item.slot);
9170 let body = with_content(&held, &self.marked(content))?;
9171 // Checked before anything is sent: content ending in what this source reads as its own
9172 // metadata slot would read back as metadata rather than as the content it was.
9173 let (visible, slot) = self.visible(Some(body.clone()))?;
9174 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
9175 return Err(SourceError::Refused {
9176 message: format!(
9177 "this content ends in what source {} reads as its own metadata slot \
9178 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9179 as content; next: remove that trailing block from the content",
9180 self.name
9181 ),
9182 });
9183 }
9184 if body != held {
9185 self.update_content(item.content_kind, &item.id, json!({"body": body}))
9186 .await?;
9187 }
9188 item.body = visible.filter(|value| !value.is_empty());
9189 item.raw_body = Some(body);
9190 item.slot = slot;
9191 self.remember_written(item, false)?;
9192 Ok(Some(()))
9193 }
9194
9195 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
9196 ///
9197 /// One read of the item — which carries the board's field definitions and the issue's
9198 /// `blockedBy`, so neither is read again — and then only what differs from it: the
9199 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
9200 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
9201 /// the title, the body — visible content and metadata slot together — and a state change.
9202 /// So an update naming any of title, body, metadata, status and priority is one read and
9203 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
9204 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
9205 /// as a whole write does; an open one selects its option and then reopens. The origin
9206 /// field is never written: an update is of an item that already exists, whose origin is
9207 /// what it is.
9208 ///
9209 /// The task answered is the item as those writes left it, built from the read and what was
9210 /// sent rather than read again — the same record a later read in this run answers from.
9211 ///
9212 /// Its `version` is the one the task holds after the writes: the change token the last
9213 /// mutation's own response spells — each sent as the twin selecting the whole issue, at
9214 /// the price of the plain document — or, where that mutation shows no whole issue (several
9215 /// board fields written together, or `blockedBy` alone), the token one read right after
9216 /// the writes spells. Never the token the item was read at before them. Against GitHub
9217 /// itself that token has not settled yet (`change_token::SETTLED`), so a read gives none
9218 /// until it has, and a write held to it before then is refused rather than let through;
9219 /// once settled, a read gives exactly this token unless the task changed again.
9220 async fn targeted_update(
9221 &self,
9222 id: &NativeId,
9223 update: &TaskUpdate,
9224 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9225 Box::pin(SPELLING.scope(
9226 std::ptr::from_ref(self) as usize,
9227 self.composed_update(id, update),
9228 ))
9229 .await
9230 }
9231
9232 /// [`Self::targeted_update`], inside the scope that has its mutations answer whole.
9233 async fn composed_update(
9234 &self,
9235 id: &NativeId,
9236 update: &TaskUpdate,
9237 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9238 // Everything this source can refuse without reading the item is refused first, in the
9239 // words a whole write of the same fields is refused with.
9240 update.consistent()?;
9241 if update
9242 .title
9243 .as_deref()
9244 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
9245 {
9246 return Err(SourceError::Refused {
9247 message: format!(
9248 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
9249 spells a document, so it would read back as one rather than as a task; \
9250 retitle it",
9251 self.name
9252 ),
9253 });
9254 }
9255 if let Some(delivers) = &update.delivers {
9256 TaskRef::listed(
9257 TaskRef::DELIVERS_KEY,
9258 id,
9259 Some(&self.name),
9260 delivers.clone(),
9261 )
9262 .map_err(|message| SourceError::Refused { message })?;
9263 }
9264 if self.priorities.is_none()
9265 && update
9266 .priority
9267 .is_some_and(|priority| priority != Priority::None)
9268 {
9269 return Err(self.holds_no_priority());
9270 }
9271 let target = update
9272 .status
9273 .as_ref()
9274 .map(|status| self.resolved_target(ItemKind::Task, status.category))
9275 .transpose()?;
9276 let Some(mut item) = self
9277 .bound_item(id)
9278 .await?
9279 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9280 else {
9281 return Ok(None);
9282 };
9283 let before = item.task()?;
9284
9285 let mut status_move = None;
9286 if let (Some(status), Some(target)) = (&update.status, target) {
9287 let board = self.status_board(&item).await?;
9288 let (field, option, name) = self
9289 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
9290 .ok_or_else(|| SourceError::Malformed {
9291 message: format!(
9292 "status {} of source {} names no board Status option",
9293 category_name(status.category),
9294 self.name
9295 ),
9296 })?;
9297 let terminal = matches!(target, StatusTarget::Terminal(_, _));
9298 if terminal && item.content_kind == ContentKind::DraftIssue {
9299 return Err(self.closes_a_draft(status.category));
9300 }
9301 let landed = match &target {
9302 StatusTarget::Terminal(_, reason) => {
9303 self.statuses
9304 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
9305 }
9306 _ => self
9307 .statuses
9308 .status(ItemKind::Task, Some(&name), false, None),
9309 };
9310 let option_moves = item
9311 .option
9312 .as_deref()
9313 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
9314 let state_moves = item.content_kind == ContentKind::Issue
9315 && (item.closed != terminal || (terminal && item.status != landed));
9316 if let Some(moves) = Moves::of(option_moves, state_moves) {
9317 status_move = Some(StatusMove {
9318 board: board.id,
9319 field,
9320 option,
9321 name,
9322 target,
9323 landed,
9324 moves,
9325 });
9326 }
9327 }
9328
9329 let mut priority_move = None;
9330 if let Some(priority) = update.priority
9331 && self.priorities.is_some()
9332 && item.priority != HeldPriority::Read(priority)
9333 {
9334 let board = match (item.carried_board(), item.named_board()) {
9335 (Some(board), _) => board,
9336 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
9337 id: board,
9338 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
9339 },
9340 _ => self.board_fields().await?,
9341 };
9342 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
9343 priority_move = Some((board.id, write, priority));
9344 }
9345 }
9346
9347 // The metadata keys this update names that a board text field projects, and what
9348 // that does to those fields — refused, as a whole write refuses it, before anything
9349 // is sent.
9350 let touched: Vec<&str> = update
9351 .metadata_set
9352 .keys()
9353 .chain(update.metadata_remove.iter())
9354 .map(MetadataKey::as_str)
9355 .collect();
9356 let mut projection_move = None;
9357 if self
9358 .metadata_fields
9359 .iter()
9360 .any(|projection| touched.contains(&&*projection.key))
9361 {
9362 let mut metadata = item.slot.clone();
9363 for (key, value) in &update.metadata_set {
9364 metadata.insert(key.as_str().to_owned(), value.clone());
9365 }
9366 for key in &update.metadata_remove {
9367 metadata.remove(key.as_str());
9368 }
9369 let board = self.projection_board(&item).await?;
9370 let (writes, projected) = self.projection_writes(
9371 &board.fields,
9372 Some(&item.projected),
9373 &metadata,
9374 Some(&touched),
9375 )?;
9376 projection_move = Some((board.id, writes, projected));
9377 }
9378
9379 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
9380 // recorded in the slot, and the slot travels in the one body update below.
9381 let edges = match &update.depends_on {
9382 Some(edges) => Some(
9383 self.partition_edges(
9384 BoardKind::Work(ItemKind::Task),
9385 item.content_kind,
9386 item.blocked_by.as_deref(),
9387 edges,
9388 )
9389 .await?,
9390 ),
9391 None => None,
9392 };
9393
9394 // A record primed from what a producer wrote holds no body anybody read, so an update
9395 // changing the body reads it first: by the `Status` write's own answer when the update
9396 // moves the option — the mutation answering with the issue whole — and otherwise by
9397 // reading the issue. The body is then composed against what GitHub holds now.
9398 let mut option_selected = false;
9399 // The mark stays until a read lands, so an update that writes no body, or one whose
9400 // read fails, leaves the next update of it to read the body too.
9401 if (update.content.is_some()
9402 || !update.metadata_set.is_empty()
9403 || !update.metadata_remove.is_empty()
9404 || update.delivers.is_some()
9405 || update.repositories.is_some()
9406 || edges.is_some())
9407 && self.is_unread(id)
9408 {
9409 let live = match status_move.as_ref().filter(|moving| moving.moves.option()) {
9410 Some(moving) => {
9411 option_selected = true;
9412 Some(
9413 self.select_reading_body(
9414 moving.board.as_str(),
9415 &item.item_id,
9416 &moving.field,
9417 &moving.option,
9418 )
9419 .await?,
9420 )
9421 }
9422 None => {
9423 self.forget(id)?;
9424 let Some(read) = self.item_by_id(id).await? else {
9425 return Ok(None);
9426 };
9427 read.raw_body
9428 }
9429 };
9430 let (visible, read_slot) = self.visible(live.clone())?;
9431 self.mark_read(id);
9432 item.raw_body = live;
9433 item.body = visible;
9434 item.slot = read_slot;
9435 }
9436
9437 let mut slot = item.slot.clone();
9438 for (key, value) in &update.metadata_set {
9439 slot.insert(key.as_str().to_owned(), value.clone());
9440 }
9441 for key in &update.metadata_remove {
9442 slot.remove(key.as_str());
9443 }
9444 if let Some(delivers) = &update.delivers {
9445 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
9446 }
9447 // The list a whole write records, by the same rule: none when it is exactly the issue's
9448 // own repository, or empty with no issue repository to derive, and otherwise the list.
9449 if let Some(repositories) = &update.repositories {
9450 let derivable = item
9451 .own_repository
9452 .as_ref()
9453 .map_or(repositories.is_empty(), |own| {
9454 repositories.as_slice() == std::slice::from_ref(own)
9455 });
9456 if derivable {
9457 slot.remove(Repository::METADATA_KEY);
9458 } else {
9459 slot.insert(
9460 Repository::METADATA_KEY.to_owned(),
9461 Value::Array(
9462 repositories
9463 .iter()
9464 .map(|repository| Value::String(repository.as_str().to_owned()))
9465 .collect(),
9466 ),
9467 );
9468 }
9469 }
9470 if let Some((_, recorded)) = &edges {
9471 record_edges(&mut slot, recorded);
9472 }
9473 let held = item.raw_body.clone().unwrap_or_default();
9474 // A content write keeps the task's `## Links` section, re-rendered from the links the
9475 // slot holds once written.
9476 let relinked = update
9477 .content
9478 .as_deref()
9479 .map(|content| onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot));
9480 let content = match &relinked {
9481 Some(content) => with_content(&held, &self.marked(content))?,
9482 None => held.clone(),
9483 };
9484 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
9485 // the body's bytes, as a metadata write compares it: a slot a person spelled with
9486 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
9487 let body = if slot == item.slot {
9488 content
9489 } else {
9490 with_slot(&content, &slot)?
9491 };
9492 // Checked before anything is sent, as a content write checks it: content ending in
9493 // what this source reads as its own slot would read back as metadata.
9494 let (visible, read) = self.visible(Some(body.clone()))?;
9495 let wanted = relinked.as_deref().or(item.body.as_deref());
9496 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
9497 return Err(SourceError::Refused {
9498 message: format!(
9499 "this content ends in what source {} reads as its own metadata slot \
9500 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9501 as content; next: remove that trailing block from the content",
9502 self.name
9503 ),
9504 });
9505 }
9506 let recorded_moves =
9507 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
9508
9509 // One `updateIssue` carries all three, because every mutation spends the secondary
9510 // limiter and the title, body and state are one mutation's inputs.
9511 let mut fields = serde_json::Map::new();
9512 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
9513 fields.insert("title".to_owned(), json!(title));
9514 }
9515 if body != held {
9516 fields.insert("body".to_owned(), json!(body));
9517 }
9518 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
9519 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
9520 }
9521 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
9522 // GitHub runs no two requests as one, and runs one document's mutation fields in order
9523 // without undoing an earlier field when a later one fails — so a body written before a
9524 // board field the board then refused would be left changed. Written after every other
9525 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
9526 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
9527 // first, together in one request — a terminal option selected before the issue
9528 // closes, as a whole write does — then the `blockedBy` difference, then the body.
9529 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
9530 let mut clears: Vec<(&BoardId, &str)> = Vec::new();
9531 if let Some(moving) = status_move
9532 .as_ref()
9533 .filter(|moving| moving.moves.option() && !option_selected)
9534 {
9535 board_writes.push((
9536 &moving.board,
9537 (
9538 moving.field.clone(),
9539 json!({"singleSelectOptionId": moving.option}),
9540 ),
9541 ));
9542 }
9543 match &priority_move {
9544 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
9545 board,
9546 (field.clone(), json!({"singleSelectOptionId": option})),
9547 )),
9548 Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
9549 None => {}
9550 }
9551 if let Some((board, writes, _)) = &projection_move {
9552 for write in writes {
9553 let (mut values, mut cleared) = (Vec::new(), Vec::new());
9554 write.join(&mut values, &mut cleared);
9555 board_writes.extend(values.into_iter().map(|value| (board, value)));
9556 clears.extend(cleared.into_iter().map(|field| (board, field)));
9557 }
9558 }
9559 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
9560 boards.extend(clears.iter().map(|(board, _)| *board));
9561 boards.dedup_by(|one, other| one.as_str() == other.as_str());
9562 for board in boards {
9563 let writes = board_writes
9564 .iter()
9565 .filter(|(on, _)| on.as_str() == board.as_str())
9566 .map(|(_, write)| write.clone())
9567 .collect::<Vec<_>>();
9568 let cleared = clears
9569 .iter()
9570 .filter(|(on, _)| on.as_str() == board.as_str())
9571 .map(|(_, field)| *field)
9572 .collect::<Vec<_>>();
9573 self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
9574 .await?;
9575 }
9576 // Whether anything is sent: what the version answered has to come after.
9577 let sends =
9578 option_selected || !board_writes.is_empty() || !clears.is_empty() || !fields.is_empty();
9579 let mut blocked_by_moved = false;
9580 if let Some((native, _)) = &edges
9581 && item.content_kind == ContentKind::Issue
9582 {
9583 blocked_by_moved = self
9584 .reconcile_blocked_by(
9585 &item.id,
9586 native,
9587 Issue::Existing(item.blocked_by.as_deref()),
9588 )
9589 .await?;
9590 }
9591 if !fields.is_empty() {
9592 self.update_content(item.content_kind, &item.id, Value::Object(fields))
9593 .await?;
9594 }
9595
9596 // The `blockedBy` this item was read with is what the write just changed, so it is no
9597 // answer any more: a later write in this command would reconcile against it — re-adding
9598 // what it kept and never removing what it dropped — and a forward-edge read would
9599 // answer with it. Left unknown, each asks GitHub instead.
9600 if blocked_by_moved {
9601 item.blocked_by = None;
9602 }
9603 if let Some(title) = &update.title {
9604 item.title.clone_from(title);
9605 }
9606 item.body = visible.filter(|value| !value.is_empty());
9607 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
9608 item.slot = slot;
9609 if let Some(delivers) = &update.delivers {
9610 item.delivers.clone_from(delivers);
9611 }
9612 if let Some(repositories) = &update.repositories {
9613 item.repositories.clone_from(repositories);
9614 }
9615 if let Some(moving) = status_move {
9616 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
9617 && item.content_kind == ContentKind::Issue;
9618 item.status = moving.landed;
9619 item.option = Some(moving.name);
9620 }
9621 if let Some((_, _, priority)) = priority_move {
9622 item.priority = HeldPriority::Read(priority);
9623 }
9624 if let Some((_, _, projected)) = projection_move {
9625 item.projected = projected;
9626 }
9627 let mut task = item.task()?;
9628 let mut written = update.changed(&before, &task);
9629 if blocked_by_moved || recorded_moves {
9630 written.insert(UpdatedField::DependsOn);
9631 }
9632 if sends || blocked_by_moved {
9633 // The token the item was read at is not the one it holds now. A batch of updates
9634 // holding this one's writes back reads every task it wrote once they are sent.
9635 task.version = if self.holding_writes_of(id) {
9636 None
9637 } else {
9638 self.version_after_write(id).await
9639 };
9640 }
9641 self.remember_written(item, false)?;
9642 Ok(Some(TaskUpdateOutcome {
9643 task,
9644 written,
9645 delivers_before: before.delivers,
9646 }))
9647 }
9648
9649 /// The change token the task `id` holds after the writes a targeted update just sent: the
9650 /// one the last mutation's own response showed, or, where none showed the whole issue, the
9651 /// one a read made now spells. `None` when that read cannot say — never the token the
9652 /// task was read at before the writes.
9653 async fn version_after_write(&self, id: &NativeId) -> Option<String> {
9654 // A response that showed the issue whole answers, a token or none: a read made now
9655 // would read the same parts.
9656 if let Some(answered) = self.written_answer(id) {
9657 return answered;
9658 }
9659 // An item this command created is answered from its own record, not read.
9660 if self.created().ok()?.iter().any(|own| own.id == *id) {
9661 return None;
9662 }
9663 let read = self.item_by_id(id).await.ok()??;
9664 let token = read.written_version()?;
9665 self.remember_written_token(&read, token.clone());
9666 Some(token)
9667 }
9668
9669 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
9670 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
9671 ///
9672 /// One update of the body: the content outside the slot, and inside it that one entry,
9673 /// every other entry kept as it was. This source keeps no template answers — an issue has
9674 /// no room beside itself that is not its body, and answers written there would duplicate
9675 /// what the content already says and count against GitHub's body limit — so `answers`
9676 /// reaches nothing here. A body that would not change is not sent at all.
9677 async fn replace_rendering(
9678 &self,
9679 id: &NativeId,
9680 kind: BoardKind,
9681 content: &str,
9682 provenance: &Value,
9683 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
9684 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9685 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
9686 return Ok(None);
9687 };
9688 let held = item.raw_body.clone().unwrap_or_default();
9689 let mut slot = item.slot.clone();
9690 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
9691 let rewritten;
9692 let content = if let Some(assets) = assets {
9693 let uploads = self
9694 .upload_assets(item.own_repository.as_ref(), assets)
9695 .await?;
9696 rewritten =
9697 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
9698 rewritten.as_str()
9699 } else {
9700 content
9701 };
9702 let relinked = onetaskgraph_plugin_api::relinked(content, item.body.as_deref(), &slot);
9703 let content = relinked.as_str();
9704 let body = with_slot(&with_content(&held, &self.marked(content))?, &slot)?;
9705 // Checked before anything is sent, as a content write checks it.
9706 let (visible, read) = self.visible(Some(body.clone()))?;
9707 if visible.as_deref().unwrap_or_default() != content || read != slot {
9708 return Err(SourceError::Refused {
9709 message: format!(
9710 "this content ends in what source {} reads as its own metadata slot \
9711 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
9712 as content; next: remove that trailing block from the template",
9713 self.name
9714 ),
9715 });
9716 }
9717 if body != held {
9718 self.update_content(item.content_kind, &item.id, json!({"body": body}))
9719 .await?;
9720 }
9721 item.body = visible.filter(|value| !value.is_empty());
9722 item.raw_body = Some(body);
9723 // The content as a read answers it: without the `## Links` section of a linked task.
9724 let answered = onetaskgraph_plugin_api::read_content(Some(content.to_owned()), &read);
9725 item.slot = read;
9726 self.remember_written(item, false)?;
9727 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
9728 id: id.clone(),
9729 content: answered,
9730 }))
9731 }
9732
9733 async fn set_item_field(
9734 &self,
9735 board_id: &str,
9736 item_id: &str,
9737 field_id: &str,
9738 value: Value,
9739 ) -> Result<(), SourceError> {
9740 let data = self
9741 .graphql(
9742 graphql::UPDATE_FIELD,
9743 json!({"input":{
9744 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
9745 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
9746 )
9747 .await?;
9748 field_update_item(&data, item_id).map(|_| ())
9749 }
9750
9751 /// GitHub accepts one value per field mutation; aliases combine those mutations in
9752 /// one request — writes in the order given, then clears. A lone write is
9753 /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
9754 /// request's slots hold go in further requests of the same document, in order. Every
9755 /// returned item id is checked, including optional aliases.
9756 async fn set_item_fields(
9757 &self,
9758 board: &str,
9759 item: &str,
9760 fields: &[(String, Value)],
9761 clears: &[&str],
9762 ) -> Result<(), SourceError> {
9763 if !(fields.is_empty() && clears.is_empty())
9764 && self.hold_fields(board, item, fields, clears)?
9765 {
9766 return Ok(());
9767 }
9768 match (fields, clears) {
9769 ([], []) => return Ok(()),
9770 ([(field, value)], []) => {
9771 return self.set_item_field(board, item, field, value.clone()).await;
9772 }
9773 ([], [field]) => {
9774 return self
9775 .write_priority(
9776 board,
9777 item,
9778 &PriorityWrite::Clear {
9779 field: (*field).to_owned(),
9780 },
9781 )
9782 .await;
9783 }
9784 _ => {}
9785 }
9786 // Any field of the request stands in for an input a slot leaves out, which GitHub
9787 // still requires to be well formed although it never runs it.
9788 let filler = fields
9789 .first()
9790 .map_or_else(|| clears[0], |(field, _)| field.as_str());
9791 let mut writes = fields.iter();
9792 let mut cleared = clears.iter();
9793 loop {
9794 let batch: Vec<&(String, Value)> =
9795 writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
9796 let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
9797 if batch.is_empty() && batch_clears.is_empty() {
9798 return Ok(());
9799 }
9800 let mut variables = serde_json::Map::new();
9801 for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
9802 let input = match batch.get(index) {
9803 Some((field, value)) => {
9804 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
9805 }
9806 None => json!({"projectId":board,"itemId":item,"fieldId":filler,
9807 "value":{"text":""}}),
9808 };
9809 variables.insert(slot.variable.to_owned(), input);
9810 variables.insert(slot.include.to_owned(), json!(index < batch.len()));
9811 }
9812 for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
9813 let field = batch_clears.get(index).map_or(filler, |field| **field);
9814 variables.insert(
9815 slot.variable.to_owned(),
9816 json!({"projectId":board,"itemId":item,"fieldId":field}),
9817 );
9818 variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
9819 }
9820 let data = self
9821 .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
9822 .await?;
9823 let sent = FIELD_WRITE_SLOTS[..batch.len()]
9824 .iter()
9825 .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
9826 for slot in sent {
9827 let alias = slot.alias;
9828 let returned = data
9829 .get(alias)
9830 .and_then(|value| value.get("projectV2Item"))
9831 .ok_or_else(|| SourceError::Malformed {
9832 message: format!("GitHub field update {alias} returned no project item"),
9833 })?;
9834 if required_str(returned, "id")? != item {
9835 return Err(SourceError::Malformed {
9836 message: format!(
9837 "GitHub field update {alias} returned the wrong project item"
9838 ),
9839 });
9840 }
9841 }
9842 }
9843 }
9844
9845 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
9846 let mut after: Option<String> = None;
9847 let mut ids = Vec::new();
9848 loop {
9849 let data = self
9850 .graphql(
9851 graphql::ISSUE_DEPENDENCIES,
9852 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
9853 )
9854 .await?;
9855 let connection =
9856 data.pointer("/node/blockedBy")
9857 .ok_or_else(|| SourceError::Malformed {
9858 message: "GitHub dependency response has no blockedBy connection".into(),
9859 })?;
9860 ids.extend(
9861 connection
9862 .get("nodes")
9863 .and_then(Value::as_array)
9864 .ok_or_else(|| SourceError::Malformed {
9865 message: "GitHub dependency response nodes is not an array".into(),
9866 })?
9867 .iter()
9868 .map(|value| required_str(value, "id").map(str::to_owned))
9869 .collect::<Result<Vec<_>, _>>()?,
9870 );
9871 let next = next_cursor(connection)?;
9872 if let Some(next) = &next {
9873 validate_cursor_progress(after.as_deref(), &next.0)?;
9874 }
9875 after = next.map(|cursor| cursor.0);
9876 if after.is_none() {
9877 return Ok(ids);
9878 }
9879 }
9880 }
9881
9882 async fn dependencies(
9883 &self,
9884 id: &NativeId,
9885 near_kind: ItemKind,
9886 direction: Direction,
9887 page: &PageRequest,
9888 ) -> Result<Page<DependencyEdge>, SourceError> {
9889 validate_page(page)?;
9890 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
9891 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
9892 let recorded = recorded_offset(cursor, direction)?;
9893 // What this issue is blocked by, when a read of it by its own id in this command
9894 // already carried the whole connection — a copy reads the item it writes before it
9895 // reads its edges — and the page asked for is the whole of it, or the recorded tail
9896 // after it. Answered from that read, in the shape the dependency read answers in;
9897 // anything else is asked of GitHub.
9898 let carried = match direction {
9899 Direction::DependsOn => self
9900 .resolved_cache()?
9901 .get(id)
9902 .filter(|item| item.content_kind == ContentKind::Issue)
9903 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
9904 Direction::DependedOnBy => None,
9905 }
9906 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
9907 // Asked for even in the recorded phase, whose page reads nothing from the
9908 // connection: `__typename` is what says whether this item has a native
9909 // relationship at all, and that is what decides which far ends the reserved key is
9910 // allowed to hold.
9911 let data = match carried {
9912 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
9913 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
9914 None => {
9915 let after = if recorded.is_some() { None } else { cursor };
9916 let first = page.limit.min(MAX_PAGE_SIZE);
9917 match direction {
9918 Direction::DependsOn => {
9919 self.graphql(
9920 graphql::ISSUE_DEPENDS_ON,
9921 json!({"id":id.0,"first":first,"after":after,"owner":self.owner,
9922 "number":self.project_number,
9923 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
9924 )
9925 .await?
9926 }
9927 Direction::DependedOnBy => {
9928 self.graphql(
9929 graphql::ISSUE_DEPENDED_ON_BY,
9930 json!({"id":id.0,"first":first,"after":after}),
9931 )
9932 .await?
9933 }
9934 }
9935 }
9936 };
9937 let draft_slot = draft_slot(&data, id, self.project_number)?;
9938 let node =
9939 data.get("node")
9940 .filter(|v| !v.is_null())
9941 .ok_or_else(|| SourceError::Refused {
9942 message: format!(
9943 "GitHub item {} was not found or does not support dependencies",
9944 id.0
9945 ),
9946 })?;
9947 let connection_name = match direction {
9948 Direction::DependsOn => "blockedBy",
9949 Direction::DependedOnBy => "blocking",
9950 };
9951 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
9952 // named natively and the reserved key may hold any far end. An issue's connections
9953 // hold issues, and this source reads them at the near item's own level.
9954 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
9955 if let Some(offset) = recorded {
9956 return Ok(recorded_page(
9957 self.recorded_edges(
9958 id,
9959 near_kind,
9960 direction,
9961 natively_names,
9962 node,
9963 draft_slot.as_ref(),
9964 )
9965 .await?,
9966 offset,
9967 limit,
9968 ));
9969 }
9970 if natively_names.is_none() {
9971 return Ok(recorded_page(
9972 self.recorded_edges(
9973 id,
9974 near_kind,
9975 direction,
9976 natively_names,
9977 node,
9978 draft_slot.as_ref(),
9979 )
9980 .await?,
9981 0,
9982 limit,
9983 ));
9984 }
9985 let connection = node
9986 .get(connection_name)
9987 .ok_or_else(|| SourceError::Malformed {
9988 message: "GitHub dependency response is missing its connection".into(),
9989 })?;
9990 let nodes = connection
9991 .get("nodes")
9992 .and_then(Value::as_array)
9993 .ok_or_else(|| SourceError::Malformed {
9994 message: "GitHub dependency response nodes is not an array".into(),
9995 })?;
9996 // `from` depends on `to`, always. GitHub spells the same relationship from either
9997 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
9998 // it — so the near item is `from` in one direction and `to` in the other.
9999 let items = nodes
10000 .iter()
10001 .map(|value| {
10002 let related = NativeId(required_str(value, "id")?.into());
10003 let related_kind = related_kind(value, self.sub_issues)?;
10004 let (from, to) = match direction {
10005 Direction::DependsOn => (
10006 DependencyEndpoint::from_native(id.clone(), near_kind),
10007 DependencyEndpoint::from_native(related, related_kind),
10008 ),
10009 Direction::DependedOnBy => (
10010 DependencyEndpoint::from_native(related, related_kind),
10011 DependencyEndpoint::from_native(id.clone(), near_kind),
10012 ),
10013 };
10014 Ok(DependencyEdge {
10015 from,
10016 to,
10017 kind: DependencyKind::Blocks,
10018 })
10019 })
10020 .collect::<Result<Vec<_>, SourceError>>()?;
10021 let mut next = next_cursor(connection)?;
10022 if let Some(next) = &next {
10023 validate_cursor_progress(cursor, &next.0)?;
10024 }
10025 // The recorded far ends ride on the last page of `blockedBy`, whose answer carried the
10026 // body they are recorded in, as far as the page has room — so they cost no request of
10027 // their own — and only what does not fit is resumed by a cursor.
10028 let mut items = items;
10029 if next.is_none() {
10030 let recorded = self
10031 .recorded_edges(
10032 id,
10033 near_kind,
10034 direction,
10035 natively_names,
10036 node,
10037 draft_slot.as_ref(),
10038 )
10039 .await?;
10040 let room = limit.saturating_sub(items.len());
10041 let total = recorded.len();
10042 items.extend(recorded.into_iter().take(room));
10043 if total > room {
10044 next = Some(Cursor(format!("{RECORDED_CURSOR}{room}")));
10045 }
10046 }
10047 Ok(Page { items, next })
10048 }
10049
10050 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
10051 /// a far end in another source has to live: no GitHub issue relationship can name one.
10052 ///
10053 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
10054 /// source never writes one down.
10055 ///
10056 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
10057 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
10058 /// request beyond the read already made, and reading the board for them would be a
10059 /// walk of every item for one field of one. A draft has no body in that answer, because
10060 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
10061 /// listing of the board, which can be behind on the very item asked about.
10062 async fn recorded_edges(
10063 &self,
10064 id: &NativeId,
10065 near_kind: ItemKind,
10066 direction: Direction,
10067 natively_names: Option<ItemKind>,
10068 node: &Value,
10069 draft_slot: Option<&Option<BTreeMap<String, Value>>>,
10070 ) -> Result<Vec<DependencyEdge>, SourceError> {
10071 if direction != Direction::DependsOn {
10072 return Ok(Vec::new());
10073 }
10074 let slot = match (node.get("body"), draft_slot) {
10075 (Some(body), _) if natively_names.is_some() => {
10076 metadata_body(body.as_str().map(str::to_owned))?.1
10077 }
10078 // A draft the dependency read itself answered: this board's, or none of its.
10079 (_, Some(Some(slot))) => slot.clone(),
10080 (_, Some(None)) => return Ok(Vec::new()),
10081 _ => {
10082 let Some(item) = self.bound_item(id).await? else {
10083 return Ok(Vec::new());
10084 };
10085 item.slot
10086 }
10087 };
10088 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
10089 .map_err(|message| SourceError::Malformed { message })
10090 }
10091
10092 /// Why `creation_owners` refuses a create filed in `target`, or `None` when it allows it —
10093 /// every create when the list is empty.
10094 fn owner_refusal(&self, target: &RepositoryTarget) -> Option<String> {
10095 if self.creation_owners.is_empty()
10096 || self
10097 .creation_owners
10098 .iter()
10099 .any(|owner| owner.eq_ignore_ascii_case(&target.owner))
10100 {
10101 return None;
10102 }
10103 Some(format!(
10104 "source {} would create the issue in repository {}, owned by {}, and its \
10105 creation_owners allows only repositories of {}",
10106 self.name,
10107 target.slug(),
10108 target.owner,
10109 self.creation_owners.join(", ")
10110 ))
10111 }
10112
10113 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
10114 self.repository
10115 .as_ref()
10116 .ok_or_else(|| SourceError::Refused {
10117 message: format!(
10118 "source {} has no repository configured, and a GitHub Projects board has no \
10119 repository of its own to create an issue in; set repository: owner/name on \
10120 this source",
10121 self.name
10122 ),
10123 })
10124 }
10125
10126 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
10127 /// states.
10128 ///
10129 /// The fallback is demanded first, whichever arm answers: a write without a configured
10130 /// repository is refused naming the field exactly as it was before the rule existed,
10131 /// so a source that could not write before cannot write now, rather than writing for
10132 /// the one item whose own field happens to decide it.
10133 ///
10134 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
10135 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
10136 /// entry owned by someone other than the owner of the parent issue's repository —
10137 /// GitHub accepts a sub-issue from another repository of the same owner and from no
10138 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
10139 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
10140 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
10141 /// and is visible to the token is checked where its node id is resolved, still before
10142 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
10143 /// looked up in a listing of the board, which can be minutes behind an issue its own
10144 /// `projectItems` already places on it — and that read answers first from this process's
10145 /// own record, so a project created moments ago in this command answers though GitHub
10146 /// has not caught up.
10147 async fn creation_target(
10148 &self,
10149 incoming: &Incoming<'_>,
10150 ) -> Result<RepositoryTarget, SourceError> {
10151 let what = format!(
10152 "{} {:?}",
10153 incoming.written.kind().describes(),
10154 incoming.title
10155 );
10156 let target = self
10157 .creation_rule(incoming.repositories, incoming.parent, &what)
10158 .await?;
10159 // The engine asks `creation_refusal` before anything of a copy is written; this holds
10160 // every other create to the same restriction, before `createIssue`.
10161 match self.owner_refusal(&target) {
10162 Some(message) => Err(SourceError::Refused { message }),
10163 None => Ok(target),
10164 }
10165 }
10166
10167 /// [`Self::creation_target`]'s rule over what it reads of the item — its `repositories`
10168 /// and the project issue it is filed under — with `what` naming the item in a refusal: the
10169 /// one place the rule is, so the create, [`TaskSource::creation_repository`] and the
10170 /// write-time read of who can read the issue ([`TaskSource::visibility`]) cannot answer
10171 /// differently.
10172 ///
10173 /// On a source declared private, an item's one named repository is read for its
10174 /// visibility first — once per engine hold, see the `visibility` module — and one that
10175 /// does not read private decides nothing: the issue is created in the parent's
10176 /// repository, else the configured one. A read that cannot be answered refuses.
10177 async fn creation_rule(
10178 &self,
10179 repositories: &[Repository],
10180 parent: Option<&NativeId>,
10181 what: &str,
10182 ) -> Result<RepositoryTarget, SourceError> {
10183 let parent = match parent {
10184 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
10185 SourceError::Refused {
10186 message: format!(
10187 "GitHub project issue {} was not found on the board of source {}, so {} \
10188 cannot be filed under it",
10189 parent.0, self.name, what
10190 ),
10191 }
10192 })?),
10193 None => None,
10194 };
10195 let parents_repository = parent
10196 .as_ref()
10197 .map(|parent| {
10198 // A draft is on the board and so is found, but it has no repository to
10199 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
10200 // would refuse the task only once `createIssue` had made it.
10201 if parent.content_kind == ContentKind::DraftIssue {
10202 return Err(SourceError::Refused {
10203 message: format!(
10204 "GitHub project item {} on the board of source {} is a draft, \
10205 which cannot have sub-issues, so {} cannot be filed under it",
10206 parent.id.0, self.name, what
10207 ),
10208 });
10209 }
10210 // An issue's repository is where a sub-issue is placed and whose owner it
10211 // is compared against, so a parent whose repository this source cannot
10212 // spell as `owner/name` — GitHub's login grammar is wider than this
10213 // source's floor — is one nothing can be filed under.
10214 parent
10215 .own_repository
10216 .as_ref()
10217 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
10218 .ok_or_else(|| SourceError::Malformed {
10219 message: format!(
10220 "GitHub project issue {} on the board of source {} is in {}, which \
10221 is not a {}/owner/name repository this source can place {} in",
10222 parent.id.0,
10223 self.name,
10224 parent
10225 .own_repository
10226 .as_ref()
10227 .map_or("no repository", Repository::as_str),
10228 RepositoryTarget::HOST,
10229 what
10230 ),
10231 })
10232 })
10233 .transpose()?;
10234 self.creation_rule_in_repository(repositories, parents_repository, what)
10235 .await
10236 }
10237
10238 async fn creation_rule_in_repository(
10239 &self,
10240 repositories: &[Repository],
10241 parents_repository: Option<RepositoryTarget>,
10242 what: &str,
10243 ) -> Result<RepositoryTarget, SourceError> {
10244 let fallback = self.configured_repository()?;
10245 match repositories {
10246 [named] => {
10247 let target =
10248 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
10249 message: format!(
10250 "{} names repository {}, which is not a {}/owner/name repository \
10251 source {} can create an issue in; name one that is, or name none",
10252 what,
10253 named.as_str(),
10254 RepositoryTarget::HOST,
10255 self.name
10256 ),
10257 })?;
10258 // On a source declared private, an issue whose one named repository does not
10259 // read private is created where an item naming none is: the item still names
10260 // that repository in its metadata slot, and its issue stays private.
10261 if self.is_declared_private()
10262 && self.repository_visibility(&target).await?
10263 != onetaskgraph_plugin_api::Visibility::Private
10264 {
10265 return Ok(parents_repository.unwrap_or_else(|| fallback.clone()));
10266 }
10267 if let Some(parents) = &parents_repository
10268 && parents.owner != target.owner
10269 {
10270 return Err(SourceError::Refused {
10271 message: format!(
10272 "{} names repository {}, owned by {}, but its project's issue is in \
10273 {}, owned by {}, and GitHub files a sub-issue only in a repository \
10274 of the same owner as its parent issue; name a repository of {}, or \
10275 name none",
10276 what,
10277 target.slug(),
10278 target.owner,
10279 parents.slug(),
10280 parents.owner,
10281 parents.owner
10282 ),
10283 });
10284 }
10285 Ok(target)
10286 }
10287 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
10288 }
10289 }
10290
10291 /// The node id of the repository `incoming` is being created in, or the refusal naming
10292 /// the item and the repository the token cannot see.
10293 ///
10294 /// Resolved once per command per repository; see [`Self::repository_cache`].
10295 async fn repository_id(
10296 &self,
10297 repository: &RepositoryTarget,
10298 incoming: &Incoming<'_>,
10299 ) -> Result<String, SourceError> {
10300 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
10301 return Ok(id);
10302 }
10303 let data = self
10304 .graphql(
10305 graphql::REPOSITORY,
10306 json!({"owner":repository.owner,"name":repository.name}),
10307 )
10308 .await?;
10309 self.repository_read(&data, repository, incoming)
10310 }
10311
10312 /// The repository's node id out of an answer carrying the `repository` root, held for
10313 /// the rest of this command, or the refusal naming the item that cannot be created in it.
10314 fn repository_read(
10315 &self,
10316 data: &Value,
10317 repository: &RepositoryTarget,
10318 incoming: &Incoming<'_>,
10319 ) -> Result<String, SourceError> {
10320 let node = data
10321 .get("repository")
10322 .filter(|value| !value.is_null())
10323 .ok_or_else(|| SourceError::Refused {
10324 message: format!(
10325 "GitHub repository {} was not found or is not visible to the token, so {} \
10326 {:?} cannot be created in it",
10327 repository.slug(),
10328 incoming.written.kind().describes(),
10329 incoming.title
10330 ),
10331 })?;
10332 let id = required_str(node, "id")?.to_owned();
10333 self.repository_cache()?
10334 .insert(repository.clone(), id.clone());
10335 Ok(id)
10336 }
10337
10338 fn repository_cache(
10339 &self,
10340 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
10341 self.repository_cache
10342 .lock()
10343 .map_err(|_| SourceError::Unavailable {
10344 message: "this source's record of the destination repository was left \
10345 inconsistent by an earlier failure; next: run the command again"
10346 .into(),
10347 })
10348 }
10349
10350 /// Create or update one board item, whichever kind it is.
10351 async fn write_item(
10352 &self,
10353 incoming: &Incoming<'_>,
10354 target: Option<&NativeId>,
10355 depends_on: &[DependencyEdge],
10356 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10357 let prepared = self.prepare_write(incoming, target, depends_on).await?;
10358 let landed = match &prepared.destination {
10359 // Its content is written last, by `complete_write`, once everything else has landed.
10360 WriteDestination::Existing(item) => Landed {
10361 content_id: item.id.clone(),
10362 item_id: Some(item.item_id.clone()),
10363 url: item.url.clone(),
10364 number: item.number,
10365 },
10366 WriteDestination::New(target) => {
10367 if self.deferring()? {
10368 self.create_filed_issue(
10369 prepared.board.id.as_str(),
10370 target,
10371 incoming,
10372 &prepared.body,
10373 )
10374 .await?
10375 } else {
10376 self.create_and_file_issue(
10377 prepared.board.id.as_str(),
10378 target,
10379 incoming,
10380 &prepared.body,
10381 )
10382 .await?
10383 }
10384 }
10385 };
10386 self.complete_write(incoming, prepared, landed).await
10387 }
10388
10389 /// Everything one write of `incoming` reads and decides before it sends anything: what is
10390 /// there now, where a new item is created, the board's fields, and every value refused while
10391 /// nothing has been written — the item's body included.
10392 async fn prepare_write(
10393 &self,
10394 incoming: &Incoming<'_>,
10395 target: Option<&NativeId>,
10396 depends_on: &[DependencyEdge],
10397 ) -> Result<Prepared, SourceError> {
10398 // Refused before anything is read or written: a task or a project titled the way
10399 // this board spells a document would land as an issue this same source reads back
10400 // as a document, so the field this destination cannot carry is named rather than
10401 // written and silently reclassified.
10402 if let Written::Work(kind, _) = incoming.written
10403 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
10404 {
10405 return Err(SourceError::Refused {
10406 message: format!(
10407 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
10408 spells a document, so it would read back as one rather than as a {}; \
10409 retitle it, or copy it as a document",
10410 kind.marker(),
10411 self.name,
10412 kind.marker()
10413 ),
10414 });
10415 }
10416 // The destination is read by its own id, and whether this board holds it is decided
10417 // by that read — its own `projectItems` — rather than by whether a listing of the
10418 // board happens to include it yet. See the module documentation.
10419 // An existing issue is never moved; a new one is created where the rule says — and
10420 // knowing where is what lets the board's fields and that repository's id be read
10421 // together, before anything below needs either.
10422 let destination = match target {
10423 Some(target) => {
10424 WriteDestination::Existing(Box::new(self.bound_item(target).await?.ok_or_else(
10425 || SourceError::Refused {
10426 message: format!("GitHub destination item {} was not found", target.0),
10427 },
10428 )?))
10429 }
10430 None => {
10431 let target = self.creation_target(incoming).await?;
10432 self.creation_context(&target, incoming).await?;
10433 WriteDestination::New(target)
10434 }
10435 };
10436 let existing = destination.existing();
10437 let board = self
10438 .fields_for(
10439 existing,
10440 incoming.metadata.contains_key(ORIGIN_KEY),
10441 incoming.written.status().is_some(),
10442 incoming
10443 .priority
10444 .is_some_and(|priority| priority != Priority::None),
10445 )
10446 .await?;
10447 let status_target = incoming
10448 .written
10449 .work_status()
10450 .map(|(kind, status)| self.resolved_target(kind, status.category))
10451 .transpose()?;
10452 let column = match (incoming.written.work_status(), status_target.as_ref()) {
10453 (Some((kind, status)), Some(target)) => {
10454 self.column_for(&board.fields, kind, status.category, target)?
10455 }
10456 _ => None,
10457 };
10458 // Resolved before anything is created, for the reason the column above is: a
10459 // priority this board has no option for is refused while nothing has been written.
10460 let priority_write = match incoming.priority {
10461 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
10462 None => None,
10463 };
10464 // And for the reason the priority is: a value no projected field can hold, or a board
10465 // that cannot hold the field, is refused while nothing has been written.
10466 let (projections, projected) = self.projection_writes(
10467 &board.fields,
10468 existing.map(|item| &item.projected),
10469 incoming.metadata,
10470 None,
10471 )?;
10472 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
10473 if content_kind == ContentKind::DraftIssue {
10474 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
10475 (status_target.as_ref(), incoming.written.status())
10476 {
10477 return Err(self.closes_a_draft(status.category));
10478 }
10479 if incoming.parent.is_some() {
10480 return Err(SourceError::Refused {
10481 message: "GitHub draft items cannot be a project's sub-issue".into(),
10482 });
10483 }
10484 }
10485 match existing {
10486 Some(item) if content_kind == ContentKind::Issue => {
10487 if item.labels != incoming.labels {
10488 return Err(SourceError::Refused {
10489 message: "GitHub issue labels differ from the labels being written".into(),
10490 });
10491 }
10492 }
10493 _ => {
10494 if !incoming.labels.is_empty() {
10495 return Err(SourceError::Refused {
10496 message: "GitHub items created by this destination carry no labels".into(),
10497 });
10498 }
10499 }
10500 }
10501
10502 // The repository the issue really lives in is what the slot below is written against,
10503 // so a single entry that is where the issue is created travels as no key at all, and
10504 // the read side derives it back from the issue.
10505 let own_repository = match &destination {
10506 WriteDestination::Existing(item) => item.own_repository.clone(),
10507 WriteDestination::New(target) => Some(
10508 Repository::try_from(target.origin())
10509 .map_err(|message| SourceError::Config { message })?,
10510 ),
10511 };
10512 let (native, fallback) = self
10513 .partition_edges(
10514 incoming.written.kind(),
10515 content_kind,
10516 existing.and_then(|item| item.blocked_by.as_deref()),
10517 depends_on,
10518 )
10519 .await?;
10520 let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
10521 let content = match incoming.assets {
10522 Some(assets) => {
10523 let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
10524 let rewritten = onetaskgraph_plugin_api::serve_asset_references(
10525 incoming.content.unwrap_or_default(),
10526 &mut slot,
10527 &uploads,
10528 );
10529 incoming.content.map(|_| rewritten)
10530 }
10531 None => incoming.content.map(str::to_owned),
10532 };
10533 // A write over an existing item keeps its `## Links` section, re-rendered from the
10534 // links it holds.
10535 let content = content.map(|content| {
10536 onetaskgraph_plugin_api::relinked(
10537 &content,
10538 existing.and_then(|item| item.body.as_deref()),
10539 incoming.metadata,
10540 )
10541 });
10542 let marked = content.as_deref().map(|content| self.marked(content));
10543 let body = compose_body(marked.as_deref(), &slot)?;
10544 // Read before anything is created, for the reason the field below is: a value
10545 // this destination cannot store has to refuse, and refusing after `createIssue`
10546 // would leave an issue behind that nothing asked for. The engine writes a
10547 // qualified id here; a caller handing this key anything else is told so rather
10548 // than having it silently stored as no origin at all.
10549 // 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.
10550 let origin = match incoming.metadata.get(ORIGIN_KEY) {
10551 None => "",
10552 Some(Value::String(origin)) => origin.as_str(),
10553 Some(other) => {
10554 return Err(SourceError::Refused {
10555 message: format!(
10556 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
10557 is {other}"
10558 ),
10559 });
10560 }
10561 };
10562 // Resolved before anything is created: a board that cannot carry the copy origin
10563 // has to refuse the write, and refusing it after `createIssue` would leave an
10564 // issue behind that nothing asked for.
10565 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
10566 Some(field) => {
10567 if required_str(field, "__typename")? != "ProjectV2Field" {
10568 return Err(SourceError::Refused {
10569 message: format!(
10570 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
10571 ),
10572 });
10573 }
10574 Some(required_str(field, "id")?.to_owned())
10575 }
10576 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
10577 return Err(SourceError::Refused {
10578 message: format!(
10579 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
10580 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
10581 the board"
10582 ),
10583 });
10584 }
10585 None => None,
10586 };
10587
10588 Ok(Prepared {
10589 origin: origin.to_owned(),
10590 destination,
10591 board,
10592 status_target,
10593 column,
10594 priority_write,
10595 projections,
10596 projected,
10597 content_kind,
10598 own_repository,
10599 native,
10600 slot,
10601 content,
10602 body,
10603 origin_field,
10604 })
10605 }
10606
10607 /// Everything one write does once the item it writes exists: its board fields, its
10608 /// relationships and — for an item that was already there — its content last, then what this
10609 /// process remembers of it.
10610 ///
10611 /// While this source is holding writes back — see [`TaskSource::begin_deferred_writes`] — an
10612 /// item this command created has its fields, its closing and its edges held for
10613 /// [`TaskSource::finish_writes`] rather than sent here.
10614 async fn complete_write(
10615 &self,
10616 incoming: &Incoming<'_>,
10617 prepared: Prepared,
10618 landed: Landed,
10619 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10620 let Prepared {
10621 destination,
10622 board,
10623 status_target,
10624 column,
10625 priority_write,
10626 projections,
10627 projected,
10628 content_kind,
10629 own_repository,
10630 native,
10631 slot,
10632 content,
10633 body,
10634 origin,
10635 origin_field,
10636 } = prepared;
10637 let existing = destination.existing();
10638 let origin = origin.as_str();
10639 let Landed {
10640 content_id,
10641 item_id,
10642 url,
10643 number,
10644 } = landed;
10645
10646 // Only what differs from the item as this write read it is sent: a priority the item
10647 // already holds is not, so an unchanged re-copy writes no field and a person's edit of
10648 // it since that read is left alone.
10649 let priority_write = priority_write.filter(|_| {
10650 !existing.is_some_and(|item| {
10651 incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
10652 })
10653 });
10654 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
10655 let column = column
10656 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
10657 .map(|(field, option, _)| (field, option));
10658 // Creating an item here is several calls — `createIssue`, which files it on the
10659 // board, then its board fields, the parent and the dependencies — and GitHub can fail
10660 // at any of them. Everything this source can refuse *before* the first of those is
10661 // already checked above, so what is left is GitHub itself failing part way. When it
10662 // does over an item this call created, the issue is taken back: a write that
10663 // refused must not leave an item behind that nobody asked for, and one that does
10664 // makes the retry create a second.
10665 // Whether the board-field write carrying a moved origin was answered as landing whole.
10666 // When it was refused, GitHub does not say which of its fields ran before the one that
10667 // failed, so the origin may or may not have moved.
10668 let mut origin_landed = false;
10669 // An item this command created while writes are held back has every part that can land
10670 // later held for `finish_writes` — its own create filed it on the board and under its
10671 // project already — and so does a later write of it in the same command.
10672 let holding = (existing.is_none() && self.deferring()?) || self.holds_back(&content_id)?;
10673 let landed = if holding {
10674 self.hold_back(
10675 &board.id,
10676 &content_id,
10677 existing,
10678 origin_field.as_deref(),
10679 origin,
10680 column,
10681 status_target.as_ref(),
10682 priority_write.as_ref(),
10683 &projections,
10684 &native,
10685 )
10686 } else if let Some(item_id) = item_id.as_deref() {
10687 self.finish_write(
10688 board.id.as_str(),
10689 incoming,
10690 &content_id,
10691 item_id,
10692 content_kind,
10693 existing,
10694 origin_field.as_deref(),
10695 origin,
10696 column,
10697 status_target.as_ref(),
10698 priority_write.as_ref(),
10699 &projections,
10700 &native,
10701 &mut origin_landed,
10702 )
10703 .await
10704 } else {
10705 // An issue a create filed on the board itself has no board item this process
10706 // knows until `finish_writes` reads it, so nothing but a held-back write may land
10707 // on it before then.
10708 Err(SourceError::Malformed {
10709 message: format!(
10710 "issue {} was written before the board item GitHub filed it as was read",
10711 content_id.0
10712 ),
10713 })
10714 };
10715 // An existing item's title, body and state go last, in one `updateIssue`, once its board
10716 // fields and its relationships have landed: a refusal of any of those then leaves its
10717 // body — and the metadata slot inside it — exactly as it stood.
10718 let landed = match (landed, existing) {
10719 (Ok(()), Some(item)) => {
10720 self.update_existing(item, incoming, &body, status_target.as_ref())
10721 .await
10722 }
10723 (landed, _) => landed,
10724 };
10725 if let Err(error) = landed {
10726 match existing {
10727 // Best effort, and the write's own failure is what the caller is told: a
10728 // refusal naming the tidy-up would hide why the write failed at all.
10729 None => {
10730 let _ = self.delete_issue(&content_id).await;
10731 }
10732 // The origin field is the one piece of an existing item's metadata written
10733 // before its body, so a write refused after it puts it back as it was. When
10734 // that is refused too, the write's own failure is still what the caller is
10735 // told — with what it left behind added, because the item's metadata is then
10736 // not as it stood and a caller retrying has to know which key moved.
10737 Some(item) => {
10738 let before = item.origin.as_deref().unwrap_or("");
10739 if let Some(field) = origin_field.as_deref()
10740 && before != origin
10741 && let Err(restore) = self
10742 .set_item_field(
10743 board.id.as_str(),
10744 &item.item_id,
10745 field,
10746 json!({"text": before}),
10747 )
10748 .await
10749 {
10750 let left = if origin_landed {
10751 format!(
10752 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
10753 not be put back to {before:?} ({restore}), so item {} still \
10754 holds {origin:?} there",
10755 item.id.0
10756 )
10757 } else {
10758 format!(
10759 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
10760 {origin:?}, GitHub does not say whether that part of it ran, \
10761 and putting it back to {before:?} was refused ({restore}), so \
10762 item {} holds {origin:?} or {before:?} there",
10763 item.id.0
10764 )
10765 };
10766 return Err(noting(
10767 error,
10768 &format!(
10769 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
10770 run the write again"
10771 ),
10772 ));
10773 }
10774 }
10775 }
10776 return Err(error);
10777 }
10778
10779 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
10780 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
10781 self.statuses
10782 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
10783 }
10784 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
10785 self.statuses
10786 .status(kind, written_option.as_deref(), false, None)
10787 }
10788 (Some((_, status)), _) => status.clone(),
10789 (None, _) => Status {
10790 category: StatusCategory::Unknown,
10791 name: "Open".to_owned(),
10792 },
10793 };
10794
10795 // So the rest of this command reads what it just did rather than what the board
10796 // said before it. See `remember_written` for which half takes it.
10797 let remembered = Resolved {
10798 // Empty for an issue whose board item is not read yet: `finish_writes` reads it
10799 // and `learn_item` puts it here.
10800 // llmlint: ignore[invalid_states_unrepresentable] `Resolved` is serialised into the cache's stored write context, so its field type is a stored shape this change may not move; the empty id lives only until `finish_writes` reads the board item in this same command, and nothing addresses it before then — `complete_write` refuses any write naming no board item.
10801 item_id: item_id.unwrap_or_default(),
10802 id: content_id.clone(),
10803 content_kind,
10804 kind: incoming.written.kind(),
10805 title: incoming.title.to_owned(),
10806 // The visible half of the body this write composed, split back off it the
10807 // way a read splits it — so what this record reports is what a read of the
10808 // same issue reports, rather than the person's text with the metadata slot
10809 // still on the end of it.
10810 body: self.visible(body.clone())?.0,
10811 raw_body: body.clone(),
10812 // A document has no status of its own; what it reads back as is whatever
10813 // the issue's own state says, which is what a re-read reports.
10814 status: written_status,
10815 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
10816 priority: match incoming.priority {
10817 Some(priority) => HeldPriority::Read(priority),
10818 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
10819 item.priority.clone()
10820 }),
10821 },
10822 // What `state_input` asked for: closed for a terminal target, open for any other
10823 // status, and the issue's own state left as it was by a document write.
10824 closed: content_kind == ContentKind::Issue
10825 && match status_target.as_ref() {
10826 Some(StatusTarget::Terminal(_, _)) => true,
10827 Some(_) => false,
10828 None => existing.is_some_and(|item| item.closed),
10829 },
10830 reason: match status_target.as_ref() {
10831 Some(StatusTarget::Terminal(_, reason)) if content_kind == ContentKind::Issue => {
10832 Some(reason.reason().to_owned())
10833 }
10834 Some(_) => None,
10835 None => existing.and_then(|item| item.reason.clone()),
10836 },
10837 delivers: incoming.delivers.to_vec(),
10838 delivered_by: incoming.delivered_by.to_vec(),
10839 labels: incoming.labels.to_vec(),
10840 parent: incoming.parent.cloned(),
10841 issue_parent: None,
10842 origin: (!origin.is_empty()).then(|| origin.to_owned()),
10843 projected,
10844 number,
10845 // In the update path this is the item's own url, read off `existing` where the
10846 // record above was bound, so one expression serves both halves.
10847 url,
10848 created_at: existing.and_then(|item| item.created_at),
10849 updated_at: existing.and_then(|item| item.updated_at),
10850 // What this write moved is GitHub's to say, so a record of it gives no token until
10851 // it is read again.
10852 item_updated_at: None,
10853 comment_count: None,
10854 relationship_events: None,
10855 served_at: None,
10856 facets: None,
10857 own_repository,
10858 repositories: incoming.repositories.to_vec(),
10859 classification: incoming.classification,
10860 slot,
10861 board_id: Some(board.id.as_str().to_owned()),
10862 fields: board
10863 .fields
10864 .get("nodes")
10865 .and_then(Value::as_array)
10866 .cloned()
10867 .unwrap_or_default(),
10868 board_fields: Some(board.fields.clone()),
10869 // What this write left the relationship holding is known by id alone, and a
10870 // later read of its edges needs each far end's kind, so it reads them again.
10871 blocked_by: None,
10872 };
10873 self.remember_written(remembered, existing.is_none())?;
10874 Ok(onetaskgraph_plugin_api::AssetsWritten {
10875 id: content_id,
10876 content,
10877 })
10878 }
10879
10880 /// Everything a write does after the item exists: its board fields, its parent, and
10881 /// its dependencies.
10882 ///
10883 /// Split out of `write_item` so there is one place a failure past the point of no
10884 /// return is caught, rather than a tidy-up repeated at each `?` above.
10885 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
10886 // so there is one place a failure past the point of no return is caught, and its
10887 // arguments are exactly the values that tail already had in scope. Bundling them into a
10888 // struct would describe no concept — it would be "the arguments of this function" — and
10889 // would put the whole of `write_item`'s locals behind one more indirection.
10890 #[allow(clippy::too_many_arguments)]
10891 async fn finish_write(
10892 &self,
10893 board_id: &str,
10894 incoming: &Incoming<'_>,
10895 content_id: &NativeId,
10896 item_id: &str,
10897 content_kind: ContentKind,
10898 existing: Option<&Resolved>,
10899 origin_field: Option<&str>,
10900 origin: &str,
10901 column: Option<(String, String)>,
10902 status_target: Option<&StatusTarget>,
10903 priority: Option<&PriorityWrite>,
10904 projections: &[ProjectionWrite],
10905 native: &[String],
10906 origin_landed: &mut bool,
10907 ) -> Result<(), SourceError> {
10908 let (fields, clears) = field_writes(
10909 existing,
10910 origin_field,
10911 origin,
10912 column,
10913 priority,
10914 projections,
10915 );
10916 self.set_item_fields(board_id, item_id, &fields, &clears)
10917 .await?;
10918 *origin_landed = true;
10919
10920 // An existing issue closes in the `updateIssue` its write ends with; one created just
10921 // now closes here, once its option is selected.
10922 if existing.is_none()
10923 && content_kind == ContentKind::Issue
10924 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
10925 {
10926 self.update_content(
10927 ContentKind::Issue,
10928 content_id,
10929 json!({"stateInput":state_input(status_target)}),
10930 )
10931 .await?;
10932 }
10933
10934 if content_kind == ContentKind::Issue {
10935 self.reparent(
10936 existing.and_then(|item| item.parent.clone()),
10937 content_id,
10938 incoming.parent,
10939 )
10940 .await?;
10941 // A document takes part in no dependency graph, so writing one neither reads
10942 // nor changes the issue's own `blockedBy` relationships. Reconciling them
10943 // against the empty list a document write carries would *delete* whatever
10944 // relationships a person had made on that issue, which is a write nobody
10945 // asked for.
10946 if incoming.written.kind() != BoardKind::Document {
10947 let issue = match existing {
10948 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
10949 None => Issue::Created,
10950 };
10951 self.reconcile_blocked_by(content_id, native, issue).await?;
10952 }
10953 }
10954 Ok(())
10955 }
10956
10957 /// Whether this source is holding writes back — see [`TaskSource::begin_deferred_writes`].
10958 fn deferring(&self) -> Result<bool, SourceError> {
10959 Ok(self.deferred()?.is_some())
10960 }
10961
10962 /// Whether `content` is an item this command created whose parts are held back.
10963 fn holds_back(&self, content: &NativeId) -> Result<bool, SourceError> {
10964 Ok(self
10965 .deferred()?
10966 .as_ref()
10967 .is_some_and(|deferred| deferred.holds(content)))
10968 }
10969
10970 fn deferred(&self) -> Result<std::sync::MutexGuard<'_, Option<Deferred>>, SourceError> {
10971 self.deferred.lock().map_err(|_| SourceError::Unavailable {
10972 message: "this source's record of the writes it is holding back was left \
10973 inconsistent by an earlier failure; next: run the command again"
10974 .into(),
10975 })
10976 }
10977
10978 /// Creates one issue filed on the board and under its project by the `createIssue` itself —
10979 /// `projectV2Ids` naming the board and `parentIssueId` the project — which is what a copy
10980 /// creates with: one request where adding it to the board and filing it under its project
10981 /// were two more. GitHub adds the board item after answering, so its id is not known here;
10982 /// [`TaskSource::finish_writes`] reads it.
10983 async fn create_filed_issue(
10984 &self,
10985 board_id: &str,
10986 repository: &RepositoryTarget,
10987 incoming: &Incoming<'_>,
10988 body: &Option<String>,
10989 ) -> Result<Landed, SourceError> {
10990 let repository_id = self.repository_id(repository, incoming).await?;
10991 let data = self
10992 .graphql(
10993 graphql::CREATE_ISSUE,
10994 json!({"input": filed_input(&repository_id, board_id, incoming, body)}),
10995 )
10996 .await?;
10997 let created = data
10998 .pointer("/createIssue/issue")
10999 .filter(|value| !value.is_null())
11000 .ok_or_else(|| SourceError::Malformed {
11001 message: "GitHub issue creation returned no issue".into(),
11002 })?;
11003 self.created_landed(created).await
11004 }
11005
11006 /// Keep the address `createIssue` answered for the issue `content`, for
11007 /// [`TaskSource::written_location`].
11008 fn note_written(&self, content: &NativeId, url: Option<&str>) {
11009 if let Some(url) = url {
11010 self.written
11011 .lock()
11012 .unwrap_or_else(std::sync::PoisonError::into_inner)
11013 .insert(content.clone(), url.to_owned());
11014 }
11015 }
11016
11017 /// What one `createIssue` answered, as the item it created — or, when its number cannot be
11018 /// read, the refusal, after taking the issue back.
11019 async fn created_landed(&self, created: &Value) -> Result<Landed, SourceError> {
11020 let content_id = NativeId(required_nonblank_str(created, "id")?.to_owned());
11021 let url = optional_str(created, "url")?.map(str::to_owned);
11022 let number = match created_issue_number(created) {
11023 Ok(number) => number,
11024 Err(error) => {
11025 let _ = self.delete_issue(&content_id).await;
11026 return Err(error);
11027 }
11028 };
11029 self.note_written(&content_id, url.as_deref());
11030 Ok(Landed {
11031 content_id,
11032 item_id: None,
11033 url,
11034 number,
11035 })
11036 }
11037
11038 /// Hold back everything a write of the created item `content` would send after creating it,
11039 /// for [`TaskSource::finish_writes`]: the same field values, closing and edges
11040 /// `finish_write` sends, the parent excepted, which its create already named. A later write
11041 /// of the same item replaces what it changes.
11042 // llmlint: ignore[suppressions_justified] The arguments are `finish_write`'s, which this holds back instead of sending, and they are exactly the values `complete_write` has in scope; bundling them would describe no concept.
11043 #[allow(clippy::too_many_arguments)]
11044 fn hold_back(
11045 &self,
11046 board_id: &BoardId,
11047 content: &NativeId,
11048 existing: Option<&Resolved>,
11049 origin_field: Option<&str>,
11050 origin: &str,
11051 column: Option<(String, String)>,
11052 status_target: Option<&StatusTarget>,
11053 priority: Option<&PriorityWrite>,
11054 projections: &[ProjectionWrite],
11055 native: &[String],
11056 ) -> Result<(), SourceError> {
11057 let status = column.map(|(field, option)| {
11058 let name = self
11059 .fields_cache()
11060 .ok()
11061 .and_then(|fields| {
11062 fields.as_ref().and_then(|board| {
11063 option_name(&board.fields, &field, &option).map(str::to_owned)
11064 })
11065 })
11066 .unwrap_or_default();
11067 (field, option, name)
11068 });
11069 let (fields, clears) =
11070 field_writes(existing, origin_field, origin, None, priority, projections);
11071 let close = match status_target {
11072 Some(StatusTarget::Terminal(_, reason)) => Some(*reason),
11073 _ => None,
11074 };
11075 let mut deferred = self.deferred()?;
11076 let deferred = deferred.get_or_insert_with(Deferred::default);
11077 if let Deferred::Nothing = deferred {
11078 *deferred = Deferred::Held {
11079 board: board_id.clone(),
11080 created: Vec::new(),
11081 };
11082 }
11083 let Deferred::Held { created, .. } = deferred else {
11084 unreachable!("held back on the line above");
11085 };
11086 let held = match created.iter().position(|held| held.content == *content) {
11087 Some(at) => &mut created[at],
11088 None => {
11089 created.push(HeldBack {
11090 content: content.clone(),
11091 fields: Vec::new(),
11092 status: None,
11093 clears: Vec::new(),
11094 close: None,
11095 edges: Vec::new(),
11096 });
11097 created.last_mut().expect("just held")
11098 }
11099 };
11100 for (field, value) in fields {
11101 held.fields.retain(|(held, _)| *held != field);
11102 held.clears.retain(|held| *held != field);
11103 held.fields.push((field, value));
11104 }
11105 for field in clears {
11106 held.fields.retain(|(held, _)| held != field);
11107 if !held.clears.iter().any(|held| held == field) {
11108 held.clears.push(field.to_owned());
11109 }
11110 }
11111 if status.is_some() {
11112 held.status = status;
11113 }
11114 if close.is_some() {
11115 held.close = close;
11116 }
11117 // Nothing of an item created in this command has been sent but its create, so the edges
11118 // it is to have are exactly the ones its latest write names.
11119 held.edges = native.iter().map(|far| NativeId(far.clone())).collect();
11120 Ok(())
11121 }
11122
11123 /// Create every task of `writes`, together, filed on the board and under each one's
11124 /// project: [`graphql::CREATE_ISSUES`], [`MUTATION_BATCH`] to a request.
11125 ///
11126 /// Each is prepared as [`Self::write_item`] prepares one — every refusal a write owes made
11127 /// before the first request — then created, then completed with its parts held back. A
11128 /// request refused part way leaves the issues it did create, which are taken back, with the
11129 /// rest of this call's, before the refusal is answered.
11130 async fn create_together(
11131 &self,
11132 writes: &[ItemWrite<Task>],
11133 ) -> Result<Vec<NativeId>, SourceError> {
11134 let incomings: Vec<Incoming<'_>> = writes
11135 .iter()
11136 .map(|write| self.task_incoming(write, None))
11137 .collect::<Result<_, _>>()?;
11138 let mut prepared = Vec::with_capacity(writes.len());
11139 for (incoming, write) in incomings.iter().zip(writes) {
11140 if write.target.is_some() {
11141 return Err(SourceError::Malformed {
11142 message: "a write naming its target was handed to a batch of creates".into(),
11143 });
11144 }
11145 prepared.push(
11146 self.prepare_write(incoming, None, &write.depends_on)
11147 .await?,
11148 );
11149 }
11150 let mut inputs = Vec::with_capacity(prepared.len());
11151 for (incoming, prepared) in incomings.iter().zip(&prepared) {
11152 let WriteDestination::New(target) = &prepared.destination else {
11153 return Err(SourceError::Malformed {
11154 message: "an update was decided for a write handed to a batch of creates"
11155 .into(),
11156 });
11157 };
11158 let repository_id = self.repository_id(target, incoming).await?;
11159 inputs.push(filed_input(
11160 &repository_id,
11161 prepared.board.id.as_str(),
11162 incoming,
11163 &prepared.body,
11164 ));
11165 }
11166 let mut landed: Vec<Landed> = Vec::with_capacity(inputs.len());
11167 let refused = 'create: {
11168 for chunk in inputs.chunks(MUTATION_BATCH) {
11169 let mut variables = serde_json::Map::new();
11170 for slot in 0..MUTATION_BATCH {
11171 variables.insert(
11172 format!("create{slot}"),
11173 chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11174 );
11175 variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11176 }
11177 let data = match self
11178 .graphql(graphql::CREATE_ISSUES, Value::Object(variables))
11179 .await
11180 {
11181 Ok(data) => data,
11182 Err(error) => break 'create Some(error),
11183 };
11184 let mut refusal = data.get(ANSWER_ERRORS).map(|errors| {
11185 let said: Vec<&str> = errors
11186 .as_array()
11187 .into_iter()
11188 .flatten()
11189 .filter_map(|error| error.get("message").and_then(Value::as_str))
11190 .collect();
11191 SourceError::Refused {
11192 message: if said.is_empty() {
11193 "GitHub returned GraphQL errors".to_owned()
11194 } else {
11195 said.join("; ")
11196 },
11197 }
11198 });
11199 for slot in 0..chunk.len() {
11200 match data
11201 .pointer(&format!("/create{slot}/issue"))
11202 .filter(|value| !value.is_null())
11203 {
11204 Some(created) => match self.created_landed(created).await {
11205 Ok(created) => landed.push(created),
11206 Err(error) => {
11207 refusal.get_or_insert(error);
11208 }
11209 },
11210 // An alias GitHub refused answers nothing, and the refusal says why.
11211 None => {
11212 refusal.get_or_insert(SourceError::Malformed {
11213 message: "GitHub issue creation returned no issue".into(),
11214 });
11215 }
11216 }
11217 }
11218 if let Some(error) = refusal {
11219 break 'create Some(error);
11220 }
11221 }
11222 None
11223 };
11224 if let Some(error) = refused {
11225 // None of these ids reaches the caller, so an issue GitHub will not take back is
11226 // named here or by nobody.
11227 let ids: Vec<&NativeId> = landed.iter().map(|created| &created.content_id).collect();
11228 let left = self.delete_issues(&ids).await;
11229 let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11230 return Err(left_behind(error, &left));
11231 }
11232 let created: Vec<NativeId> = landed
11233 .iter()
11234 .map(|landed| landed.content_id.clone())
11235 .collect();
11236 let mut ids = Vec::with_capacity(landed.len());
11237 for ((incoming, prepared), landed) in incomings.iter().zip(prepared).zip(landed) {
11238 match self.complete_write(incoming, prepared, landed).await {
11239 Ok(written) => ids.push(written.id),
11240 // llmlint: ignore[changed_behavior_has_e2e] While writes are held back, which is the only time a batch is created, completing a created item only holds its parts back, and that fails on nothing but a poisoned lock, which no journey can cause; the issues are taken back regardless, because the caller is never handed their ids.
11241 Err(error) => {
11242 let ids: Vec<&NativeId> = created.iter().collect();
11243 let left = self.delete_issues(&ids).await;
11244 let left: Vec<&str> = left.iter().map(|id| id.0.as_str()).collect();
11245 return Err(left_behind(error, &left));
11246 }
11247 }
11248 }
11249 Ok(ids)
11250 }
11251
11252 /// Hold back the update of the existing issue `issue` — `members`, what its own
11253 /// [`graphql::UPDATE_ISSUE`] would send beside its id — while writes are being held back — so the updates a copy makes one after
11254 /// another go to GitHub as one request. Whatever this source held of the issue is dropped
11255 /// as the mutation itself would drop it. Answers whether it was held: never while a write
11256 /// the store's cache expects runs, which records each mutation's own answer, and never for
11257 /// an issue an update is already held for, which that one is sent ahead of.
11258 fn hold_update(&self, issue: &NativeId, members: &Value) -> Result<bool, SourceError> {
11259 if self.expected.journaling() || self.deferred()?.is_none() {
11260 return Ok(false);
11261 }
11262 let mut held = self
11263 .held_updates
11264 .lock()
11265 .map_err(|_| SourceError::Unavailable {
11266 message: "issue updates held back were left inconsistent; run the command again"
11267 .into(),
11268 })?;
11269 if held.iter().any(|earlier| earlier.issue == *issue) {
11270 return Ok(false);
11271 }
11272 let named = |value: &str| issue.0 == value;
11273 self.resolved_cache()?
11274 .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11275 self.read_ahead()?.retain(|id, item| {
11276 !named(&id.0) && !item.as_ref().is_some_and(|item| named(&item.item_id))
11277 });
11278 self.carried()?
11279 .retain(|id, item| !named(&id.0) && !named(&item.item_id));
11280 held.push(HeldUpdate::new(issue, members));
11281 Ok(true)
11282 }
11283
11284 /// Send every update [`Self::hold_update`] held back: one alone as [`graphql::UPDATE_ISSUE`]
11285 /// sends it, several as aliased `updateIssue`s of [`graphql::UPDATE_ISSUES`],
11286 /// [`MUTATION_BATCH`] to a request. Each counts one write, as it would alone.
11287 async fn send_held_updates(&self) -> Result<(), SourceError> {
11288 let held = std::mem::take(&mut *self.held_updates.lock().map_err(|_| {
11289 SourceError::Unavailable {
11290 message: "issue updates held back were left inconsistent; run the command again"
11291 .into(),
11292 }
11293 })?);
11294 if let [one] = held.as_slice() {
11295 return self
11296 .send_update(
11297 graphql::UPDATE_ISSUE,
11298 "/updateIssue/issue",
11299 &one.issue,
11300 one.input.clone(),
11301 )
11302 .await;
11303 }
11304 for chunk in held.chunks(MUTATION_BATCH) {
11305 let mut variables = serde_json::Map::new();
11306 for slot in 0..MUTATION_BATCH {
11307 variables.insert(
11308 format!("update{slot}"),
11309 chunk.get(slot).unwrap_or(&chunk[0]).input.clone(),
11310 );
11311 variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11312 }
11313 let data = self
11314 .graphql(graphql::UPDATE_ISSUES, Value::Object(variables))
11315 .await?;
11316 let inputs: Vec<&Value> = chunk.iter().map(|held| &held.input).collect();
11317 check_aliased_answers(
11318 graphql::UPDATE_ISSUES,
11319 &data,
11320 "update",
11321 &inputs,
11322 &[("issue", "id")],
11323 )?;
11324 }
11325 Ok(())
11326 }
11327
11328 /// Send everything held back since [`TaskSource::begin_deferred_writes`]: one read of the
11329 /// created items' board items, then their field values and edges together in requests of
11330 /// [`WRITE_BATCH`], then their closes in requests of [`MUTATION_BATCH`]. Anything refused
11331 /// takes back every issue held, [`DELETE_BATCH`] to a request, and answers the refusal.
11332 async fn send_held_back(&self) -> Result<(), SourceError> {
11333 let Some(Deferred::Held { board, created }) = self.deferred()?.take() else {
11334 return Ok(());
11335 };
11336 if created.is_empty() {
11337 return Ok(());
11338 }
11339 let sent = self.send_deferred(board.as_str(), &created).await;
11340 if let Err(error) = sent {
11341 // Best effort, and the failure is what the caller is told, as a write of one item
11342 // that fails part way takes back what it created: the copy that held these back
11343 // journalled every one, so one this cannot take back its undo tries again, and says
11344 // so when it cannot either.
11345 let ids: Vec<&NativeId> = created.iter().map(|held| &held.content).collect();
11346 let _ = self.delete_issues(&ids).await;
11347 return Err(error);
11348 }
11349 Ok(())
11350 }
11351
11352 /// The requests [`Self::send_held_back`] makes.
11353 async fn send_deferred(&self, board: &str, created: &[HeldBack]) -> Result<(), SourceError> {
11354 let items = self.created_board_items(board, created).await?;
11355 let mut fields: Vec<Value> = Vec::new();
11356 let mut closes: Vec<Value> = Vec::new();
11357 let mut edges: Vec<Value> = Vec::new();
11358 for held in created {
11359 let (item, status) = &items[&held.content];
11360 let mut values = held.fields.clone();
11361 // A status the board already shows — set by a workflow a person turned on to file
11362 // new items at it — is not written again.
11363 if let Some((field, option, name)) = &held.status
11364 && status.as_deref() != Some(name.as_str())
11365 {
11366 values.insert(0, (field.clone(), json!({"singleSelectOptionId": option})));
11367 }
11368 for (field, value) in values {
11369 fields.push(json!({"projectId":board,"itemId":item,"fieldId":field,"value":value}));
11370 }
11371 if let Some(reason) = held.close {
11372 closes.push(json!({"id": held.content.0, "stateInput": closed_input(reason)}));
11373 }
11374 for far in &held.edges {
11375 edges.push(json!({"issueId": held.content.0, "blockingIssueId": far.0}));
11376 }
11377 if !held.clears.is_empty() {
11378 let clears: Vec<&str> = held.clears.iter().map(String::as_str).collect();
11379 self.set_item_fields(board, item, &[], &clears).await?;
11380 }
11381 }
11382 // Each alias answers with what it wrote, which is what its input named: the board item a
11383 // field value is set on, the issue closed, the issue blocked and the one blocking it.
11384 let field_answer: &[(&str, &str)] = &[("projectV2Item", "itemId")];
11385 let close_answer: &[(&str, &str)] = &[("issue", "id")];
11386 let block_answer: &[(&str, &str)] =
11387 &[("issue", "issueId"), ("blockingIssue", "blockingIssueId")];
11388 // Every field value, then every edge, as one ordered list sent `WRITE_BATCH` aliases to a
11389 // request: a request holding both kinds is the one document carrying both, so a plan's
11390 // values and edges cost one request where they fit in one.
11391 let writes: Vec<HeldWrite<'_>> = fields
11392 .iter()
11393 .map(HeldWrite::Field)
11394 .chain(edges.iter().map(HeldWrite::Edge))
11395 .collect();
11396 for chunk in writes.chunks(WRITE_BATCH) {
11397 let values: Vec<&Value> = chunk
11398 .iter()
11399 .filter_map(|write| match write {
11400 HeldWrite::Field(input) => Some(*input),
11401 HeldWrite::Edge(_) => None,
11402 })
11403 .collect();
11404 let blocks: Vec<&Value> = chunk
11405 .iter()
11406 .filter_map(|write| match write {
11407 HeldWrite::Edge(input) => Some(*input),
11408 HeldWrite::Field(_) => None,
11409 })
11410 .collect();
11411 let (document, sends) = match (values.is_empty(), blocks.is_empty()) {
11412 (false, false) => (graphql::WRITE_FIELDS_AND_EDGES, ["sendField", "sendBlock"]),
11413 (false, true) => (graphql::WRITE_FIELD_VALUES, ["send", "send"]),
11414 _ => (graphql::ADD_BLOCKED_BYS, ["send", "send"]),
11415 };
11416 let mut variables = serde_json::Map::new();
11417 let kinds = [
11418 ("field", &values, sends[0], field_answer),
11419 ("block", &blocks, sends[1], block_answer),
11420 ];
11421 for (prefix, inputs, send, _) in &kinds {
11422 if inputs.is_empty() {
11423 continue;
11424 }
11425 for slot in 0..WRITE_BATCH {
11426 variables.insert(
11427 format!("{prefix}{slot}"),
11428 (*inputs.get(slot).unwrap_or(&inputs[0])).clone(),
11429 );
11430 variables.insert(format!("{send}{slot}"), json!(slot < inputs.len()));
11431 }
11432 }
11433 let data = self.graphql(document, Value::Object(variables)).await?;
11434 for (prefix, inputs, _, answers) in kinds {
11435 check_aliased_answers(document, &data, prefix, inputs, answers)?;
11436 }
11437 }
11438 for chunk in closes.chunks(MUTATION_BATCH) {
11439 let mut variables = serde_json::Map::new();
11440 for slot in 0..MUTATION_BATCH {
11441 variables.insert(
11442 format!("close{slot}"),
11443 chunk.get(slot).cloned().unwrap_or_else(|| chunk[0].clone()),
11444 );
11445 variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11446 }
11447 let data = self
11448 .graphql(graphql::CLOSE_ISSUES, Value::Object(variables))
11449 .await?;
11450 let inputs: Vec<&Value> = chunk.iter().collect();
11451 check_aliased_answers(graphql::CLOSE_ISSUES, &data, "close", &inputs, close_answer)?;
11452 }
11453 for held in created {
11454 self.learn_item(&held.content, &items[&held.content].0)?;
11455 }
11456 Ok(())
11457 }
11458
11459 /// The board item each of `created` is on this board, with its `Status` as the board set it:
11460 /// [`graphql::CREATED_BOARD_ITEMS`], read again — after a wait of a second, then two, four,
11461 /// eight and sixteen — for only those GitHub has not filed yet.
11462 ///
11463 /// **Membership is never assumed.** `createIssue`'s `projectV2Ids` files an issue some time
11464 /// after GitHub answered the create — the feasibility spike saw 0.45 to 1.84 seconds, and no
11465 /// create answer carrying the item — so only a read says it is there. One still absent once
11466 /// those reads are spent is filed with [`graphql::ADD_TO_BOARD`], one write each; GitHub
11467 /// filing it meanwhile refuses that "Content already exists in this project", which says it
11468 /// is placed, and one more read finds the item rather than a second one being made.
11469 async fn created_board_items(
11470 &self,
11471 board: &str,
11472 created: &[HeldBack],
11473 ) -> Result<BTreeMap<NativeId, (String, Option<String>)>, SourceError> {
11474 let mut found: BTreeMap<NativeId, (String, Option<String>)> = BTreeMap::new();
11475 let unfiled = |found: &BTreeMap<NativeId, (String, Option<String>)>| -> Vec<NativeId> {
11476 created
11477 .iter()
11478 .map(|held| held.content.clone())
11479 .filter(|id| !found.contains_key(id))
11480 .collect()
11481 };
11482 let mut wait = Duration::from_secs(1);
11483 for attempt in 0..6 {
11484 let absent = unfiled(&found);
11485 if absent.is_empty() {
11486 return Ok(found);
11487 }
11488 if attempt > 0 {
11489 self.clock
11490 .sleep_because(wait, pacing::cause::BOARD_FILING)
11491 .await;
11492 wait = wait.saturating_mul(2);
11493 }
11494 self.read_created_items(board, &absent, &mut found).await?;
11495 }
11496 let mut placed_meanwhile = Vec::new();
11497 for id in unfiled(&found) {
11498 match self
11499 .graphql(
11500 graphql::ADD_TO_BOARD,
11501 json!({"input":{"projectId":board,"contentId":id.0}}),
11502 )
11503 .await
11504 {
11505 Ok(added) => {
11506 let item = added
11507 .pointer("/addProjectV2ItemById/item")
11508 .filter(|value| !value.is_null())
11509 .ok_or_else(|| SourceError::Malformed {
11510 message: "GitHub board addition returned no project item".into(),
11511 })?;
11512 found.insert(id, (required_nonblank_str(item, "id")?.to_owned(), None));
11513 }
11514 Err(error) if already_on_the_board(&error) => placed_meanwhile.push(id),
11515 Err(error) => return Err(error),
11516 }
11517 }
11518 if !placed_meanwhile.is_empty() {
11519 self.read_created_items(board, &placed_meanwhile, &mut found)
11520 .await?;
11521 }
11522 let absent = unfiled(&found);
11523 if absent.is_empty() {
11524 return Ok(found);
11525 }
11526 let absent: Vec<&str> = absent.iter().map(|id| id.0.as_str()).collect();
11527 Err(SourceError::Unavailable {
11528 message: format!(
11529 "GitHub created {} filed on board {board} and, after 31 seconds and adding {} \
11530 to it, still answers {} on no item of it: {}; next: run the copy again once \
11531 GitHub has caught up",
11532 if absent.len() == 1 {
11533 "an issue"
11534 } else {
11535 "issues"
11536 },
11537 if absent.len() == 1 { "it" } else { "them" },
11538 if absent.len() == 1 { "it" } else { "them" },
11539 absent.join(", ")
11540 ),
11541 })
11542 }
11543
11544 /// One [`graphql::CREATED_BOARD_ITEMS`] read of each of `ids`, [`COPY_DETAIL_BATCH`] to a
11545 /// request, adding to `found` the board item of every one GitHub has filed on `board`.
11546 async fn read_created_items(
11547 &self,
11548 board: &str,
11549 ids: &[NativeId],
11550 found: &mut BTreeMap<NativeId, (String, Option<String>)>,
11551 ) -> Result<(), SourceError> {
11552 for chunk in ids.chunks(COPY_DETAIL_BATCH) {
11553 let mut variables = serde_json::Map::new();
11554 for slot in 0..COPY_DETAIL_BATCH {
11555 variables.insert(
11556 format!("id{slot}"),
11557 json!(chunk.get(slot).unwrap_or(&chunk[0]).0),
11558 );
11559 variables.insert(format!("read{slot}"), json!(slot < chunk.len()));
11560 }
11561 variables.insert("boardItems".into(), json!(BOARD_ITEMS_PAGE_SIZE));
11562 variables.insert("statusName".into(), json!(STATUS_FIELD));
11563 let data = self
11564 .graphql(graphql::CREATED_BOARD_ITEMS, Value::Object(variables))
11565 .await?;
11566 for (slot, id) in chunk.iter().enumerate() {
11567 // An answer for another issue than the one asked about says nothing of
11568 // where that one is filed, and its board item is not this one's.
11569 let node = data.get(format!("i{slot}")).filter(|node| !node.is_null());
11570 let named = node.map(|node| node.get("id").and_then(Value::as_str));
11571 if let Some(named) = named
11572 && named != Some(id.0.as_str())
11573 {
11574 return Err(SourceError::Malformed {
11575 message: format!(
11576 "GitHub answered the board items of {} when asked for those of {}",
11577 named.unwrap_or("an issue it did not name"),
11578 id.0
11579 ),
11580 });
11581 }
11582 // An issue GitHub does not answer yet is not filed yet; one it answers is
11583 // read whole, its connection, each board item's board and its status, and
11584 // any of them unreadable is refused rather than read as not filed.
11585 let Some(node) = node else { continue };
11586 let malformed = |what: &str| SourceError::Malformed {
11587 message: format!("GitHub answered the board items of {} with {what}", id.0),
11588 };
11589 let nodes = node
11590 .pointer("/projectItems/nodes")
11591 .and_then(Value::as_array)
11592 .ok_or_else(|| malformed("no list of board items"))?;
11593 let mut item = None;
11594 for node in nodes {
11595 let on = node
11596 .pointer("/project/id")
11597 .and_then(Value::as_str)
11598 .ok_or_else(|| malformed("a board item naming no board"))?;
11599 if on == board {
11600 item = Some(node);
11601 }
11602 }
11603 if let Some(item) = item {
11604 let status = match item.get("status") {
11605 None | Some(Value::Null) => None,
11606 Some(status) => Some(
11607 status
11608 .get("name")
11609 .and_then(Value::as_str)
11610 .ok_or_else(|| malformed("a status that names no option"))?
11611 .to_owned(),
11612 ),
11613 };
11614 found.insert(
11615 id.clone(),
11616 (required_nonblank_str(item, "id")?.to_owned(), status),
11617 );
11618 }
11619 }
11620 }
11621 Ok(())
11622 }
11623
11624 /// The board item id of `content`, which this process created and remembered without one,
11625 /// in every half of its own view.
11626 fn learn_item(&self, content: &NativeId, item: &str) -> Result<(), SourceError> {
11627 if let Some(held) = self.resolved_cache()?.get_mut(content) {
11628 held.item_id = item.to_owned();
11629 }
11630 if let Some(held) = self.created()?.iter_mut().find(|held| held.id == *content) {
11631 held.item_id = item.to_owned();
11632 }
11633 Ok(())
11634 }
11635
11636 /// Ask together which items each of `origins` names, and what creating an item reads; see
11637 /// [`TaskSource::read_ahead_origins_for_copy`]. An origin whose answer runs past its first
11638 /// page is left to its own lookup.
11639 async fn origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
11640 if self.repository.is_none() {
11641 return Ok(());
11642 }
11643 let unasked: Vec<&String> = {
11644 let held = self.narrowed_cache()?;
11645 let mut unasked: Vec<&String> = Vec::new();
11646 for origin in origins {
11647 if !held.contains_key(&Narrowing::Origin(origin.clone()).key())
11648 && !unasked.contains(&origin)
11649 {
11650 unasked.push(origin);
11651 }
11652 }
11653 unasked
11654 };
11655 if unasked.is_empty() {
11656 return Ok(());
11657 }
11658 // One batch is held back for the read ahead that follows, which may carry a project's
11659 // members beside it in the same request.
11660 if unasked.len() <= ORIGIN_BATCH {
11661 let mut pending =
11662 self.pending_origins
11663 .lock()
11664 .map_err(|_| SourceError::Unavailable {
11665 message:
11666 "origins held for a read ahead were left inconsistent; run the command \
11667 again"
11668 .into(),
11669 })?;
11670 for origin in unasked {
11671 if !pending.contains(origin) {
11672 pending.push(origin.clone());
11673 }
11674 }
11675 return Ok(());
11676 }
11677 let unasked: Vec<String> = unasked.into_iter().cloned().collect();
11678 self.send_origin_lookups(&unasked, None).await
11679 }
11680
11681 /// Hold, for [`TaskSource::member_classifications`] of `project`, each member's
11682 /// classification and repositories as the members page `data` carries them — when that page
11683 /// is every member, each one names `project` as its parent, and every record it holds is
11684 /// readable. Any other page holds nothing, so the members are read as they always were.
11685 fn hold_member_classes(&self, project: &NativeId, data: &Value) -> Result<(), SourceError> {
11686 let Some(page) = data
11687 .pointer("/members/subIssues")
11688 .filter(|page| !page.is_null())
11689 else {
11690 return Ok(());
11691 };
11692 if page
11693 .pointer("/pageInfo/hasNextPage")
11694 .and_then(Value::as_bool)
11695 != Some(false)
11696 {
11697 return Ok(());
11698 }
11699 let Some(nodes) = page.get("nodes").and_then(Value::as_array) else {
11700 return Ok(());
11701 };
11702 let mut members = Vec::with_capacity(nodes.len());
11703 for node in nodes {
11704 if node.pointer("/parent/id").and_then(Value::as_str) != Some(project.0.as_str()) {
11705 return Ok(());
11706 }
11707 let Some(body) = node.get("body").and_then(Value::as_str) else {
11708 return Ok(());
11709 };
11710 // Every node here is filed under `project`, which `BoardKind::of` reads as one of its
11711 // tasks whatever its marker says, so each is a member the full read would classify.
11712 let Ok((_, slot)) = metadata_body(Some(body.to_owned())) else {
11713 return Ok(());
11714 };
11715 let Ok(classification) = Classification::from_metadata(&slot) else {
11716 return Ok(());
11717 };
11718 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
11719 let Ok(repositories) = Repository::from_metadata(&slot) else {
11720 return Ok(());
11721 };
11722 repositories
11723 } else {
11724 let Some(origin) = node
11725 .pointer("/repository/nameWithOwner")
11726 .and_then(Value::as_str)
11727 else {
11728 return Ok(());
11729 };
11730 let Ok(own) = Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST))
11731 else {
11732 return Ok(());
11733 };
11734 vec![own]
11735 };
11736 members.push((classification, repositories));
11737 }
11738 self.member_classes
11739 .lock()
11740 .map_err(|_| SourceError::Unavailable {
11741 message: "a project's members held for its classification were left \
11742 inconsistent; run the command again"
11743 .into(),
11744 })?
11745 .insert(project.clone(), members);
11746 Ok(())
11747 }
11748
11749 /// Send every origin held back for the read ahead, beside the members of `members` when it
11750 /// names a project — or nothing, when none is held.
11751 async fn send_pending_origins(&self, members: Option<&NativeId>) -> Result<(), SourceError> {
11752 let pending = std::mem::take(&mut *self.pending_origins.lock().map_err(|_| {
11753 SourceError::Unavailable {
11754 message: "origins held for a read ahead were left inconsistent; run the command \
11755 again"
11756 .into(),
11757 }
11758 })?);
11759 if pending.is_empty() {
11760 return Ok(());
11761 }
11762 self.send_origin_lookups(&pending, members).await
11763 }
11764
11765 /// [`graphql::ORIGIN_LOOKUPS`] of `unasked`, [`ORIGIN_BATCH`] to a request — the first one
11766 /// [`graphql::ORIGIN_LOOKUPS_WITH_MEMBERS`], carrying the members of `members`, when it names
11767 /// a project — holding each origin's answer and, from a members page that is the whole of
11768 /// them, each member's classification.
11769 async fn send_origin_lookups(
11770 &self,
11771 unasked: &[String],
11772 members: Option<&NativeId>,
11773 ) -> Result<(), SourceError> {
11774 let Some(repository) = self.repository.clone() else {
11775 return Ok(());
11776 };
11777 let mut members = members;
11778 for chunk in unasked.chunks(ORIGIN_BATCH) {
11779 let mut variables = json!({"owner":self.owner,"number":self.project_number,
11780 "type":"ISSUE","lookupFirst":ORIGIN_LOOKUPS_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
11781 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true,
11782 "repositoryOwner":repository.owner,"repositoryName":repository.name});
11783 for slot in 0..ORIGIN_BATCH {
11784 let origin = chunk.get(slot).unwrap_or(&chunk[0]);
11785 variables[format!("filter{slot}")] =
11786 json!(format!("{ORIGIN_FIELD}:{}", quoted(origin)));
11787 variables[format!("search{slot}")] = json!(
11788 self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))))
11789 );
11790 variables[format!("ask{slot}")] = json!(slot < chunk.len());
11791 }
11792 let project = members.take();
11793 let document = match project {
11794 Some(project) => {
11795 variables["project"] = json!(project.0);
11796 variables["memberFirst"] = json!(MEMBER_PAGE_SIZE);
11797 graphql::ORIGIN_LOOKUPS_WITH_MEMBERS
11798 }
11799 None => graphql::ORIGIN_LOOKUPS,
11800 };
11801 let data = self.graphql(document, variables).await?;
11802 self.fields_read(&data)?;
11803 if let Some(project) = project {
11804 self.hold_member_classes(project, &data)?;
11805 }
11806 if let Some(node) = data.get("repository").filter(|value| !value.is_null()) {
11807 let id = required_nonblank_str(node, "id")?.to_owned();
11808 self.repository_cache()?.insert(repository.clone(), id);
11809 if let Some(numeric) = node
11810 .get("databaseId")
11811 .and_then(Value::as_u64)
11812 .and_then(std::num::NonZeroU64::new)
11813 {
11814 self.numeric_repositories
11815 .lock()
11816 .await
11817 .insert(repository.clone(), numeric);
11818 }
11819 }
11820 for (slot, origin) in chunk.iter().enumerate() {
11821 let items = data
11822 .pointer(&format!("/o{slot}/projectV2/items"))
11823 .filter(|value| !value.is_null())
11824 .ok_or_else(|| SourceError::Refused {
11825 message: format!(
11826 "GitHub project {}/{} was not found or is not visible to the token",
11827 self.owner, self.project_number
11828 ),
11829 })?;
11830 let searched =
11831 data.get(format!("s{slot}"))
11832 .ok_or_else(|| SourceError::Malformed {
11833 message: "GitHub search response has no search connection".into(),
11834 })?;
11835 if resumed(items, None)?.has_more() || resumed(searched, None)?.has_more() {
11836 continue;
11837 }
11838 let mut found: Vec<Resolved> = Vec::new();
11839 for item in optional_nodes(Some(items), "project items")?
11840 .into_iter()
11841 .flatten()
11842 {
11843 if let Some(resolved) = self.resolve(item)?
11844 && resolved.content_kind == ContentKind::Issue
11845 && !found.iter().any(|held| held.id == resolved.id)
11846 {
11847 found.push(resolved);
11848 }
11849 }
11850 for node in optional_nodes(Some(searched), "search")?
11851 .into_iter()
11852 .flatten()
11853 {
11854 if let Some(resolved) = self.resolve_issue(node).await?
11855 && !found.iter().any(|held| held.id == resolved.id)
11856 {
11857 found.push(resolved);
11858 }
11859 }
11860 self.narrowed_cache()?
11861 .insert(Narrowing::Origin((*origin).clone()).key(), found);
11862 }
11863 }
11864 Ok(())
11865 }
11866
11867 /// Delete every issue of `ids`, [`DELETE_BATCH`] to a request with
11868 /// [`graphql::DELETE_ISSUES`], and answer those still there: each a request's answer does not
11869 /// confirm, every one of a request refused whole included.
11870 async fn delete_issues(&self, ids: &[&NativeId]) -> Vec<NativeId> {
11871 let mut left = Vec::new();
11872 for chunk in ids.chunks(DELETE_BATCH) {
11873 let mut variables = serde_json::Map::new();
11874 for slot in 0..DELETE_BATCH {
11875 let id = chunk.get(slot).unwrap_or(&chunk[0]);
11876 variables.insert(format!("delete{slot}"), json!({"issueId": id.0}));
11877 variables.insert(format!("send{slot}"), json!(slot < chunk.len()));
11878 }
11879 let data = self
11880 .graphql(graphql::DELETE_ISSUES, Value::Object(variables))
11881 .await
11882 .unwrap_or(Value::Null);
11883 for (slot, id) in chunk.iter().enumerate() {
11884 // Confirmed only by the repository the deleted issue was in, named by a
11885 // non-blank id: anything else says nothing of whether the issue is gone.
11886 let confirmed = data
11887 .pointer(&format!("/delete{slot}/repository/id"))
11888 .and_then(Value::as_str)
11889 .is_some_and(|id| !id.trim().is_empty());
11890 if !confirmed {
11891 left.push((*id).clone());
11892 }
11893 }
11894 }
11895 left
11896 }
11897
11898 /// Delete one issue, which takes its board item with it.
11899 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
11900 let data = self
11901 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11902 .await?;
11903 data.pointer("/deleteIssue/repository")
11904 .filter(|value| !value.is_null())
11905 .ok_or_else(|| SourceError::Malformed {
11906 message: "GitHub issue deletion returned no repository".into(),
11907 })?;
11908 self.forget(id)?;
11909 Ok(())
11910 }
11911
11912 /// Remove one item this copy created, so a copy that could not finish leaves the board
11913 /// as it found it.
11914 ///
11915 /// Deleting the issue takes its board item with it, so there is no second mutation to
11916 /// keep in step. An id the board does not hold is not an error: the item is already
11917 /// gone, which is the state this asks for. Which that is, is decided by reading the item
11918 /// by its own id — a listing of the board can still be missing an item it holds, and
11919 /// reading that as *already gone* would leave behind the very item this was asked to
11920 /// take back.
11921 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
11922 let Some(item) = self.bound_item(id).await? else {
11923 return Ok(());
11924 };
11925 if item.content_kind == ContentKind::DraftIssue {
11926 return Err(SourceError::Refused {
11927 message: format!(
11928 "GitHub item {} is a draft, and this source removes an item by deleting \
11929 its issue; next: remove it from the board by hand",
11930 id.0
11931 ),
11932 });
11933 }
11934 let data = self
11935 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
11936 .await?;
11937 data.pointer("/deleteIssue/repository")
11938 .filter(|value| !value.is_null())
11939 .ok_or_else(|| SourceError::Malformed {
11940 message: "GitHub issue deletion returned no repository".into(),
11941 })?;
11942 self.forget(id)?;
11943 Ok(())
11944 }
11945
11946 /// The issue a comment call on `task` is about, or `None` when this board holds no such
11947 /// task.
11948 ///
11949 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
11950 /// read of the task cannot disagree about which ids name one: a project or a document of
11951 /// this board is not a task here either.
11952 ///
11953 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
11954 /// issues and a draft is not one. It is refused rather than answered with an empty page,
11955 /// which would read as a task nobody has commented on yet.
11956 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
11957 let cached = self.resolved_cache()?.get(task).cloned();
11958 let Some(item) = (match cached {
11959 Some(item) => Some(item),
11960 None => self.item_by_id(task).await?,
11961 })
11962 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
11963 return Ok(None);
11964 };
11965 if item.content_kind == ContentKind::DraftIssue {
11966 return Err(self.draft_has_no_comments(task));
11967 }
11968 Ok(Some(item.id))
11969 }
11970
11971 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
11972 /// issues, and a draft is not one.
11973 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
11974 SourceError::Refused {
11975 message: format!(
11976 "task {} of source {} is a draft item on the board, and GitHub keeps \
11977 comments on issues alone, so a draft has none to read or write; next: \
11978 convert the draft to an issue on the board, then comment on the issue it \
11979 becomes",
11980 task.0, self.name
11981 ),
11982 }
11983 }
11984
11985 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
11986 /// request — or `None` when this board holds no task by that id.
11987 ///
11988 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
11989 /// is answered with the draft and the refusal, at the price of the draft's own read.
11990 async fn issue_detail(
11991 &self,
11992 id: &NativeId,
11993 page: &PageRequest,
11994 ) -> Result<Option<TaskDetailRead>, SourceError> {
11995 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
11996 let asked = self
11997 .graphql(
11998 graphql::ISSUE_DETAIL,
11999 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
12000 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
12001 "duplicates":true}),
12002 )
12003 .await;
12004 let data = match asked {
12005 Ok(data) => data,
12006 Err(error) if unresolvable_node(&error) => return Ok(None),
12007 Err(error) => return Err(error),
12008 };
12009 // `node` is null for an id that names nothing, and absent only from an answer this
12010 // source cannot read — never the same thing.
12011 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
12012 message: format!("GitHub answered the read of {} with no node", id.0),
12013 })?;
12014 self.detail_of(id, node, true, after).await
12015 }
12016
12017 /// Several tasks, each with the first page of its comments when `comments` is set, read
12018 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
12019 /// order.
12020 ///
12021 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
12022 /// one item at a time, so that id is answered as missing and the others as themselves; any
12023 /// other refusal is every id of that batch's answer.
12024 async fn issue_details(
12025 &self,
12026 ids: &[NativeId],
12027 comments: Option<&PageRequest>,
12028 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
12029 let mut read = Vec::with_capacity(ids.len());
12030 for batch in ids.chunks(DETAIL_BATCH) {
12031 match self
12032 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
12033 .await
12034 {
12035 Ok(data) => {
12036 for (slot, id) in batch.iter().enumerate() {
12037 // Every alias asked for is answered, null for an id naming nothing;
12038 // one missing is an answer this source cannot read.
12039 let read_one = match data.get(format!("i{slot}")) {
12040 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
12041 None => Err(SourceError::Malformed {
12042 message: format!(
12043 "GitHub answered a batch read with no item for {}",
12044 id.0
12045 ),
12046 }),
12047 };
12048 read.push(read_one);
12049 }
12050 }
12051 Err(error) if unresolvable_node(&error) => {
12052 for id in batch {
12053 read.push(match comments {
12054 Some(page) => self.issue_detail(id, page).await,
12055 None => self.task_read(id).await,
12056 });
12057 }
12058 }
12059 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
12060 }
12061 }
12062 read
12063 }
12064
12065 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
12066 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
12067 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
12068 task,
12069 comments: None,
12070 }))
12071 }
12072
12073 /// What one node a detail read reached says: the task this board holds by `id`, with the
12074 /// page of comments the node carries when `commented` — or `None` for a node that is no
12075 /// task of this board.
12076 ///
12077 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
12078 /// and an item this process created answers from this process's own record, which a node
12079 /// read taken moments after the write can still be behind.
12080 async fn detail_of(
12081 &self,
12082 id: &NativeId,
12083 node: &Value,
12084 commented: bool,
12085 after: Option<&str>,
12086 ) -> Result<Option<TaskDetailRead>, SourceError> {
12087 if node.is_null() {
12088 return Ok(None);
12089 }
12090 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
12091 // An issue answered under one id is that id's, or the answer is not one this source
12092 // can report: reporting another issue's task and comments under the qualified id asked
12093 // for would be the one wrong answer here. A draft's own read checks the same.
12094 if !draft
12095 && optional_str(node, "__typename")? == Some("Issue")
12096 && required_str(node, "id")? != id.0
12097 {
12098 return Err(SourceError::Malformed {
12099 message: format!(
12100 "GitHub answered the read of {} with issue {}",
12101 id.0,
12102 required_str(node, "id")?
12103 ),
12104 });
12105 }
12106 let item = if draft {
12107 self.draft_by_id(id).await?
12108 } else {
12109 self.resolve_issue(node).await?
12110 };
12111 // A read that carried the whole of what the issue is blocked by — a copy's — is kept
12112 // for this command, so the dependency read the copy makes next is answered from it.
12113 if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
12114 self.resolved_cache()?.insert(read.id.clone(), read.clone());
12115 }
12116 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
12117 return Ok(None);
12118 };
12119 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
12120 let task = own.unwrap_or(item).task()?;
12121 let comments = match (commented, draft) {
12122 (false, _) => None,
12123 (true, true) => Some(Err(self.draft_has_no_comments(id))),
12124 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
12125 };
12126 if commented && !draft && after.is_none() {
12127 self.expected.learn_comments(id, node);
12128 }
12129 Ok(Some(TaskDetailRead { task, comments }))
12130 }
12131
12132 /// Whether the comment `comment` is one of `issue`'s own.
12133 ///
12134 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
12135 /// comment's id and nothing else: a comment id given against the wrong task would
12136 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
12137 /// names something that is not an issue comment, is a comment this task does not have —
12138 /// which is what GitHub refusing to resolve it means too.
12139 async fn comment_is_on(
12140 &self,
12141 issue: &NativeId,
12142 comment: &NativeId,
12143 ) -> Result<bool, SourceError> {
12144 Ok(self.comment_on(issue, comment).await?.is_some())
12145 }
12146
12147 /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
12148 /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
12149 async fn comment_on(
12150 &self,
12151 issue: &NativeId,
12152 comment: &NativeId,
12153 ) -> Result<Option<String>, SourceError> {
12154 // A write the store's cache expects knows which comments its issue held, and their
12155 // bodies, at the version it expects: what this read would answer.
12156 if let Some(primed) = self.expected.primed_comment(issue, comment) {
12157 return Ok(primed);
12158 }
12159 let asked = self
12160 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
12161 .await;
12162 let data = match asked {
12163 Ok(data) => data,
12164 Err(error) if unresolvable_node(&error) => return Ok(None),
12165 Err(error) => return Err(error),
12166 };
12167 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
12168 return Ok(None);
12169 };
12170 if optional_str(node, "__typename")? != Some("IssueComment") {
12171 return Ok(None);
12172 }
12173 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
12174 message: format!("GitHub issue comment {} names no issue", comment.0),
12175 })?;
12176 if required_str(on, "id")? != issue.0 {
12177 return Ok(None);
12178 }
12179 Ok(Some(required_str(node, "body")?.to_owned()))
12180 }
12181
12182 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
12183 async fn partition_edges(
12184 &self,
12185 near_kind: BoardKind,
12186 near_content: ContentKind,
12187 carried: Option<&[Value]>,
12188 depends_on: &[DependencyEdge],
12189 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
12190 let mut native = Vec::new();
12191 let mut fallback = Vec::new();
12192 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
12193 .iter()
12194 .map(|edge| {
12195 // An opaque far end names another tracker, so it is never this board's own.
12196 let same_source = edge.to.designator().is_none()
12197 && edge
12198 .to
12199 .source()
12200 .is_none_or(|source| source == self.name.as_str());
12201 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
12202 // `DependencyEndpoint::source` both read it that way — and a native id may hold
12203 // colons of its own, so the far end is everything after that one separator.
12204 // Splitting at the last would truncate `work:urn:task:7` to `7`.
12205 let far_id = if edge.to.is_qualified() {
12206 edge.to
12207 .id()
12208 .split_once(':')
12209 .map_or(edge.to.id(), |(_, native)| native)
12210 } else {
12211 edge.to.id()
12212 };
12213 // One that already blocks the near issue was answered by that issue's own
12214 // read, which carried each of its blockers' kinds — an issue every one — so it
12215 // is not read again.
12216 let blocking = carried.and_then(|nodes| {
12217 nodes
12218 .iter()
12219 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
12220 });
12221 (edge, far_id, same_source, blocking)
12222 })
12223 .collect();
12224 // Every other same-source far end is read by its own id, exactly as the item it is a
12225 // far end of is: whether this board holds it is that read's answer, never a listing's.
12226 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
12227 let mut unread: Vec<NativeId> = Vec::new();
12228 for (_, far_id, same_source, blocking) in &far_ends {
12229 let id = NativeId((*far_id).to_owned());
12230 if *same_source && blocking.is_none() && !unread.contains(&id) {
12231 unread.push(id);
12232 }
12233 }
12234 let read: BTreeMap<NativeId, Option<Resolved>> = unread
12235 .iter()
12236 .cloned()
12237 .zip(self.items_by_ids(&unread).await?)
12238 .collect();
12239 for (edge, far_id, same_source, blocking) in far_ends {
12240 let far = match (same_source, blocking) {
12241 (false, _) => None,
12242 (true, Some(node)) => Some(FarEnd {
12243 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
12244 BoardKind::Document
12245 } else {
12246 BoardKind::Work(related_kind(node, self.sub_issues)?)
12247 },
12248 content_kind: ContentKind::Issue,
12249 }),
12250 (true, None) => {
12251 let read = read
12252 .get(&NativeId(far_id.to_owned()))
12253 .cloned()
12254 .flatten()
12255 .ok_or_else(|| SourceError::Refused {
12256 message: format!("GitHub dependency item {far_id} was not found"),
12257 })?;
12258 Some(FarEnd {
12259 kind: read.kind,
12260 content_kind: read.content_kind,
12261 })
12262 }
12263 };
12264 let far = far.as_ref();
12265 // The caller says which kind the far end is, and this board holds the far end
12266 // itself, so a disagreement is settled here rather than stored: recorded, the
12267 // wrong kind would read back as a cross-level edge that never existed; written
12268 // natively, it would name a relationship of a different level than the caller
12269 // asked for.
12270 //
12271 // A far end this board holds as a *document* fails the same comparison and is
12272 // refused by the same sentence: `ItemKind` has no document variant because
12273 // nothing may point at one, so no caller can name it correctly and the refusal
12274 // is the only honest answer.
12275 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
12276 return Err(SourceError::Refused {
12277 message: format!(
12278 "GitHub dependency item {far_id} is a {} of this board, and this item \
12279 names it as a {}; record the kind it is",
12280 disagreeing.kind.describes(),
12281 edge.to.kind.marker()
12282 ),
12283 });
12284 }
12285 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
12286 // however the far end is spelled — and one classified native here would be
12287 // written nowhere at all, because a draft's native reconciliation never runs.
12288 let native_here = near_content == ContentKind::Issue
12289 && far.is_some_and(|far| {
12290 far.content_kind == ContentKind::Issue
12291 && BoardKind::Work(edge.to.kind) == near_kind
12292 });
12293 if native_here {
12294 native.push(far_id.to_owned());
12295 } else {
12296 fallback.push(edge.clone());
12297 }
12298 }
12299 Ok((native, fallback))
12300 }
12301
12302 async fn update_existing(
12303 &self,
12304 item: &Resolved,
12305 incoming: &Incoming<'_>,
12306 body: &Option<String>,
12307 status_target: Option<&StatusTarget>,
12308 ) -> Result<(), SourceError> {
12309 let title = incoming.written_title();
12310 // A terminal status closes the issue here, in the same mutation as its body: its board
12311 // option was selected before this, so a close never lands on an item whose board cannot
12312 // show it.
12313 let mut fields = match item.content_kind {
12314 ContentKind::DraftIssue => json!({"title":title,"body":body}),
12315 ContentKind::Issue => json!({"title":title,"body":body,
12316 "stateInput":state_input(status_target)}),
12317 };
12318 // Only the members whose value differs from the item as this write read it are sent,
12319 // and nothing at all when none does — whether or not the store's cache expects a
12320 // version of it. A member left out is one GitHub leaves as it is, so a person's edit
12321 // of it since that read is kept rather than overwritten with what it already held,
12322 // and a re-copy of an unchanged item costs no mutation.
12323 if let Some(members) = fields.as_object_mut() {
12324 if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
12325 members.remove("title");
12326 }
12327 let body_unchanged = match (members.get("body"), item.raw_body.as_deref()) {
12328 (Some(Value::String(sent)), Some(held)) => sent == held,
12329 (Some(Value::Null), held) => held.is_none_or(str::is_empty),
12330 _ => false,
12331 };
12332 if body_unchanged {
12333 members.remove("body");
12334 }
12335 // An issue already in the state the target asks for keeps it: an open one stays
12336 // open, and a closed one whose closure reads as the target's category keeps its
12337 // reason — a duplicate's says more than this write would.
12338 let unchanged_state = match status_target {
12339 Some(StatusTarget::Terminal(_, reason)) => {
12340 item.closed
12341 && closed_category(item.reason.as_deref())
12342 == closed_category(Some(reason.reason()))
12343 }
12344 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => !item.closed,
12345 None => true,
12346 };
12347 if unchanged_state {
12348 members.remove("stateInput");
12349 }
12350 if members.is_empty() {
12351 return Ok(());
12352 }
12353 }
12354 self.update_content(item.content_kind, &item.id, fields)
12355 .await
12356 }
12357
12358 /// Update one board item's content with exactly `fields` beside its id, through the
12359 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
12360 /// a draft.
12361 ///
12362 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
12363 /// is what lets a narrow write carry the one thing it changes and nothing else.
12364 async fn update_content(
12365 &self,
12366 kind: ContentKind,
12367 id: &NativeId,
12368 fields: Value,
12369 ) -> Result<(), SourceError> {
12370 let (operation, id_key, pointer) = match kind {
12371 ContentKind::DraftIssue => (
12372 graphql::UPDATE_DRAFT,
12373 "draftIssueId",
12374 "/updateProjectV2DraftIssue/draftIssue",
12375 ),
12376 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
12377 };
12378 if kind == ContentKind::Issue && self.hold_issue(&fields)? {
12379 return Ok(());
12380 }
12381 if kind == ContentKind::Issue && self.hold_update(id, &fields)? {
12382 return Ok(());
12383 }
12384 let mut input = fields;
12385 input[id_key] = json!(id.0);
12386 self.send_update(operation, pointer, id, input).await
12387 }
12388
12389 /// Send one item's update, `input`, as `operation`, and hold GitHub's answer at `pointer`
12390 /// to naming that item, `id`.
12391 async fn send_update(
12392 &self,
12393 operation: &str,
12394 pointer: &str,
12395 id: &NativeId,
12396 input: Value,
12397 ) -> Result<(), SourceError> {
12398 let data = self.graphql(operation, json!({"input":input})).await?;
12399 let returned = data
12400 .pointer(pointer)
12401 .ok_or_else(|| SourceError::Malformed {
12402 message: "GitHub item update returned no item".into(),
12403 })?;
12404 if required_str(returned, "id")? != id.0 {
12405 return Err(SourceError::Malformed {
12406 message: "GitHub item update returned the wrong item".into(),
12407 });
12408 }
12409 Ok(())
12410 }
12411
12412 /// Creates one issue, files it on the board, and reports what a read of it would say:
12413 /// its content id, its board item id, and the web address GitHub gave it.
12414 ///
12415 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
12416 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
12417 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
12418 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
12419 /// "Content already exists in this project". A terminal status is not written here:
12420 /// `finish_write` selects its option first and closes the issue after, so a close never
12421 /// lands on an item whose board cannot show it.
12422 ///
12423 /// The address and the number come back here because this is the only place either is
12424 /// known before GitHub's own board read catches up — an item this run created answers
12425 /// the reads that follow it out of the record below, and one remembered without them
12426 /// would report no location and no key for the rest of the run.
12427 async fn create_and_file_issue(
12428 &self,
12429 board_id: &str,
12430 repository: &RepositoryTarget,
12431 incoming: &Incoming<'_>,
12432 body: &Option<String>,
12433 ) -> Result<Landed, SourceError> {
12434 let repository_id = self.repository_id(repository, incoming).await?;
12435 let data = self
12436 .graphql(
12437 graphql::CREATE_ISSUE,
12438 json!({"input":{
12439 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
12440 }}),
12441 )
12442 .await?;
12443 let created = data
12444 .pointer("/createIssue/issue")
12445 .filter(|value| !value.is_null())
12446 .ok_or_else(|| SourceError::Malformed {
12447 message: "GitHub issue creation returned no issue".into(),
12448 })?;
12449 let content_id = NativeId(required_str(created, "id")?.to_owned());
12450 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
12451 // a response without it is not worth failing a landed write over — the item simply
12452 // reports no location until the board read catches up, which is what it did before.
12453 let url = optional_str(created, "url")?.map(str::to_owned);
12454 // The issue exists from here on, so an unreadable number and a refused board
12455 // filing below each try, best effort, to take it back: an issue in the repository
12456 // that is on no board is an item nobody asked for and nothing here would find again.
12457 //
12458 // Its number is optional on the same terms its address is — a landed write is not
12459 // worth failing over a member that came back missing, and such an item reports no
12460 // handle until a board read catches up. A number that is *present* and is not an
12461 // unsigned integer is still a response this source cannot read.
12462 let number = match created_issue_number(created) {
12463 Ok(number) => number,
12464 Err(error) => {
12465 let _ = self.delete_issue(&content_id).await;
12466 return Err(error);
12467 }
12468 };
12469 let added = match self
12470 .graphql(
12471 graphql::ADD_TO_BOARD,
12472 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
12473 )
12474 .await
12475 {
12476 Ok(added) => added,
12477 Err(error) => {
12478 let _ = self.delete_issue(&content_id).await;
12479 return Err(error);
12480 }
12481 };
12482 let item = added
12483 .pointer("/addProjectV2ItemById/item")
12484 .filter(|value| !value.is_null())
12485 .ok_or_else(|| SourceError::Malformed {
12486 message: "GitHub board addition returned no project item".into(),
12487 })?;
12488 self.note_written(&content_id, url.as_deref());
12489 Ok(Landed {
12490 content_id,
12491 item_id: Some(required_str(item, "id")?.to_owned()),
12492 url,
12493 number,
12494 })
12495 }
12496
12497 /// Move one issue under the project it now belongs to, or out of the one it left.
12498 async fn reparent(
12499 &self,
12500 held: Option<NativeId>,
12501 child: &NativeId,
12502 wanted: Option<&NativeId>,
12503 ) -> Result<(), SourceError> {
12504 if held.as_ref() == wanted {
12505 return Ok(());
12506 }
12507 if let Some(held) = &held {
12508 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
12509 .await?;
12510 }
12511 if let Some(wanted) = wanted {
12512 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
12513 .await?;
12514 }
12515 Ok(())
12516 }
12517
12518 async fn sub_issue(
12519 &self,
12520 operation: &str,
12521 parent: &NativeId,
12522 child: &NativeId,
12523 root: &str,
12524 ) -> Result<(), SourceError> {
12525 let data = self
12526 .graphql(
12527 operation,
12528 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
12529 )
12530 .await?;
12531 let issue =
12532 data.pointer(&format!("/{root}/issue"))
12533 .ok_or_else(|| SourceError::Malformed {
12534 message: "GitHub sub-issue update returned no issue".into(),
12535 })?;
12536 let sub =
12537 data.pointer(&format!("/{root}/subIssue"))
12538 .ok_or_else(|| SourceError::Malformed {
12539 message: "GitHub sub-issue update returned no sub-issue".into(),
12540 })?;
12541 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
12542 return Err(SourceError::Malformed {
12543 message: "GitHub sub-issue update returned the wrong issues".into(),
12544 });
12545 }
12546 Ok(())
12547 }
12548
12549 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
12550 /// whether there was one.
12551 ///
12552 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
12553 /// relationships are not read: there is nothing a read of them could find.
12554 async fn reconcile_blocked_by(
12555 &self,
12556 content_id: &NativeId,
12557 native: &[String],
12558 issue: Issue<'_>,
12559 ) -> Result<bool, SourceError> {
12560 let current = match issue {
12561 Issue::Created => Vec::new(),
12562 Issue::Existing(Some(held)) => held
12563 .iter()
12564 .map(|far| required_str(far, "id").map(str::to_owned))
12565 .collect::<Result<Vec<_>, _>>()?,
12566 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
12567 };
12568 let mut changed = false;
12569 for (operation, far_id) in current
12570 .iter()
12571 .filter(|id| !native.contains(id))
12572 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
12573 .chain(
12574 native
12575 .iter()
12576 .filter(|id| !current.contains(id))
12577 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
12578 )
12579 {
12580 let data = self
12581 .graphql(
12582 operation,
12583 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
12584 )
12585 .await?;
12586 let root = if operation == graphql::ADD_BLOCKED_BY {
12587 "addBlockedBy"
12588 } else {
12589 "removeBlockedBy"
12590 };
12591 let issue =
12592 data.pointer(&format!("/{root}/issue"))
12593 .ok_or_else(|| SourceError::Malformed {
12594 message: "GitHub dependency update returned no issue".into(),
12595 })?;
12596 let blocker = data
12597 .pointer(&format!("/{root}/blockingIssue"))
12598 .ok_or_else(|| SourceError::Malformed {
12599 message: "GitHub dependency update returned no blocking issue".into(),
12600 })?;
12601 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
12602 {
12603 return Err(SourceError::Malformed {
12604 message: "GitHub dependency update returned the wrong issues".into(),
12605 });
12606 }
12607 changed = true;
12608 }
12609 Ok(changed)
12610 }
12611}
12612
12613/// The board field values one write sends, in the order GitHub runs them, and the fields it
12614/// clears after them: the origin field when it moves, the `Status` option `column` names, the
12615/// priority, then each projected field.
12616fn field_writes<'a>(
12617 existing: Option<&Resolved>,
12618 origin_field: Option<&str>,
12619 origin: &str,
12620 column: Option<(String, String)>,
12621 priority: Option<&'a PriorityWrite>,
12622 projections: &'a [ProjectionWrite],
12623) -> (Vec<(String, Value)>, Vec<&'a str>) {
12624 let mut fields = Vec::new();
12625 if let Some(field_id) = origin_field
12626 && existing.map_or(!origin.is_empty(), |item| {
12627 item.origin.as_deref().unwrap_or("") != origin
12628 })
12629 {
12630 fields.push((field_id.to_owned(), json!({"text":origin})));
12631 }
12632 if let Some((field_id, option_id)) = column {
12633 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
12634 }
12635 let mut clears = Vec::new();
12636 match priority {
12637 Some(PriorityWrite::Select { field, option }) => {
12638 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
12639 }
12640 Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
12641 None => {}
12642 }
12643 for projection in projections {
12644 projection.join(&mut fields, &mut clears);
12645 }
12646 (fields, clears)
12647}
12648
12649/// Everything one write decided before sending anything: see
12650/// [`GitHubProjectsSource::prepare_write`].
12651struct Prepared {
12652 /// What the write lands on.
12653 destination: WriteDestination,
12654 board: BoardFields,
12655 status_target: Option<StatusTarget>,
12656 /// The `Status` field, the option and the option's name the status lands on.
12657 // llmlint: ignore[invalid_states_unrepresentable] Exactly what `column_for` returns, which predates this change and four callers share; this record only carries it from `prepare_write` to `complete_write`, which a retyping of that return would reach and this change does not need.
12658 column: Option<(String, String, String)>,
12659 priority_write: Option<PriorityWrite>,
12660 projections: Vec<ProjectionWrite>,
12661 projected: BTreeMap<String, String>,
12662 content_kind: ContentKind,
12663 own_repository: Option<Repository>,
12664 /// The far ends of the item's native blocked-by relationships.
12665 // llmlint: ignore[invalid_states_unrepresentable] What `partition_edges` answers and `finish_write` takes as `&[String]`, both of which predate this change; this record only carries the value between them.
12666 native: Vec<String>,
12667 slot: BTreeMap<String, Value>,
12668 content: Option<String>,
12669 body: Option<String>,
12670 /// The copy origin the item records, empty for none.
12671 // llmlint: ignore[invalid_states_unrepresentable] `finish_write` and `field_writes` take the origin as `&str`, empty for none, as they did before this change; this record only carries it to them.
12672 origin: String,
12673 origin_field: Option<String>,
12674}
12675
12676/// One write a copy held back of the kinds sent together: a board field value, or a blocked-by
12677/// edge, each its input.
12678enum HeldWrite<'a> {
12679 Field(&'a Value),
12680 Edge(&'a Value),
12681}
12682
12683/// Whether `error` is GitHub refusing to file an issue on a board because it is filed there
12684/// already — what `addProjectV2ItemById` answers once `createIssue`'s `projectV2Ids` has placed
12685/// it, rather than answering with the item it holds.
12686fn already_on_the_board(error: &SourceError) -> bool {
12687 error
12688 .to_string()
12689 .contains("Content already exists in this project")
12690}
12691
12692/// Hold every alias `inputs` sent under `prefix` in `data` to answering with what its input
12693/// named — each `(root, named)` of `answers` — and refuse one that answered nothing or another
12694/// item.
12695fn check_aliased_answers(
12696 document: &str,
12697 data: &Value,
12698 prefix: &str,
12699 inputs: &[&Value],
12700 answers: &[(&str, &str)],
12701) -> Result<(), SourceError> {
12702 for (slot, input) in inputs.iter().enumerate() {
12703 for (root, named) in answers {
12704 let answered = data
12705 .pointer(&format!("/{prefix}{slot}/{root}/id"))
12706 .and_then(Value::as_str)
12707 .ok_or_else(|| SourceError::Malformed {
12708 message: format!(
12709 "GitHub answered {} with nothing at {prefix}{slot}",
12710 operation_description(document)
12711 ),
12712 })?;
12713 let asked = input.get(named).and_then(Value::as_str).unwrap_or_default();
12714 if answered != asked {
12715 return Err(SourceError::Malformed {
12716 message: format!(
12717 "GitHub answered {} at {prefix}{slot} with {answered}, which is not \
12718 {asked}, the item it was asked to write",
12719 operation_description(document)
12720 ),
12721 });
12722 }
12723 }
12724 }
12725 Ok(())
12726}
12727
12728/// `error`, which stopped a batch of creates, and — when GitHub refused to take any of them
12729/// back — each issue of `left` it created that is still in the repository, which its caller,
12730/// never handed their ids, could not name.
12731fn left_behind(error: SourceError, left: &[&str]) -> SourceError {
12732 if left.is_empty() {
12733 return error;
12734 }
12735 SourceError::Refused {
12736 message: format!(
12737 "{error}; GitHub then refused to take back {}, which this write created and which \
12738 are still in the repository; next: delete {} before writing again",
12739 left.join(", "),
12740 if left.len() == 1 { "it" } else { "them" }
12741 ),
12742 }
12743}
12744
12745/// What one write lands on: the item it updates, as it stood, or where it creates a new one.
12746enum WriteDestination {
12747 /// The item as it stood, for an update.
12748 Existing(Box<Resolved>),
12749 /// Where a new item is created.
12750 New(RepositoryTarget),
12751}
12752
12753impl WriteDestination {
12754 /// The item an update writes over, as it stood; `None` for a create.
12755 fn existing(&self) -> Option<&Resolved> {
12756 match self {
12757 Self::Existing(item) => Some(item),
12758 Self::New(_) => None,
12759 }
12760 }
12761}
12762
12763/// One existing issue's update held back by [`GitHubProjectsSource::hold_update`]: the issue,
12764/// and the `UpdateIssueInput` its own [`graphql::UPDATE_ISSUE`] would have sent — the members
12765/// it writes beside that issue's id, which only [`Self::new`] sets.
12766struct HeldUpdate {
12767 issue: NativeId,
12768 // llmlint: ignore[invalid_states_unrepresentable] The members are exactly what `update_content` would have sent at once, which every one of its callers builds as GraphQL variables; holding them back changes when they are sent, not what they may be, and only `new` builds one, fixing the issue's id. Typing `UpdateIssueInput` would retype every caller of `update_content`, a change of its own.
12769 input: Value,
12770}
12771
12772impl HeldUpdate {
12773 /// The update writing `members` — an `UpdateIssueInput` without its id — to `issue`.
12774 fn new(issue: &NativeId, members: &Value) -> Self {
12775 let mut input = members.clone();
12776 input["id"] = json!(issue.0);
12777 Self {
12778 issue: issue.clone(),
12779 input,
12780 }
12781 }
12782}
12783
12784/// What a copy has held back of the items it created; see
12785/// [`GitHubProjectsSource::hold_back`].
12786#[derive(Debug, Default)]
12787enum Deferred {
12788 /// Writes are being held back, and nothing has been held yet.
12789 #[default]
12790 Nothing,
12791 /// The items held back, every one of them on `board`.
12792 Held {
12793 /// The board every one of them is on.
12794 board: BoardId,
12795 /// Each created item, in the order it was created.
12796 created: Vec<HeldBack>,
12797 },
12798}
12799
12800impl Deferred {
12801 /// Whether `content` is one of the items held back.
12802 fn holds(&self, content: &NativeId) -> bool {
12803 match self {
12804 Self::Nothing => false,
12805 Self::Held { created, .. } => created.iter().any(|held| held.content == *content),
12806 }
12807 }
12808}
12809
12810/// One created item's parts that land once [`TaskSource::finish_writes`] is called.
12811#[derive(Debug)]
12812struct HeldBack {
12813 /// The issue.
12814 content: NativeId,
12815 /// Its board field values other than its `Status`, in the order they are written.
12816 fields: Vec<(String, Value)>,
12817 /// The `Status` field, and the option and its name that the item's status lands on.
12818 // llmlint: ignore[invalid_states_unrepresentable] The same triple `column_for` returns and `Prepared::column` carries, held back unchanged until it is sent.
12819 status: Option<(String, String, String)>,
12820 /// The fields it clears after those are written.
12821 clears: Vec<String>,
12822 /// The reason it is closed for, once its option is selected; `None` leaves it open.
12823 close: Option<ClosedState>,
12824 /// The issues it is blocked by.
12825 edges: Vec<NativeId>,
12826}
12827
12828/// The member [`answer_with_errors`] carries a response's GraphQL errors under, beside its data:
12829/// no GitHub type has a field of this name.
12830const ANSWER_ERRORS: &str = "onetaskgraphErrors";
12831
12832/// A response's `data` with its GraphQL errors beside it under [`ANSWER_ERRORS`], when it has
12833/// both — or `None`, for an answer [`GitHubProjectsSource::answer`] reads as it always has.
12834///
12835/// What [`graphql::CREATE_ISSUES`] is answered with: GitHub runs a document's mutation fields in
12836/// order and answers each one that ran, so a refusal of one alias leaves the issues the others
12837/// created named in the data beside it — the ids a copy needs to take them back.
12838fn answer_with_errors(body: &str) -> Option<Value> {
12839 let parsed: Value = serde_json::from_str(body).ok()?;
12840 let errors = parsed
12841 .get("errors")?
12842 .as_array()
12843 .filter(|errors| !errors.is_empty())?;
12844 let mut data = parsed.get("data").filter(|data| data.is_object())?.clone();
12845 data[ANSWER_ERRORS] = Value::Array(errors.clone());
12846 Some(data)
12847}
12848
12849/// The `createIssue` input that files the new issue on `board` and, when `incoming` names one,
12850/// under its project — see [`GitHubProjectsSource::create_filed_issue`].
12851fn filed_input(
12852 repository: &str,
12853 board: &str,
12854 incoming: &Incoming<'_>,
12855 body: &Option<String>,
12856) -> Value {
12857 let mut input = json!({"repositoryId":repository,"title":incoming.written_title(),
12858 "body":body,"projectV2Ids":[board]});
12859 if let Some(parent) = incoming.parent {
12860 input["parentIssueId"] = json!(parent.0);
12861 }
12862 input
12863}
12864
12865/// The name of option `option` of field `field` among a board's `fields`.
12866fn option_name<'a>(fields: &'a Value, field: &str, option: &str) -> Option<&'a str> {
12867 fields
12868 .get("nodes")?
12869 .as_array()?
12870 .iter()
12871 .find(|node| node.get("id").and_then(Value::as_str) == Some(field))?
12872 .get("options")?
12873 .as_array()?
12874 .iter()
12875 .find(|candidate| candidate.get("id").and_then(Value::as_str) == Some(option))?
12876 .get("name")?
12877 .as_str()
12878}
12879
12880/// What a write needs to know of one far end it names: which kind of item it is, and whether
12881/// it is an issue a native relationship can name.
12882struct FarEnd {
12883 kind: BoardKind,
12884 content_kind: ContentKind,
12885}
12886
12887/// Whether the issue one write reconciles was created by that write or was already there.
12888#[derive(Clone, Copy, PartialEq, Eq)]
12889enum Issue<'a> {
12890 /// Created by this write, so it holds no relationships yet.
12891 Created,
12892 /// On the board before this write, holding whatever relationships it holds — the far
12893 /// ends of its whole `blockedBy`, when the read that reached it carried them.
12894 Existing(Option<&'a [Value]>),
12895}
12896
12897/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
12898enum Reached {
12899 /// An issue this board holds, resolved into everything this source reports about it.
12900 Held(Box<Resolved>),
12901 /// Nothing this board holds: no such node, or a node on some other board.
12902 Nothing,
12903 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
12904 /// again by [`GitHubProjectsSource::draft_by_id`].
12905 Draft,
12906}
12907
12908/// What GitHub says when a string is not a node id it can resolve.
12909///
12910/// Matched because it is the ordinary answer to a project selector naming a project by its
12911/// *name*, and reporting that as a failure would make naming one impossible. It is read
12912/// off the refusal GitHub sent, never guessed from the shape of the string: this source
12913/// does not define the syntax of a GitHub node id and would be wrong about it.
12914const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
12915
12916/// `error` with `note` added to the end of what it says, its kind and every other member
12917/// unchanged — so a caller still branches on the failure that happened, and reads beside it
12918/// what that failure left behind.
12919fn noting(error: SourceError, note: &str) -> SourceError {
12920 match error {
12921 SourceError::Config { message } => SourceError::Config {
12922 message: message + note,
12923 },
12924 SourceError::Auth { message } => SourceError::Auth {
12925 message: message + note,
12926 },
12927 SourceError::Refused { message } => SourceError::Refused {
12928 message: message + note,
12929 },
12930 SourceError::RateLimited {
12931 retry_after_seconds,
12932 message,
12933 } => SourceError::RateLimited {
12934 retry_after_seconds,
12935 message: Some(message.unwrap_or_default() + note),
12936 },
12937 SourceError::Unavailable { message } => SourceError::Unavailable {
12938 message: message + note,
12939 },
12940 SourceError::Malformed { message } => SourceError::Malformed {
12941 message: message + note,
12942 },
12943 }
12944}
12945
12946/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
12947/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
12948fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest, children: bool) -> Value {
12949 let mut variables = serde_json::Map::new();
12950 for slot in 0..COPY_DETAIL_BATCH {
12951 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12952 variables.insert(format!("id{slot}"), json!(id));
12953 }
12954 variables.insert("project".to_owned(), json!(batch.first().map(|id| &id.0)));
12955 variables.insert("children".to_owned(), json!(children));
12956 variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
12957 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12958 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12959 variables.insert("duplicates".to_owned(), json!(true));
12960 Value::Object(variables)
12961}
12962
12963/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
12964/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
12965/// for them.
12966///
12967/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
12968/// is read again at no added price.
12969fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
12970 let mut variables = serde_json::Map::new();
12971 for slot in 0..DETAIL_BATCH {
12972 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
12973 variables.insert(format!("id{slot}"), json!(id));
12974 }
12975 variables.insert(
12976 "first".to_owned(),
12977 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
12978 );
12979 variables.insert("comments".to_owned(), json!(comments.is_some()));
12980 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
12981 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
12982 variables.insert("duplicates".to_owned(), json!(true));
12983 Value::Object(variables)
12984}
12985
12986/// Whether this refusal is GitHub saying the id names no node at all.
12987fn unresolvable_node(error: &SourceError) -> bool {
12988 matches!(error, SourceError::Refused { message }
12989 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
12990}
12991
12992/// One project name, as a search qualifier which filters on it at the server.
12993///
12994/// Quoted so the whole title is one phrase rather than a bag of words, with the two
12995/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
12996/// the way it documents. A title matched here is still compared for equality afterwards:
12997/// the qualifier narrows what the server sends, and this source decides what it names.
12998fn title_qualifier(name: &str) -> String {
12999 format!("in:title {}", quoted(name))
13000}
13001
13002/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
13003/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
13004/// it documents — so a value holding a qualifier's spelling is searched for rather than
13005/// obeyed.
13006fn quoted(value: &str) -> String {
13007 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
13008 format!("\"{escaped}\"")
13009}
13010
13011/// The search qualifier for the issues updated at or after `since`.
13012///
13013/// Written to the second, rounded down, which can only widen what the search returns.
13014fn updated_qualifier(since: DateTime<Utc>) -> String {
13015 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
13016}
13017
13018/// The search terms that narrow a board-scoped issue search to a task query's text and
13019/// metadata predicates, or `None` when it carries neither.
13020///
13021/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
13022/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
13023/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
13024/// matches each in any field the `in:` qualifier names, so a query naming a title search and
13025/// a metadata value searches both fields for both — wider than asked, never narrower, and
13026/// every candidate is confirmed in process afterwards.
13027///
13028/// **This narrows a text search, and that is this source's declared semantics.** GitHub
13029/// matches whole tokens where a substring rule would match inside a word, so an item holding
13030/// the text only inside a longer word is not returned. A text of nothing but whitespace
13031/// matches every item, so it narrows nothing and is not sent.
13032fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
13033 let text = query
13034 .text
13035 .as_ref()
13036 .filter(|text| !text.terms.trim().is_empty());
13037 if text.is_none() && query.metadata.is_empty() {
13038 return None;
13039 }
13040 let (title, body) = match text.map(|text| text.fields) {
13041 None => (false, true),
13042 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
13043 Some(TextFields::Content) => (false, true),
13044 Some(TextFields::TitleOrContent) => (true, true),
13045 };
13046 let fields = match (title, body) {
13047 (true, true) => "in:title,body",
13048 (true, false) => "in:title",
13049 _ => "in:body",
13050 };
13051 let phrases = text
13052 .map(|text| text.terms.clone())
13053 .into_iter()
13054 .chain(
13055 query
13056 .metadata
13057 .iter()
13058 .map(|wanted| as_stored(wanted.value())),
13059 )
13060 .map(|phrase| quoted(&phrase))
13061 .collect::<Vec<_>>();
13062 Some(format!("{fields} {}", phrases.join(" ")))
13063}
13064
13065/// Whether every status category `query` keeps reads only off an open issue or a draft: a
13066/// closed issue reads `done`, `cancelled` or — closed for a reason that implies no category —
13067/// `unknown`, so a status filter naming none of those three can ask every issue search it sends
13068/// for open issues alone; each candidate's category is still confirmed from its own item.
13069fn open_only(query: &TaskQuery) -> bool {
13070 !query.statuses.is_empty()
13071 && query.statuses.iter().all(|category| {
13072 !matches!(
13073 category,
13074 StatusCategory::Done | StatusCategory::Cancelled | StatusCategory::Unknown
13075 )
13076 })
13077}
13078
13079/// What a task query is narrowed by beside a board-scoped issue search's scope — its comment
13080/// activity and its text and metadata, with `is:open` when [`open_only`] — or `None` for a query
13081/// carrying none of those, which no narrowed search answers.
13082///
13083/// A narrowed search never answers a draft, which is no issue, so narrowing it to open issues
13084/// too leaves out nothing such a search could have answered.
13085fn search_qualifiers(query: &TaskQuery) -> Option<String> {
13086 let mut qualifiers: Vec<String> = [
13087 query.commented_since.map(updated_qualifier),
13088 narrowing_qualifiers(query),
13089 ]
13090 .into_iter()
13091 .flatten()
13092 .collect();
13093 if qualifiers.is_empty() {
13094 return None;
13095 }
13096 if open_only(query) {
13097 qualifiers.push(OPEN_QUALIFIER.to_owned());
13098 }
13099 Some(qualifiers.join(" "))
13100}
13101
13102/// The search terms that narrow a board-scoped issue search to a project or document query's
13103/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
13104/// carrying that text alone is sent as by [`narrowing_qualifiers`].
13105fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
13106 narrowing_qualifiers(&TaskQuery {
13107 text: text.cloned(),
13108 ..TaskQuery::default()
13109 })
13110}
13111
13112/// Refuses a project or document query's text GitHub's issue search cannot find, before
13113/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
13114/// query's.
13115fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
13116 refuse_unsearchable(&TaskQuery {
13117 text: text.cloned(),
13118 ..TaskQuery::default()
13119 })
13120}
13121
13122/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
13123/// before anything is asked of GitHub.
13124///
13125/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
13126/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
13127/// left out, the search is every issue of the board. So this source says it cannot answer
13128/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
13129/// nothing GitHub could search for, and keeps the board read it always had.
13130fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
13131 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
13132 letter or digit with a bounded query";
13133 if let Some(text) = &query.text
13134 && !text.terms.trim().is_empty()
13135 && !has_words(&text.terms)
13136 {
13137 return Err(SourceError::Refused {
13138 message: format!(
13139 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
13140 text.terms
13141 ),
13142 });
13143 }
13144 if let Some(wanted) = query
13145 .metadata
13146 .iter()
13147 .find(|wanted| !has_words(wanted.value()))
13148 {
13149 return Err(SourceError::Refused {
13150 message: format!(
13151 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
13152 wanted.value(),
13153 std::iter::once(wanted.key())
13154 .chain(wanted.path().iter().map(String::as_str))
13155 .collect::<Vec<_>>()
13156 .join("/"),
13157 ),
13158 });
13159 }
13160 Ok(())
13161}
13162
13163/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
13164fn has_words(phrase: &str) -> bool {
13165 phrase.chars().any(char::is_alphanumeric)
13166}
13167
13168/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
13169///
13170/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
13171/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
13172/// which GitHub's word match would read as different words.
13173fn as_stored(value: &str) -> String {
13174 let encoded = Value::String(value.to_owned()).to_string();
13175 encoded[1..encoded.len() - 1].to_owned()
13176}
13177
13178/// The one narrower question a task query carrying a text, metadata or origin predicate is
13179/// sent as.
13180enum Narrowing {
13181 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
13182 Origin(String),
13183 /// The board-scoped issue search narrowed by these qualifiers.
13184 Search(String),
13185}
13186
13187impl Narrowing {
13188 /// What this question is remembered under for the length of one command.
13189 fn key(&self) -> String {
13190 match self {
13191 Self::Origin(origin) => format!("origin {origin}"),
13192 Self::Search(also) => format!("search {also}"),
13193 }
13194 }
13195}
13196
13197/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
13198enum Resumed {
13199 /// It reported another page, which starts after this cursor.
13200 More(String),
13201 /// It has ended. Sending this cursor again — the page's own end when it had one, and
13202 /// otherwise the cursor it was reached from — answers an empty page, so the one document
13203 /// can go on walking the other connection.
13204 Ended(Option<String>),
13205}
13206
13207impl Resumed {
13208 /// Whether the connection has another page.
13209 const fn has_more(&self) -> bool {
13210 matches!(self, Self::More(_))
13211 }
13212
13213 /// The cursor to send this connection next.
13214 fn cursor(self) -> Option<String> {
13215 match self {
13216 Self::More(next) => Some(next),
13217 Self::Ended(last) => last,
13218 }
13219 }
13220}
13221
13222/// Where `connection`, reached from `after`, resumes — refused when it reports another page
13223/// with no cursor to it, or from a cursor that does not advance.
13224fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
13225 let info = connection
13226 .get("pageInfo")
13227 .ok_or_else(|| SourceError::Malformed {
13228 message: "GitHub connection has no pageInfo".into(),
13229 })?;
13230 let end = optional_str(info, "endCursor")?;
13231 if required_bool(info, "hasNextPage")? {
13232 let next = end.ok_or_else(|| SourceError::Malformed {
13233 message: "GitHub connection reports another page and no endCursor".into(),
13234 })?;
13235 validate_cursor_progress(after, next)?;
13236 return Ok(Resumed::More(next.to_owned()));
13237 }
13238 Ok(Resumed::Ended(
13239 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
13240 ))
13241}
13242
13243/// The board, and every item on it this source reports.
13244#[derive(Clone)]
13245struct Board {
13246 id: String,
13247 fields: Value,
13248 items: Vec<Resolved>,
13249}
13250
13251/// What a write needs of the board and nothing more: its node id and its field
13252/// definitions, in the shape a read of the board's own `fields` gives them.
13253///
13254/// Deliberately no items. A write decides which item it writes, which parent it files
13255/// under and which far ends it names by reading each of them by its own id; this is the
13256/// half of the board those reads cannot carry, and holding no item is what keeps it from
13257/// ever being asked whether an item is there.
13258#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13259struct BoardFields {
13260 id: BoardId,
13261 fields: Value,
13262}
13263
13264/// A board's node id: what a field write and `addProjectV2ItemById` address.
13265///
13266/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
13267/// refused where it is read, and one an item names blank is read as not named at all.
13268#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
13269#[serde(try_from = "String")]
13270struct BoardId(String);
13271
13272impl TryFrom<String> for BoardId {
13273 type Error = String;
13274
13275 /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
13276 fn try_from(id: String) -> Result<Self, Self::Error> {
13277 Self::parse(&id).map_err(|error| error.to_string())
13278 }
13279}
13280
13281/// Where one write left its item, for the record the rest of the command reads it out of.
13282///
13283/// A named record rather than a tuple because the update arm and the create arm each fill
13284/// all four, and two `Option`s of different meaning side by side in a tuple are two
13285/// positions a reader has to count.
13286struct Landed {
13287 /// The issue's own node id, which is the [`NativeId`] this source reports.
13288 content_id: NativeId,
13289 /// The board item's id, which is what a field write addresses — `None` for an issue its
13290 /// create filed on the board itself, whose board item GitHub adds after answering and
13291 /// [`TaskSource::finish_writes`] reads.
13292 // 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.
13293 item_id: Option<String>,
13294 /// The web address GitHub gave the issue, when it gave one.
13295 // 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.
13296 url: Option<String>,
13297 /// The issue's number on its repository, when GitHub reported one.
13298 number: Option<u64>,
13299}
13300
13301impl BoardId {
13302 fn parse(id: &str) -> Result<Self, SourceError> {
13303 if id.trim().is_empty() {
13304 return Err(SourceError::Malformed {
13305 message: "GitHub named a board with a blank node id".into(),
13306 });
13307 }
13308 Ok(Self(id.to_owned()))
13309 }
13310
13311 fn as_str(&self) -> &str {
13312 &self.0
13313 }
13314}
13315
13316impl Board {
13317 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
13318 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
13319 let nodes = fields
13320 .get("nodes")
13321 .and_then(Value::as_array)
13322 .ok_or_else(|| SourceError::Malformed {
13323 message: "GitHub project fields.nodes is not an array".into(),
13324 })?;
13325 Ok(nodes
13326 .iter()
13327 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
13328 }
13329}
13330
13331/// One board item, resolved into everything this source reports about it.
13332///
13333/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
13334/// [`TaskSource::prime_write`] takes back — see [`expected`].
13335#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
13336struct Resolved {
13337 item_id: String,
13338 id: NativeId,
13339 content_kind: ContentKind,
13340 kind: BoardKind,
13341 title: String,
13342 body: Option<String>,
13343 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
13344 /// that changes the slot alone has to keep byte for byte outside it.
13345 raw_body: Option<String>,
13346 status: Status,
13347 /// The name of the board `Status` option this item sits in, as the board spells it.
13348 option: Option<String>,
13349 /// What its `Priority` field says, read through this instance's mapping.
13350 priority: HeldPriority,
13351 /// Whether this item's issue is closed. A draft has no such state and is never closed.
13352 closed: bool,
13353 /// GitHub's reason for a closed issue's closure, as it spells it — `None` for an open
13354 /// issue, a draft, and an issue closed before GitHub recorded reasons.
13355 #[serde(default)]
13356 reason: Option<String>,
13357 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
13358 delivers: Vec<TaskRef>,
13359 /// Every task that delivers this one, read out of its slot. Empty for anything not a
13360 /// task.
13361 delivered_by: Vec<TaskRef>,
13362 labels: Vec<Label>,
13363 parent: Option<NativeId>,
13364 /// The issue's GitHub parent on a source reading sub-issues as children (`sub_issues:
13365 /// parent`), where [`Self::parent`] — the project it is filed under — is always `None`.
13366 #[serde(default)]
13367 issue_parent: Option<NativeId>,
13368 // 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.
13369 origin: Option<String>,
13370 /// What each board text field this instance projects metadata onto holds for this item,
13371 /// by field name — only the fields holding a value, an empty text included. What a write compares the
13372 /// item's metadata against, so a field already holding its value is not written again;
13373 /// never read back as metadata, which the slot alone reports.
13374 projected: BTreeMap<String, String>,
13375 /// The issue's own number on its repository, as GitHub reports it.
13376 ///
13377 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
13378 /// declares none, and a draft is not filed in a repository to be numbered by one — and
13379 /// an issue this run created whose creating mutation answered without one, which is a
13380 /// response GitHub's own schema says cannot happen and which a landed write is not
13381 /// worth failing over. An `Issue` read off the board always has one.
13382 number: Option<u64>,
13383 url: Option<String>,
13384 created_at: Option<DateTime<Utc>>,
13385 updated_at: Option<DateTime<Utc>>,
13386 /// When the board item itself last changed — what a board field write moves — when the
13387 /// read that reached it said.
13388 item_updated_at: Option<DateTime<Utc>>,
13389 /// How many comments the issue holds, when the read that reached it said.
13390 comment_count: Option<u64>,
13391 /// How many relationship events the issue's timeline holds — every dependency, as
13392 /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
13393 /// removed — when the read that reached it said.
13394 relationship_events: Option<u64>,
13395 /// The server's clock on the response that read it — its `Date` header — when the
13396 /// response carried one.
13397 served_at: Option<DateTime<Utc>>,
13398 /// The digest of its [`change_token::Facets`] — its own kind, its board field values and
13399 /// its labels — when the read that reached it carried all three whole.
13400 #[serde(default)]
13401 facets: Option<change_token::FacetsDigest>,
13402 own_repository: Option<Repository>,
13403 repositories: Vec<Repository>,
13404 classification: Classification,
13405 slot: BTreeMap<String, Value>,
13406 /// The node id of the board this item sits on, when the read that reached it said.
13407 board_id: Option<String>,
13408 /// The definition of every board field this item holds a value of, in the shape a read
13409 /// of the board's own `fields` gives one.
13410 ///
13411 /// Only the fields this item has a value in: a field it holds nothing of is not here,
13412 /// which says nothing about whether the board has it.
13413 fields: Vec<Value>,
13414 /// Every field the board this item sits on defines, as its own read of the board's
13415 /// `fields` gives them — when the read that reached the item carried them, which a read
13416 /// of it by its own id does. What a write of it needs of the board, then, needs no read
13417 /// of the board.
13418 board_fields: Option<Value>,
13419 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
13420 /// selects one — when the read that reached it carried the connection to its end, which a
13421 /// read of it by its own id does for any issue blocked by no more than a page. What a
13422 /// write reconciles that relationship against, and what a read of its forward edges in
13423 /// the same command answers with.
13424 blocked_by: Option<Vec<Value>>,
13425}
13426
13427impl Resolved {
13428 /// The board this item's own read names it on, when that read named one this source can
13429 /// address.
13430 fn named_board(&self) -> Option<BoardId> {
13431 self.board_id
13432 .as_deref()
13433 .and_then(|id| BoardId::parse(id).ok())
13434 }
13435
13436 /// The board's id and every field it defines, when the read that reached this item
13437 /// carried both — which a read of it by its own id does.
13438 fn carried_board(&self) -> Option<BoardFields> {
13439 Some(BoardFields {
13440 id: self.named_board()?,
13441 fields: self.board_fields.clone()?,
13442 })
13443 }
13444
13445 /// Whether this item holds a value of the board field called `name`, and so carries
13446 /// that field's definition. `false` says nothing about whether the board has the field.
13447 fn defines(&self, name: &str) -> bool {
13448 self.fields
13449 .iter()
13450 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
13451 }
13452
13453 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
13454 /// in a field of its own, and none of the five keys that are only an encoding.
13455 ///
13456 /// The two delivery keys are left out for every kind, not only for a task: they are
13457 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
13458 /// document carrying one holds nothing a caller's own metadata could mean by it.
13459 fn metadata(&self) -> BTreeMap<String, Value> {
13460 let mut metadata = self.slot.clone();
13461 metadata.remove(Repository::METADATA_KEY);
13462 metadata.remove(DependencyEdge::RECORDED_KEY);
13463 metadata.remove(ItemKind::METADATA_KEY);
13464 metadata.remove(TaskRef::DELIVERS_KEY);
13465 metadata.remove(TaskRef::DELIVERED_BY_KEY);
13466 metadata.remove(Classification::METADATA_KEY);
13467 // The board field is the origin, and the body's copy of it is only a mirror for the
13468 // issue search to find: an item whose field holds none has none, whatever its body
13469 // says, so no reader ever sees two answers.
13470 metadata.remove(ORIGIN_KEY);
13471 if let Some(origin) = &self.origin {
13472 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
13473 }
13474 metadata
13475 }
13476
13477 /// Where this item is, as a link a reader can open.
13478 ///
13479 /// A board is a hosted place and every issue on it has a web address, so that address
13480 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
13481 /// of place it is, so a reader knows to open it rather than to read a file out. It
13482 /// does not replace or derive from `url`: the field goes on reporting exactly what it
13483 /// reported before, and this says what that address *is*.
13484 ///
13485 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
13486 /// rather than a third variant, which is the contract's "the source did not say". An
13487 /// issue this run created is not one of those: its address comes back from the
13488 /// creating mutation, so it is somewhere a reader can open from the moment it exists
13489 /// rather than from whenever the board read catches up.
13490 fn location(&self) -> Option<Location> {
13491 self.url.clone().map(Location::Url)
13492 }
13493
13494 /// The short handle this board's backend shows people for a task: the issue's number
13495 /// alone, as a decimal string.
13496 ///
13497 /// The number alone rather than `owner/repo#1043`, because that is the contract's
13498 /// value for this backend. A draft has no number and so no handle, which is the
13499 /// contract's *absent* rather than a handle of some other shape — and the native
13500 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
13501 /// derives from.
13502 fn key(&self) -> Option<String> {
13503 self.number.map(|number| number.to_string())
13504 }
13505
13506 /// This item's change token — see [`change_token`] — or `None` when the read that
13507 /// reached it did not carry all six of its parts, which a draft never does, did not say
13508 /// when GitHub served it, or found it changed too recently to have settled.
13509 fn version(&self) -> Option<String> {
13510 let parts = self.token_parts()?;
13511 change_token::settled(parts.issue, parts.item, self.served_at?)
13512 .then(|| change_token::spell(&parts))
13513 }
13514
13515 /// The change token this item, read just after a write of it, holds now — whether or not it
13516 /// has settled: what a written task's `version` answers (see
13517 /// [`GitHubProjectsSource::written_tokens`]), spelled from the parts a later read spells its
13518 /// token from.
13519 fn written_version(&self) -> Option<String> {
13520 self.token_parts().map(|parts| change_token::spell(&parts))
13521 }
13522
13523 /// Every part of this item's change token, or `None` when the read that reached it did not
13524 /// carry them all — a draft never does.
13525 fn token_parts(&self) -> Option<change_token::Parts> {
13526 match self.content_kind {
13527 ContentKind::Issue => Some(change_token::Parts {
13528 issue: self.updated_at?,
13529 item: self.item_updated_at?,
13530 comments: self.comment_count?,
13531 relationships: self.relationship_events?,
13532 facets: self.facets.clone()?,
13533 repository: self.own_repository.clone()?,
13534 }),
13535 ContentKind::DraftIssue => None,
13536 }
13537 }
13538
13539 /// Whether its `Priority` field holds a value at all, mapped or not.
13540 fn holds_priority(&self) -> bool {
13541 self.priority != HeldPriority::Read(Priority::None)
13542 }
13543
13544 /// The task this item is.
13545 ///
13546 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
13547 /// reading that as a level would be a guess, and reading it as `none` would let the next
13548 /// copy clear a priority a person set.
13549 fn task(&self) -> Result<Task, SourceError> {
13550 let priority = match &self.priority {
13551 HeldPriority::Read(priority) => *priority,
13552 HeldPriority::Unmapped(option) => {
13553 return Err(SourceError::Malformed {
13554 message: format!(
13555 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
13556 this source's priority_mapping does not name, so its priority cannot be \
13557 read; next: name {option:?} under priority_mapping, or move the item to \
13558 a mapped option",
13559 self.id,
13560 self.number
13561 .map(|number| format!(" (#{number})"))
13562 .unwrap_or_default()
13563 ),
13564 });
13565 }
13566 };
13567 let metadata = self.metadata();
13568 Ok(Task {
13569 version: self.version(),
13570 id: self.id.clone(),
13571 key: self.key(),
13572 title: self.title.clone(),
13573 // The `## Links` section the engine rendered is stripped, so content round-trips.
13574 content: onetaskgraph_plugin_api::read_content(self.body.clone(), &metadata),
13575 status: self.status.clone(),
13576 priority,
13577 labels: self.labels.clone(),
13578 project: self.parent.clone(),
13579 url: self.url.clone(),
13580 location: self.location(),
13581 created_at: self.created_at,
13582 updated_at: self.updated_at,
13583 metadata,
13584 repositories: self.repositories.clone(),
13585 delivers: self.delivers.clone(),
13586 delivered_by: self.delivered_by.clone(),
13587 classification: self.classification,
13588 })
13589 }
13590
13591 fn project(&self) -> Project {
13592 Project {
13593 version: self.version(),
13594 id: self.id.clone(),
13595 title: self.title.clone(),
13596 content: self.body.clone(),
13597 status: self.status.clone(),
13598 labels: self.labels.clone(),
13599 url: self.url.clone(),
13600 location: self.location(),
13601 created_at: self.created_at,
13602 updated_at: self.updated_at,
13603 metadata: self.metadata(),
13604 repositories: self.repositories.clone(),
13605 classification: self.classification,
13606 }
13607 }
13608
13609 /// The same issue as a document: the project it is filed under, and no status and no
13610 /// dependencies, because a document is not work.
13611 fn document(&self) -> Document {
13612 Document {
13613 version: self.version(),
13614 id: self.id.clone(),
13615 title: self.title.clone(),
13616 content: self.body.clone(),
13617 project: self.parent.clone(),
13618 labels: self.labels.clone(),
13619 url: self.url.clone(),
13620 location: self.location(),
13621 created_at: self.created_at,
13622 updated_at: self.updated_at,
13623 metadata: self.metadata(),
13624 repositories: self.repositories.clone(),
13625 classification: self.classification,
13626 }
13627 }
13628}
13629
13630/// Where one targeted update moves an item's status, and which of its two halves move.
13631struct StatusMove {
13632 /// The board the item's `Status` field is on.
13633 board: BoardId,
13634 /// The `Status` field's id.
13635 field: String,
13636 /// The option's id.
13637 option: String,
13638 /// The option's name, as the board spells it.
13639 name: String,
13640 /// What the status asks of the issue's state.
13641 target: StatusTarget,
13642 /// The status the item reads as once it is there.
13643 landed: Status,
13644 /// Which of the status's two halves differ from what the item holds.
13645 moves: Moves,
13646}
13647
13648/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
13649/// closed state of its issue, or both. A status neither half of which differs is no move at all,
13650/// and is not a value of this type.
13651#[derive(Clone, Copy, PartialEq, Eq)]
13652enum Moves {
13653 /// The option alone.
13654 Option,
13655 /// The issue's state alone: open, closed, or closed with another reason.
13656 State,
13657 /// Both.
13658 Both,
13659}
13660
13661impl Moves {
13662 /// What differs, or `None` when nothing does.
13663 const fn of(option: bool, state: bool) -> Option<Self> {
13664 match (option, state) {
13665 (true, true) => Some(Self::Both),
13666 (true, false) => Some(Self::Option),
13667 (false, true) => Some(Self::State),
13668 (false, false) => None,
13669 }
13670 }
13671
13672 /// Whether the option moves.
13673 const fn option(self) -> bool {
13674 matches!(self, Self::Option | Self::Both)
13675 }
13676
13677 /// Whether the issue's state moves.
13678 const fn state(self) -> bool {
13679 matches!(self, Self::State | Self::Both)
13680 }
13681}
13682
13683/// What one write is, and the status that comes with being it.
13684///
13685/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
13686/// status and a task or a project always has one, so "a document carrying a status" and
13687/// "a task carrying none" are states a write cannot be in rather than states every use
13688/// site below has to defend against.
13689enum Written<'a> {
13690 /// A document, which is not work and so has no status at all.
13691 Document,
13692 /// A task or a project, and the status it is being written with.
13693 Work(ItemKind, &'a Status),
13694}
13695
13696impl Written<'_> {
13697 /// Which of the board's three kinds this write is.
13698 const fn kind(&self) -> BoardKind {
13699 match self {
13700 Self::Document => BoardKind::Document,
13701 Self::Work(kind, _) => BoardKind::Work(*kind),
13702 }
13703 }
13704
13705 /// The status this write carries. A document carries none, so a write of one says
13706 /// nothing about the issue's open or closed state and selects no board `Status`
13707 /// option.
13708 const fn status(&self) -> Option<&Status> {
13709 match self {
13710 Self::Document => None,
13711 Self::Work(_, status) => Some(status),
13712 }
13713 }
13714
13715 /// The status this write carries with the kind whose half of `status_mapping` it is
13716 /// written through.
13717 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
13718 match self {
13719 Self::Document => None,
13720 Self::Work(kind, status) => Some((*kind, status)),
13721 }
13722 }
13723}
13724
13725/// The item being written, in the one shape all three write methods reach.
13726struct Incoming<'a> {
13727 written: Written<'a>,
13728 /// The title a person wrote. A document's goes onto the issue with
13729 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
13730 title: &'a str,
13731 content: Option<&'a str>,
13732 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
13733 labels: &'a [Label],
13734 metadata: &'a BTreeMap<String, Value>,
13735 repositories: &'a [Repository],
13736 /// Recorded in the slot while private, and nowhere while public.
13737 classification: Classification,
13738 parent: Option<&'a NativeId>,
13739 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
13740 /// what keeps either key out of their slot.
13741 delivers: &'a [TaskRef],
13742 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
13743 delivered_by: &'a [TaskRef],
13744 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
13745 /// project, a document, and every write to an instance with no `priority_mapping` —
13746 /// which is what keeps such a write's requests exactly what they were before.
13747 priority: Option<Priority>,
13748}
13749
13750/// What one write does to one projected metadata text field, by the field's id.
13751#[derive(Debug, Clone)]
13752enum ProjectionWrite {
13753 /// Write this text.
13754 Set {
13755 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.
13756 text: String,
13757 },
13758 /// Clear the field's value.
13759 Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
13760}
13761
13762impl ProjectionWrite {
13763 /// Add this write to a field write's list of values and list of clears.
13764 fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
13765 match self {
13766 Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
13767 Self::Clear { field } => clears.push(field),
13768 }
13769 }
13770}
13771
13772/// What one write does to an item's `Priority` field.
13773enum PriorityWrite {
13774 /// Select this option of this field.
13775 Select {
13776 /// The `Priority` field's id.
13777 field: String,
13778 /// The mapped option's id.
13779 option: String,
13780 },
13781 /// Clear the field's value, which is what `none` is.
13782 Clear {
13783 /// The `Priority` field's id.
13784 field: String,
13785 },
13786}
13787
13788impl Incoming<'_> {
13789 /// The title this write puts on the issue.
13790 fn written_title(&self) -> String {
13791 match self.written {
13792 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
13793 Written::Work(..) => self.title.to_owned(),
13794 }
13795 }
13796}
13797
13798#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13799enum ContentKind {
13800 DraftIssue,
13801 Issue,
13802}
13803
13804/// What one board issue is: a document, or the work an [`ItemKind`] names.
13805///
13806/// A type of this source's own rather than an `ItemKind` with a third variant, because
13807/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
13808/// document — the contract keeps a document out of that enum deliberately. Holding the
13809/// board's three answers in one value is what makes every place that asks "which is this?"
13810/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
13811/// two thirds of the board.
13812#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13813enum BoardKind {
13814 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
13815 Document,
13816 /// Every other issue, and every draft.
13817 Work(ItemKind),
13818}
13819
13820impl BoardKind {
13821 /// The kind of an item titled `title`, filed under a project or not, holding `sub_issues`
13822 /// sub-issues and carrying the kind marker `marked` in its slot.
13823 ///
13824 /// The design prefix is read *first*, before either of the two rules that separate a
13825 /// project from a task. A document is not work whatever sub-issues it has and whatever
13826 /// marker it carries, and reading the prefix later would make a design issue with none of
13827 /// either an empty project. Being a sub-issue wins outright over the rest, and no marker
13828 /// overrides it: an issue filed under a project is that project's task even when it has
13829 /// sub-issues of its own.
13830 fn of(title: &str, filed: bool, sub_issues: u64, marked: Option<ItemKind>) -> Self {
13831 if title.starts_with(DESIGN_TITLE_PREFIX) {
13832 Self::Document
13833 } else if filed {
13834 Self::Work(ItemKind::Task)
13835 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
13836 Self::Work(ItemKind::Project)
13837 } else {
13838 Self::Work(ItemKind::Task)
13839 }
13840 }
13841
13842 /// What the change token's facets call this kind.
13843 const fn facet(self) -> change_token::FacetKind {
13844 match self {
13845 Self::Document => change_token::FacetKind::Document,
13846 Self::Work(ItemKind::Project) => change_token::FacetKind::Project,
13847 Self::Work(ItemKind::Task) => change_token::FacetKind::Task,
13848 }
13849 }
13850
13851 /// Whose half of `status_mapping` an item of this kind reads its status through. A
13852 /// document has no status of its own, so the task half stands in for whatever the issue
13853 /// holds; nothing reports it.
13854 const fn status_kind(self) -> ItemKind {
13855 match self {
13856 Self::Document => ItemKind::Task,
13857 Self::Work(kind) => kind,
13858 }
13859 }
13860
13861 /// How a refusal names this kind to the person reading it.
13862 const fn describes(self) -> &'static str {
13863 match self {
13864 Self::Document => "document",
13865 Self::Work(kind) => kind.marker(),
13866 }
13867 }
13868}
13869
13870/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
13871///
13872/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
13873/// the shared cross-source journeys assert one answer to one question, so two sources
13874/// that disagree about what "carries the label bug" means fail them.
13875fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
13876 let holds = |name: &String| {
13877 labels
13878 .iter()
13879 .any(|label| label.name.eq_ignore_ascii_case(name))
13880 };
13881 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
13882 && filter.all_of.iter().all(holds)
13883 && !filter.none_of.iter().any(holds)
13884}
13885
13886/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
13887/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
13888fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
13889 statuses.is_empty() || statuses.contains(&category)
13890}
13891
13892/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
13893///
13894/// `content` is the item's own prose — the body with this source's trailing metadata
13895/// comment already taken off — so a search never matches an encoding the author of the
13896/// issue never wrote.
13897fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
13898 let terms = query.terms.to_lowercase();
13899 let in_title = title.to_lowercase().contains(&terms);
13900 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
13901 match query.fields {
13902 TextFields::Title => in_title,
13903 TextFields::Content => in_content,
13904 TextFields::TitleOrContent => in_title || in_content,
13905 }
13906}
13907
13908/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
13909///
13910/// The project predicate is passed separately because a read narrowed to one project has
13911/// already answered it by asking *that project* for its own items — and re-applying it
13912/// there would compare the caller's selector, which may be a project's **name**, against
13913/// the id of the project that name resolved to, and keep nothing. Every other read passes
13914/// `query.project` and applies it here, which is what keeps `projects` a predicate this
13915/// source really does apply.
13916fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
13917 labels_match(&task.labels, &query.labels)
13918 && status_matches(task.status.category, &query.statuses)
13919 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
13920 && match project {
13921 ProjectFilter::Any => true,
13922 ProjectFilter::Orphans => task.project.is_none(),
13923 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
13924 }
13925 && query
13926 .text
13927 .as_ref()
13928 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
13929 // Against the parsed metadata slot, and against the origin field, which is where
13930 // `Resolved::metadata` reads each of them from.
13931 && query.metadata_matches(&task.metadata)
13932 && query.origin_matches(&task.metadata)
13933}
13934
13935fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
13936 labels_match(&project.labels, &query.labels)
13937 && status_matches(project.status.category, &query.statuses)
13938 && query
13939 .text
13940 .as_ref()
13941 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
13942}
13943
13944/// The same three predicates a task query carries, minus the status filter.
13945///
13946/// A document is not work, so it has no status for one to compare against and the query
13947/// type carries none. The project predicate is the same one — a design issue filed under a
13948/// project issue is in that project, and one filed under nothing is in none — so it is
13949/// spelled the same way here rather than answered differently.
13950fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
13951 labels_match(&document.labels, &query.labels)
13952 && match project {
13953 ProjectFilter::Any => true,
13954 ProjectFilter::Orphans => document.project.is_none(),
13955 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
13956 }
13957 && query
13958 .text
13959 .as_ref()
13960 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
13961}
13962
13963/// Why a GitHub item's change token does not vouch for its forward edges.
13964///
13965/// A forward edge names its far end and that far end's kind, and the near item's token moves
13966/// with the edges it holds but says nothing of the far ends themselves: a far end turning from a
13967/// task into a project when it gains a sub-issue, retitled into a document, or deleted moves
13968/// nothing the near token reads, so an edge kept at the near version could name a far end that
13969/// is no longer what it said.
13970const FAR_ENDS_UNCOVERED: &str = "a github item's change token does not attest a far end's kind, \
13971 whether it became a document, or its deletion, so its forward edges are always read from \
13972 github";
13973
13974/// The issue-search qualifier that keeps open issues alone, which a status filter naming only
13975/// categories an open issue reads as narrows a board-scoped search with.
13976const OPEN_QUALIFIER: &str = "is:open";
13977
13978/// The board filter `task status reconcile` reads the board through: closed items alone.
13979const CLOSED_FILTER: &str = "is:closed";
13980
13981/// The variables of one [`graphql::ISSUES_BY_REFERENCE`] request for `batch`: each slot names
13982/// one reference, and a slot the batch has none for names its last one again.
13983fn reference_batch(batch: &[IssueRef]) -> Value {
13984 let mut variables = serde_json::Map::new();
13985 for slot in 0..REFERENCE_BATCH {
13986 let issue = batch.get(slot).or(batch.last());
13987 variables.insert(format!("owner{slot}"), json!(issue.map(|i| &i.owner)));
13988 variables.insert(format!("name{slot}"), json!(issue.map(|i| &i.name)));
13989 variables.insert(format!("number{slot}"), json!(issue.map(|i| i.number)));
13990 }
13991 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
13992 variables.insert("statusField".to_owned(), json!(STATUS_FIELD));
13993 variables.insert("duplicates".to_owned(), json!(true));
13994 Value::Object(variables)
13995}
13996
13997/// One issue an issue read by reference answered, in the shape every other read of an issue
13998/// hands its resolver: each board membership's `Status` value, read by name, as the one field
13999/// value that membership carries.
14000///
14001/// A membership's `status` is `null` when its card sits at no option — or on a board with no
14002/// field of that name — and otherwise an option value naming its option; anything else is not
14003/// an answer to this read, and is refused rather than read as a card at no option.
14004fn held_issue(issue: &Value) -> Result<Value, SourceError> {
14005 let malformed = |what: &str| SourceError::Malformed {
14006 message: format!("GitHub answered an issue read by reference with {what}"),
14007 };
14008 let mut issue = issue.clone();
14009 let nodes = issue
14010 .pointer_mut("/projectItems/nodes")
14011 .and_then(Value::as_array_mut)
14012 .ok_or_else(|| malformed("no list of board memberships"))?;
14013 for node in nodes {
14014 let node = node
14015 .as_object_mut()
14016 .ok_or_else(|| malformed("a board membership that is not an object"))?;
14017 let status = match node.get("status") {
14018 None => return Err(malformed("a board membership with no status member")),
14019 Some(Value::Null) => None,
14020 Some(status) if status.get("name").is_some_and(Value::is_string) => {
14021 Some(status.clone())
14022 }
14023 // A single-select value of another field type answers `{}`; only an option value
14024 // carries a name.
14025 Some(status) if status.as_object().is_some_and(serde_json::Map::is_empty) => None,
14026 Some(_) => return Err(malformed("a membership status that names no option")),
14027 };
14028 node.insert(
14029 "fieldValues".to_owned(),
14030 json!({"nodes": status.into_iter().collect::<Vec<_>>(),
14031 "pageInfo": {"hasNextPage": false}}),
14032 );
14033 }
14034 Ok(issue)
14035}
14036
14037/// The issue one slot of a read by reference answered, `None` when GitHub answered that
14038/// slot's repository or issue `null` — which [`GitHubProjectsSource::answer_unresolved`]
14039/// admitted only beside a `NOT_FOUND` naming that very slot — and a refusal when the answer
14040/// leaves the slot, or its `issue` member, out: an answer missing a member is malformed rather
14041/// than a reference nothing holds.
14042fn referenced_issue(data: &Value, slot: usize) -> Result<Option<&Value>, SourceError> {
14043 let alias = format!("r{slot}");
14044 let repository = data.get(&alias).ok_or_else(|| SourceError::Malformed {
14045 message: format!("GitHub answered a read by reference with no {alias}"),
14046 })?;
14047 if repository.is_null() {
14048 return Ok(None);
14049 }
14050 let issue = repository
14051 .get("issue")
14052 .ok_or_else(|| SourceError::Malformed {
14053 message: format!("GitHub answered {alias} of a read by reference with no issue"),
14054 })?;
14055 if issue.is_null() {
14056 return Ok(None);
14057 }
14058 // `Repository.issue` is typed `Issue`, so anything else is not an answer to this read.
14059 if optional_str(issue, "__typename")? != Some("Issue") {
14060 return Err(SourceError::Malformed {
14061 message: format!(
14062 "GitHub answered {alias} of a read by reference with an issue whose __typename \
14063 is not Issue"
14064 ),
14065 });
14066 }
14067 Ok(Some(issue))
14068}
14069
14070/// A closure reason as a person reads it: `NOT_PLANNED` as `not planned`.
14071fn spoken_reason(reason: &str) -> String {
14072 reason.to_ascii_lowercase().replace('_', " ")
14073}
14074
14075impl GitHubProjectsSource {
14076 /// Every task and project of this board whose card contradicts its issue's closure, each
14077 /// judged against its own kind's half of `status_mapping`, read through the board's own
14078 /// `is:closed` filter, page by page — never the board's unfiltered `items`.
14079 ///
14080 /// Each item the filter answers is confirmed from its own node: an open one, one whose
14081 /// closure reads `unknown`, and one whose card is already in line are left out whatever
14082 /// the filter let through. The board's fields ride along and are held for the option
14083 /// writes that follow, so those need no read of the board.
14084 async fn contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
14085 let mut after: Option<String> = None;
14086 let mut found = Vec::new();
14087 loop {
14088 let data = self
14089 .graphql(
14090 graphql::CLOSED_ITEMS,
14091 json!({"owner":self.owner,"number":self.project_number,
14092 "first":MAX_PAGE_SIZE,"after":after,"filter":CLOSED_FILTER,
14093 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
14094 )
14095 .await?;
14096 let board = data
14097 .pointer("/closedItems/projectV2")
14098 .filter(|value| !value.is_null())
14099 .ok_or_else(|| SourceError::Refused {
14100 message: format!(
14101 "GitHub project {}/{} was not found or is not visible to the token",
14102 self.owner, self.project_number
14103 ),
14104 })?;
14105 *self.fields_cache()? = Some(BoardFields {
14106 id: BoardId::parse(required_str(board, "id")?)?,
14107 fields: board.get("fields").cloned().unwrap_or(Value::Null),
14108 });
14109 let items = board.get("items").ok_or_else(|| SourceError::Malformed {
14110 message: "GitHub project closed items are missing".into(),
14111 })?;
14112 for item in items
14113 .get("nodes")
14114 .and_then(Value::as_array)
14115 .ok_or_else(|| SourceError::Malformed {
14116 message: "GitHub project items.nodes is not an array".into(),
14117 })?
14118 {
14119 let Some(resolved) = self.resolve(item)? else {
14120 continue;
14121 };
14122 let BoardKind::Work(kind) = resolved.kind else {
14123 continue;
14124 };
14125 let Some((closed_as, name)) = self.statuses.contradicted(
14126 kind,
14127 resolved.option.as_deref(),
14128 resolved.closed,
14129 resolved.reason.as_deref(),
14130 ) else {
14131 continue;
14132 };
14133 found.push(StatusContradiction {
14134 closed_as,
14135 to: name.to_owned(),
14136 option: resolved.option.clone(),
14137 item: match kind {
14138 ItemKind::Task => ContradictedItem::Task(resolved.task()?),
14139 ItemKind::Project => ContradictedItem::Project(resolved.project()),
14140 },
14141 });
14142 }
14143 let info = items
14144 .get("pageInfo")
14145 .ok_or_else(|| SourceError::Malformed {
14146 message: "GitHub project items have no pageInfo".into(),
14147 })?;
14148 if !required_bool(info, "hasNextPage")? {
14149 return Ok(found);
14150 }
14151 let next = required_str(info, "endCursor")?;
14152 validate_cursor_progress(after.as_deref(), next)?;
14153 after = Some(next.to_owned());
14154 }
14155 }
14156
14157 /// What this board holds for each issue in `issues`, once each in the order given, read
14158 /// [`REFERENCE_BATCH`] references at a time — and nothing else of the board.
14159 ///
14160 /// An issue GitHub cannot find for this source's credential, and one with no item on this
14161 /// board, are both absent. A closure that already says the work will not be done here —
14162 /// not planned, duplicate — is skipped: a landing does not overrule it. An item closed as
14163 /// completed whose card sits at its kind's `done` option is unchanged, and every other item
14164 /// is due a `done` write. A project item — a plan's home card, its issue holding the plan's
14165 /// tasks as sub-issues — is held under exactly these rules, judged against the project
14166 /// kind's half of `status_mapping`.
14167 async fn holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
14168 let mut distinct: Vec<IssueRef> = Vec::with_capacity(issues.len());
14169 for issue in issues {
14170 if !distinct.contains(issue) {
14171 distinct.push(issue.clone());
14172 }
14173 }
14174 let done = |kind: ItemKind| {
14175 self.statuses
14176 .target(kind, StatusCategory::Done)
14177 .option()
14178 .unwrap_or("Done")
14179 .to_owned()
14180 };
14181 // GitHub numbers an issue with a GraphQL `Int`, a signed 32-bit value, so a reference past
14182 // its range names no issue any repository can hold: it is answered absent and never sent,
14183 // where binding it would have GitHub refuse the whole batch.
14184 let readable: Vec<IssueRef> = distinct
14185 .iter()
14186 .filter(|issue| i32::try_from(issue.number).is_ok())
14187 .cloned()
14188 .collect();
14189 let mut holdings = Vec::with_capacity(readable.len());
14190 for batch in readable.chunks(REFERENCE_BATCH) {
14191 let data = self
14192 .graphql(graphql::ISSUES_BY_REFERENCE, reference_batch(batch))
14193 .await?;
14194 for (slot, issue) in batch.iter().enumerate() {
14195 let node = referenced_issue(&data, slot)?;
14196 let resolved = match node {
14197 Some(node) => self.resolve_issue_as(&held_issue(node)?, false).await?,
14198 None => None,
14199 };
14200 // A task and a project are both held; a document is not work, so nothing is.
14201 let Some((item, kind)) = resolved.and_then(|item| match item.kind {
14202 BoardKind::Work(kind) => Some((item, kind)),
14203 BoardKind::Document => None,
14204 }) else {
14205 holdings.push(IssueHolding {
14206 issue: issue.clone(),
14207 completion: Completion::Absent,
14208 });
14209 continue;
14210 };
14211 let task = match kind {
14212 ItemKind::Task => {
14213 let held = item.task()?;
14214 HeldTask {
14215 id: held.id,
14216 kind,
14217 option: item.option.clone(),
14218 classification: held.classification,
14219 repositories: held.repositories,
14220 delivers: held.delivers,
14221 }
14222 }
14223 // A project delivers no task, so what its write keeps in step is nothing.
14224 ItemKind::Project => {
14225 let held = item.project();
14226 HeldTask {
14227 id: held.id,
14228 kind,
14229 option: item.option.clone(),
14230 classification: held.classification,
14231 repositories: held.repositories,
14232 delivers: Vec::new(),
14233 }
14234 }
14235 };
14236 let done = done(kind);
14237 let completion = match (item.closed, item.reason.as_deref()) {
14238 (true, Some(reason @ ("NOT_PLANNED" | "DUPLICATE"))) => Completion::Skipped {
14239 task,
14240 reason: format!("closed as {}", spoken_reason(reason)),
14241 },
14242 (true, None | Some("COMPLETED"))
14243 if item
14244 .option
14245 .as_deref()
14246 .is_some_and(|option| option.eq_ignore_ascii_case(&done)) =>
14247 {
14248 Completion::Unchanged { task }
14249 }
14250 _ => {
14251 let kept = item.clone();
14252 self.held_cache
14253 .lock()
14254 .map_err(|_| SourceError::Unavailable {
14255 message: "items read by issue reference were left \
14256 inconsistent; run the command again"
14257 .into(),
14258 })?
14259 .insert(kept.id.clone(), kept);
14260 Completion::Due {
14261 task,
14262 to: done.clone(),
14263 }
14264 }
14265 };
14266 holdings.push(IssueHolding {
14267 issue: issue.clone(),
14268 completion,
14269 });
14270 }
14271 }
14272 let mut read = holdings.into_iter();
14273 let mut answered = Vec::with_capacity(distinct.len());
14274 for issue in distinct {
14275 if i32::try_from(issue.number).is_ok() {
14276 answered.push(read.next().ok_or_else(|| SourceError::Malformed {
14277 message: format!("no holding was read for {issue}"),
14278 })?);
14279 } else {
14280 answered.push(IssueHolding {
14281 issue,
14282 completion: Completion::Absent,
14283 });
14284 }
14285 }
14286 Ok(answered)
14287 }
14288}
14289
14290#[async_trait::async_trait]
14291impl TaskSource for GitHubProjectsSource {
14292 fn kind(&self) -> &'static str {
14293 KIND
14294 }
14295 /// GitHub's issue search, which narrows a read of the comments made since an instant, is
14296 /// an index behind the writes it indexes — the credentialed lane has watched it miss a newly
14297 /// commented issue for thirty seconds — so a watermark is set back by
14298 /// [`COMMENT_SEARCH_LAG`], four times that.
14299 fn comment_consistency_window(&self) -> std::time::Duration {
14300 COMMENT_SEARCH_LAG
14301 }
14302 /// The `Date` of the last answer GitHub gave this source.
14303 fn served_at(&self) -> Option<DateTime<Utc>> {
14304 *self
14305 .last_served
14306 .lock()
14307 .unwrap_or_else(std::sync::PoisonError::into_inner)
14308 }
14309 fn attach_body_markup(&self, markup: onetaskgraph_plugin_api::SharedBodyMarkup) {
14310 let _ = self.body_markup.set(markup);
14311 }
14312 fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
14313 change_token::order(a, b)
14314 }
14315 /// A task's fields and comments: §4.13d of `docs/plugin-protocol.md` states, member by
14316 /// member, the change each part of the token moves for. Not its forward edges — see
14317 /// [`FAR_ENDS_UNCOVERED`].
14318 fn version_coverage(&self) -> VersionCoverage {
14319 VersionCoverage {
14320 forward_edges: Coverage::Uncovered {
14321 reason: FAR_ENDS_UNCOVERED.to_owned(),
14322 },
14323 ..VersionCoverage::whole()
14324 }
14325 }
14326 /// Every part, for a copy's skip: the relationship count moves for an edge added or removed
14327 /// and for a far end deleted, so the token sees every change to a task's edges but one — a
14328 /// far end's own kind flipping, by gaining a sub-issue or being retitled into a document,
14329 /// which `spike-coverage` measured stale 1 of 1. A skipping recopy does not carry that, and
14330 /// the next copy that reads the task does (`docs/plugin-protocol.md` §4.13d).
14331 // 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.
14332 fn copy_version_coverage(&self) -> VersionCoverage {
14333 VersionCoverage::whole()
14334 }
14335 /// A project's fields, and not its forward edges, for the same reason a task's are not.
14336 fn project_version_coverage(&self) -> ProjectVersionCoverage {
14337 ProjectVersionCoverage {
14338 fields: Coverage::Covered,
14339 forward_edges: Coverage::Uncovered {
14340 reason: FAR_ENDS_UNCOVERED.to_owned(),
14341 },
14342 }
14343 }
14344 /// A document's fields and the manifest of its image assets, which its metadata slot
14345 /// records: both are the issue's body, whose every edit moves the token's issue time.
14346 fn document_version_coverage(&self) -> DocumentVersionCoverage {
14347 DocumentVersionCoverage::whole()
14348 }
14349 fn write_context(&self, id: &NativeId) -> Option<Value> {
14350 self.context_of(id)
14351 }
14352 fn board_context(&self) -> Option<Value> {
14353 self.board_context_held()
14354 }
14355 fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
14356 self.prime(id, context, board)
14357 }
14358 fn unprime(&self, id: &NativeId) {
14359 self.unprime_held(id);
14360 }
14361 fn placement_context(&self) -> Option<Value> {
14362 self.placement_held()
14363 }
14364 fn prime_placement(&self, context: &Value) -> bool {
14365 self.prime_placement_held(context)
14366 }
14367 fn written_context(&self, id: &NativeId) -> Option<Value> {
14368 self.written_held(id)
14369 }
14370 fn prime_written(&self, id: &NativeId, context: &Value, task: &Task) -> bool {
14371 self.prime_written_held(id, context, task)
14372 }
14373 fn begin_journal(&self) {
14374 self.expected.begin();
14375 }
14376 fn end_journal(&self) -> Option<WriteJournal> {
14377 Some(self.expected.end())
14378 }
14379 fn capabilities(&self) -> Capabilities {
14380 Capabilities {
14381 projects: Support::Native,
14382 documents: Support::Native,
14383 comments: Support::Native,
14384 assets: Support::Native,
14385 priority: if self.priorities.is_some() {
14386 Support::Native
14387 } else {
14388 Support::Unsupported
14389 },
14390 filter_by_priority: Support::Native,
14391 filter_by_comment_activity: Support::Native,
14392 filter_by_metadata: Support::Native,
14393 filter_by_origin: Support::Native,
14394 reconcile_status: Support::Native,
14395 complete_by_issue: Support::Native,
14396 orphan_tasks: Support::Native,
14397 filter_by_label: Support::Native,
14398 filter_by_status: Support::Native,
14399 search_title: Support::Native,
14400 search_content: Support::Native,
14401 task_dependencies: DependencySupport::BothDirections,
14402 project_dependencies: DependencySupport::BothDirections,
14403 max_page_size: MAX_PAGE_SIZE,
14404 }
14405 }
14406 async fn visibility(
14407 &self,
14408 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14409 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14410 self.write_visibility(target).await
14411 }
14412 async fn visibility_with_project(
14413 &self,
14414 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14415 project: &onetaskgraph_plugin_api::WriteTarget<'_>,
14416 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
14417 self.write_visibility_with_project(target, project).await
14418 }
14419 fn declare_visibility(&self, declared: onetaskgraph_plugin_api::Visibility) {
14420 self.declared_private.store(
14421 declared == onetaskgraph_plugin_api::Visibility::Private,
14422 std::sync::atomic::Ordering::SeqCst,
14423 );
14424 }
14425 fn scope_visibility<'a>(
14426 &'a self,
14427 operation: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>,
14428 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
14429 self.scope_visibility_reads(operation)
14430 }
14431
14432 /// Where a create of `target` would file its issue, by [`Self::creation_rule`] — the rule
14433 /// the create applies — reading the parent project's issue only when `target` names one.
14434 async fn creation_repository(
14435 &self,
14436 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14437 ) -> Result<Option<Repository>, SourceError> {
14438 let onetaskgraph_plugin_api::WriteTarget::New {
14439 repositories,
14440 project,
14441 } = target
14442 else {
14443 return Ok(None);
14444 };
14445 let target = self
14446 .creation_rule(repositories, *project, "a new item")
14447 .await?;
14448 Repository::try_from(target.origin())
14449 .map(Some)
14450 .map_err(|message| SourceError::Malformed { message })
14451 }
14452 /// Every issue of the repository a create of `target` files in whose body carries `key` in
14453 /// its metadata slot, read off that repository's issues newest first back to `since` —
14454 /// never through search, whose index lags a fresh issue by seconds.
14455 async fn created_with_key(
14456 &self,
14457 record: onetaskgraph_plugin_api::MetadataRecord,
14458 key: &str,
14459 since: Option<DateTime<Utc>>,
14460 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14461 ) -> Result<Vec<NativeId>, SourceError> {
14462 let _ = record;
14463 let onetaskgraph_plugin_api::WriteTarget::New {
14464 repositories,
14465 project,
14466 } = target
14467 else {
14468 return Ok(Vec::new());
14469 };
14470 let repository = self
14471 .creation_rule(repositories, *project, "a new item")
14472 .await?;
14473 let wanted = Value::String(key.to_owned());
14474 let mut found = Vec::new();
14475 let mut after: Option<String> = None;
14476 let mut followed = std::collections::HashSet::new();
14477 loop {
14478 let data = self
14479 .graphql(
14480 graphql::ISSUES_BY_CREATION,
14481 json!({"owner": repository.owner, "name": repository.name,
14482 "first": MAX_PAGE_SIZE, "after": after}),
14483 )
14484 .await?;
14485 let issues =
14486 data.pointer("/repository/issues")
14487 .ok_or_else(|| SourceError::Malformed {
14488 message: format!(
14489 "GitHub answered no issues for repository {}",
14490 repository.slug()
14491 ),
14492 })?;
14493 let malformed = |what: &str| SourceError::Malformed {
14494 message: format!(
14495 "GitHub answered a page of repository {}'s issues {what}",
14496 repository.slug()
14497 ),
14498 };
14499 let nodes = issues
14500 .get("nodes")
14501 .and_then(Value::as_array)
14502 .ok_or_else(|| malformed("with no list of issues"))?;
14503 let mut older = false;
14504 for node in nodes {
14505 let id = node
14506 .get("id")
14507 .and_then(Value::as_str)
14508 .filter(|id| !id.is_empty())
14509 .ok_or_else(|| malformed("holding an issue with no id"))?;
14510 let created = optional_time(node, "createdAt")?
14511 .ok_or_else(|| malformed("holding an issue with no creation time"))?;
14512 if since.is_some_and(|since| created < since) {
14513 older = true;
14514 break;
14515 }
14516 let body = match node.get("body") {
14517 Some(Value::String(body)) => Some(body.clone()),
14518 Some(Value::Null) => None,
14519 _ => return Err(malformed("holding an issue with no body member")),
14520 };
14521 let (_, metadata) = metadata_body(body)?;
14522 if metadata.get(MetadataKey::CREATE_KEY) == Some(&wanted) {
14523 found.push(NativeId(id.to_owned()));
14524 }
14525 }
14526 let next = issues
14527 .pointer("/pageInfo/hasNextPage")
14528 .and_then(Value::as_bool)
14529 .ok_or_else(|| malformed("that does not say whether another follows"))?;
14530 if older || !next {
14531 return Ok(found);
14532 }
14533 // A page that says another follows names where it starts, and never a page already
14534 // read: anything else would ask for the same pages again for ever.
14535 let cursor = issues
14536 .pointer("/pageInfo/endCursor")
14537 .and_then(Value::as_str)
14538 .filter(|cursor| !cursor.is_empty())
14539 .map(str::to_owned);
14540 if cursor
14541 .as_ref()
14542 .is_none_or(|cursor| !followed.insert(cursor.clone()))
14543 {
14544 return Err(SourceError::Malformed {
14545 message: format!(
14546 "GitHub answered a page of repository {}'s issues that says another \
14547 follows without a new cursor to it",
14548 repository.slug()
14549 ),
14550 });
14551 }
14552 after = cursor;
14553 }
14554 }
14555
14556 /// `Repository.issues` lists an issue GitHub committed only once its listing catches up.
14557 fn create_key_lags(&self) -> bool {
14558 true
14559 }
14560
14561 /// The task as this process's own latest write of it left it, from the item it recorded
14562 /// that write as — never a request. Its `version` is always `None`: a board's change token
14563 /// is given only once an item has gone unchanged for [`change_token::SETTLED`], which an
14564 /// item this command has just written has not, so no write's own answer can vouch for one.
14565 async fn written_task(&self, id: &NativeId) -> Option<Task> {
14566 let held = self
14567 .created()
14568 .ok()?
14569 .iter()
14570 .find(|held| held.id == *id)
14571 .cloned()
14572 .or_else(|| {
14573 self.updated()
14574 .ok()?
14575 .iter()
14576 .find(|held| held.id == *id)
14577 .cloned()
14578 })?;
14579 let mut task = held.task().ok()?;
14580 task.version = self.written_token(id);
14581 Some(task)
14582 }
14583
14584 /// Why `creation_owners` refuses a create of `target`, by [`Self::creation_rule`] — read
14585 /// only when the list is not empty, so a source restricting nothing reads nothing.
14586 async fn creation_refusal(
14587 &self,
14588 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
14589 ) -> Result<Option<String>, SourceError> {
14590 let onetaskgraph_plugin_api::WriteTarget::New {
14591 repositories,
14592 project,
14593 } = target
14594 else {
14595 return Ok(None);
14596 };
14597 if self.creation_owners.is_empty() {
14598 return Ok(None);
14599 }
14600 let target = self
14601 .creation_rule(repositories, *project, "a new item")
14602 .await?;
14603 Ok(self.owner_refusal(&target))
14604 }
14605
14606 async fn health(&self) -> Result<Health, SourceError> {
14607 let board = self.board_page(None, 1).await?;
14608 Ok(Health {
14609 reachable: true,
14610 detail: Some(format!(
14611 "reading GitHub project {}/{} ({})",
14612 self.owner,
14613 self.project_number,
14614 required_str(&board, "title")?
14615 )),
14616 })
14617 }
14618 /// The board's own node id, digested: the one request reading its fields, or none when
14619 /// this command has read them already.
14620 async fn designator(&self) -> Result<Option<Designator>, SourceError> {
14621 let board = self.board_fields().await?;
14622 Designator::of_backend(KIND, board.id.as_str())
14623 .map(Some)
14624 .map_err(|message| SourceError::Malformed { message })
14625 }
14626 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
14627 self.item_by_id(id)
14628 .await?
14629 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14630 .map(|item| item.task())
14631 .transpose()
14632 }
14633 async fn task_assets(
14634 &self,
14635 id: &NativeId,
14636 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14637 self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
14638 }
14639 async fn task_asset(
14640 &self,
14641 id: &NativeId,
14642 name: &onetaskgraph_plugin_api::AssetName,
14643 ) -> Result<Option<Vec<u8>>, SourceError> {
14644 self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
14645 .await
14646 }
14647 async fn set_task_rendering_with_assets(
14648 &self,
14649 id: &NativeId,
14650 content: &str,
14651 provenance: &Value,
14652 _answers: &BTreeMap<String, Value>,
14653 assets: &onetaskgraph_plugin_api::AssetWrite,
14654 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14655 self.replace_rendering(
14656 id,
14657 BoardKind::Work(ItemKind::Task),
14658 content,
14659 provenance,
14660 Some(assets),
14661 )
14662 .await
14663 }
14664 async fn document_assets(
14665 &self,
14666 id: &NativeId,
14667 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
14668 self.held_assets(id, BoardKind::Document).await
14669 }
14670 async fn document_asset(
14671 &self,
14672 id: &NativeId,
14673 name: &onetaskgraph_plugin_api::AssetName,
14674 ) -> Result<Option<Vec<u8>>, SourceError> {
14675 self.held_asset(id, BoardKind::Document, name).await
14676 }
14677 async fn set_document_rendering_with_assets(
14678 &self,
14679 id: &NativeId,
14680 content: &str,
14681 provenance: &Value,
14682 _answers: &BTreeMap<String, Value>,
14683 assets: &onetaskgraph_plugin_api::AssetWrite,
14684 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
14685 self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
14686 .await
14687 }
14688 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
14689 Ok(self
14690 .item_by_id(id)
14691 .await?
14692 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14693 .map(|item| item.project()))
14694 }
14695 /// Every narrowed search and origin lookup asked, a page at a time, as aliased fields of one
14696 /// [`graphql::SEARCH_BATCH`] request a round — a search's issue search in an `s<n>` field,
14697 /// an origin's board filter in an `o<n>` field beside its issue search — and every other
14698 /// query by [`Self::query_tasks`] on its own: one narrowed by a project or by comment
14699 /// activity, one carrying no narrowing, one resumed from a cursor that read handed out, and
14700 /// every query of a process that has written this board, whose own writes complete a
14701 /// narrowed answer. A lone search is its own read too, which costs a batch's point at a
14702 /// page of its own size.
14703 ///
14704 /// Each search's first page asks [`SEARCH_BATCH_FIRST_PAGE`] rows and each later page
14705 /// [`SEARCH_PAGE_SIZE`]. GitHub answers an error about one aliased field at that field's
14706 /// path, beside the others' answers, so a refused page fails that query's answer alone; a
14707 /// request it refuses whole is every query of that request's answer.
14708 async fn query_tasks_batch(
14709 &self,
14710 asks: &[(TaskQuery, PageRequest)],
14711 ) -> Vec<Result<Page<Task>, SourceError>> {
14712 self.search_batched(asks).await
14713 }
14714 async fn query_tasks(
14715 &self,
14716 query: &TaskQuery,
14717 page: &PageRequest,
14718 ) -> Result<Page<Task>, SourceError> {
14719 validate_page(page)?;
14720 refuse_unsearchable(query)?;
14721 // A closed issue reads `done`, `cancelled` or — closed for a reason that implies no
14722 // category — `unknown`; every other category reads only off an open issue or a draft. So
14723 // a status filter naming none of those three asks every issue search it sends for open
14724 // issues alone; each candidate's category is still confirmed from its own item below.
14725 let open_only = open_only(query);
14726 if query.origin.is_none()
14727 && !matches!(query.project, ProjectFilter::Is(_))
14728 && let Some(also) = search_qualifiers(query)
14729 {
14730 return self.search_tasks(query, page, &also).await;
14731 }
14732
14733 // A read narrowed to one project asks that project for its own tasks, so nothing
14734 // about it costs what the rest of the board holds. A read carrying a text, metadata
14735 // or origin predicate asks GitHub the narrower question those predicates are, and a
14736 // read narrowed to comment activity alone asks the board's own issue search for the
14737 // issues updated since, which is every issue a comment could have been written or
14738 // edited on since. Every other task read is a question about the whole board and is
14739 // answered by reading it.
14740 let (held, membership) = match (&query.project, query.commented_since) {
14741 (ProjectFilter::Is(project), _) => (
14742 match self.held_task_children(project)? {
14743 // Taken as `project_children` takes the project's own sub-issue read: with
14744 // every item this process wrote under it since, and none it moved away.
14745 Some(held) => self.completed_with_written(
14746 held.into_iter()
14747 .filter(|child| child.parent.as_ref() == Some(project))
14748 .collect(),
14749 |own| own.parent.as_ref() == Some(project),
14750 )?,
14751 None => self.project_children(project).await?,
14752 },
14753 // Answered by where these items came from; see `task_matches`.
14754 &ProjectFilter::Any,
14755 ),
14756 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
14757 match (self.narrowed(query).await?, since) {
14758 (Some(narrowed), _) => (narrowed, &query.project),
14759 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
14760 // The board's own items list every draft; only the search half, which
14761 // completes them with issues they lag behind on, is narrowed — a closed
14762 // issue that half leaves out is at no category the filter keeps.
14763 (None, None) if open_only => (
14764 self.board_completed_by(Some(OPEN_QUALIFIER)).await?.items,
14765 &query.project,
14766 ),
14767 (None, None) => (self.board().await?.items, &query.project),
14768 }
14769 }
14770 };
14771 // Every candidate this read confirms by its comments is read together.
14772 let candidates = self.comment_candidates(held.iter(), query, membership)?;
14773 self.hold_comment_pages(&candidates).await?;
14774 // Filtered before paged: a page of a filtered result is a page of the survivors,
14775 // never the survivors of a page.
14776 let mut tasks = Vec::new();
14777 for item in held
14778 .iter()
14779 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
14780 {
14781 let task = item.task()?;
14782 if task_matches(&task, query, membership)
14783 && self.commented_since(item, query.commented_since).await?
14784 {
14785 tasks.push(task);
14786 }
14787 }
14788 Ok(offset_page(
14789 tasks,
14790 numeric_cursor(page.cursor.as_ref())?,
14791 page.limit.min(MAX_PAGE_SIZE) as usize,
14792 ))
14793 }
14794 /// Read board pages directly, at the board connection's consistency. Unlike
14795 /// `query_tasks`, this does not complete a lagging board with issue search: a complete
14796 /// sweep needs one request per listing page, including the first comments.
14797 async fn query_task_details(
14798 &self,
14799 query: &TaskQuery,
14800 page: &PageRequest,
14801 comments: Option<&PageRequest>,
14802 ) -> Result<Page<TaskDetailRead>, SourceError> {
14803 let Some(comments) = comments else {
14804 let tasks = self.query_tasks(query, page).await?;
14805 return Ok(Page {
14806 items: tasks
14807 .items
14808 .into_iter()
14809 .map(|task| TaskDetailRead {
14810 task,
14811 comments: None,
14812 })
14813 .collect(),
14814 next: tasks.next,
14815 });
14816 };
14817 validate_page(page)?;
14818 validate_page(comments)?;
14819 refuse_unsearchable(query)?;
14820 if comments.cursor.is_some() || query.commented_since.is_some() {
14821 return Err(SourceError::Refused {
14822 message: "a board listing with comments requires a first comment page and cannot filter by comment activity".into(),
14823 });
14824 }
14825 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
14826 let data = self
14827 .graphql(
14828 graphql::BOARD_WITH_COMMENTS,
14829 json!({
14830 "owner":self.owner,"number":self.project_number,
14831 "first":page.limit.min(MAX_PAGE_SIZE),"after":after,
14832 "commentFirst":comments.limit.min(MAX_PAGE_SIZE),
14833 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true,
14834 }),
14835 )
14836 .await?;
14837 let connection = data
14838 .pointer("/owner/projectV2/items")
14839 .filter(|value| !value.is_null())
14840 .ok_or_else(|| SourceError::Refused {
14841 message: "GitHub project was not found or is not visible to the token".into(),
14842 })?;
14843 let mut items = Vec::new();
14844 for node in optional_nodes(Some(connection), "board items")?
14845 .into_iter()
14846 .flatten()
14847 {
14848 let Some(item) = self.resolve(node)? else {
14849 continue;
14850 };
14851 if item.kind != BoardKind::Work(ItemKind::Task) {
14852 continue;
14853 }
14854 let task = item.task()?;
14855 if !task_matches(&task, query, &query.project) {
14856 continue;
14857 }
14858 let read = if item.content_kind == ContentKind::DraftIssue {
14859 Err(self.draft_has_no_comments(&task.id))
14860 } else {
14861 self.expected.learn_comments(&task.id, &node["content"]);
14862 comment_page(&node["content"], &task.id.0, None).map(Some)
14863 };
14864 items.push(TaskDetailRead {
14865 task,
14866 comments: Some(read),
14867 });
14868 }
14869 let next = next_cursor(connection)?;
14870 if let Some(next) = &next {
14871 validate_cursor_progress(after, &next.0)?;
14872 }
14873 Ok(Page { items, next })
14874 }
14875
14876 async fn query_projects(
14877 &self,
14878 query: &ProjectQuery,
14879 page: &PageRequest,
14880 ) -> Result<Page<Project>, SourceError> {
14881 validate_page(page)?;
14882 refuse_unsearchable_text(query.text.as_ref())?;
14883 // The projects a board holds are found by an issue search scoped to that board,
14884 // never by walking the board's own item connection: what tells a project from a
14885 // task is the `parent` each issue carries, which costs nothing to read. A query
14886 // carrying a text asks that search for the text too, so it reads the issues that
14887 // hold it rather than every issue of the board.
14888 let held = match self.text_searched(query.text.as_ref()).await? {
14889 Some(searched) => searched,
14890 None => self.board_issues().await?,
14891 };
14892 let projects = held
14893 .iter()
14894 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
14895 .map(Resolved::project)
14896 .filter(|project| project_matches(project, query))
14897 .collect();
14898 Ok(offset_page(
14899 projects,
14900 numeric_cursor(page.cursor.as_ref())?,
14901 page.limit.min(MAX_PAGE_SIZE) as usize,
14902 ))
14903 }
14904 async fn query_projects_by_origin(
14905 &self,
14906 origin: &str,
14907 page: &PageRequest,
14908 ) -> Result<Page<Project>, SourceError> {
14909 validate_page(page)?;
14910 let projects = self
14911 .copied_from(origin, BoardKind::Work(ItemKind::Project))
14912 .await?
14913 .iter()
14914 .map(Resolved::project)
14915 .collect();
14916 Ok(offset_page(
14917 projects,
14918 numeric_cursor(page.cursor.as_ref())?,
14919 page.limit.min(MAX_PAGE_SIZE) as usize,
14920 ))
14921 }
14922 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
14923 let Some(item) = self
14924 .document_by_id(id)
14925 .await?
14926 .filter(|item| item.kind == BoardKind::Document)
14927 else {
14928 return Ok(None);
14929 };
14930 self.expected.read_document(&item);
14931 Ok(Some(item.document()))
14932 }
14933 async fn query_documents_by_origin(
14934 &self,
14935 origin: &str,
14936 page: &PageRequest,
14937 ) -> Result<Page<Document>, SourceError> {
14938 validate_page(page)?;
14939 let documents = self
14940 .copied_from(origin, BoardKind::Document)
14941 .await?
14942 .iter()
14943 .map(Resolved::document)
14944 .collect();
14945 Ok(offset_page(
14946 documents,
14947 numeric_cursor(page.cursor.as_ref())?,
14948 page.limit.min(MAX_PAGE_SIZE) as usize,
14949 ))
14950 }
14951 async fn query_documents(
14952 &self,
14953 query: &DocumentQuery,
14954 page: &PageRequest,
14955 ) -> Result<Page<Document>, SourceError> {
14956 validate_page(page)?;
14957 // Narrowed to one project, this is the same sub-issue read a task list scoped to
14958 // that project makes — a document filed under a project is a sub-issue of it too,
14959 // and which of them come back is the kind this caller asked for. Unscoped, a query
14960 // carrying a text asks the board-scoped issue search for it, as a task query does,
14961 // and only one carrying none reads the board.
14962 let (held, membership) = match &query.project {
14963 ProjectFilter::Is(project) => (
14964 self.project_children(project).await?,
14965 // Answered by where these items came from; see `task_matches`.
14966 &ProjectFilter::Any,
14967 ),
14968 ProjectFilter::Any | ProjectFilter::Orphans => {
14969 refuse_unsearchable_text(query.text.as_ref())?;
14970 match self.text_searched(query.text.as_ref()).await? {
14971 Some(searched) => (searched, &query.project),
14972 None => (self.board().await?.items, &query.project),
14973 }
14974 }
14975 };
14976 // Filtered before paged, exactly as a task read is: a page of a filtered result is
14977 // a page of the survivors, never the survivors of a page.
14978 let documents = held
14979 .iter()
14980 .filter(|item| item.kind == BoardKind::Document)
14981 .map(Resolved::document)
14982 .filter(|document| document_matches(document, query, membership))
14983 .collect();
14984 Ok(offset_page(
14985 documents,
14986 numeric_cursor(page.cursor.as_ref())?,
14987 page.limit.min(MAX_PAGE_SIZE) as usize,
14988 ))
14989 }
14990 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
14991 validate_page(page)?;
14992 let offset = numeric_cursor(page.cursor.as_ref())?;
14993 let mut labels = self
14994 .board()
14995 .await?
14996 .items
14997 .into_iter()
14998 .flat_map(|item| item.labels)
14999 .fold(Vec::new(), |mut all, label| {
15000 if !all.iter().any(|x: &Label| x.id == label.id) {
15001 all.push(label);
15002 }
15003 all
15004 });
15005 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
15006 Ok(offset_page(
15007 labels,
15008 offset,
15009 page.limit.min(MAX_PAGE_SIZE) as usize,
15010 ))
15011 }
15012 async fn task_dependencies(
15013 &self,
15014 id: &NativeId,
15015 direction: Direction,
15016 page: &PageRequest,
15017 ) -> Result<Page<DependencyEdge>, SourceError> {
15018 self.dependencies(id, ItemKind::Task, direction, page).await
15019 }
15020 async fn project_dependencies(
15021 &self,
15022 id: &NativeId,
15023 direction: Direction,
15024 page: &PageRequest,
15025 ) -> Result<Page<DependencyEdge>, SourceError> {
15026 self.dependencies(id, ItemKind::Project, direction, page)
15027 .await
15028 }
15029
15030 fn writes(&self) -> WriteSupport {
15031 WriteSupport::Supported
15032 }
15033
15034 /// Create or update one task.
15035 ///
15036 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
15037 /// neither may name the task itself or name one task twice — and land in the body's
15038 /// metadata slot under their reserved keys, in place of any caller metadata of those
15039 /// names.
15040 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
15041 if write.item.project.is_some() {
15042 self.projectless()?;
15043 }
15044 self.write_task_assets(write, None)
15045 .await
15046 .map(|written| written.id)
15047 }
15048
15049 async fn read_ahead_origins_for_copy(&self, origins: &[String]) -> Result<(), SourceError> {
15050 self.origins_ahead(origins).await
15051 }
15052
15053 async fn member_classifications(
15054 &self,
15055 project: &NativeId,
15056 ) -> Result<Option<Vec<(Classification, Vec<Repository>)>>, SourceError> {
15057 Ok(self
15058 .member_classes
15059 .lock()
15060 .map_err(|_| SourceError::Unavailable {
15061 message: "a project's members held for its classification were left \
15062 inconsistent; run the command again"
15063 .into(),
15064 })?
15065 .remove(project))
15066 }
15067
15068 /// The issue's own web address, as the `createIssue` that made it in this command answered
15069 /// it: a task, a project and a document are each an issue here.
15070 fn written_location(
15071 &self,
15072 _record: onetaskgraph_plugin_api::MetadataRecord,
15073 id: &NativeId,
15074 ) -> Option<Location> {
15075 self.written
15076 .lock()
15077 .unwrap_or_else(std::sync::PoisonError::into_inner)
15078 .get(id)
15079 .cloned()
15080 .map(Location::Url)
15081 }
15082
15083 /// [`MUTATION_BATCH`], while writes are held back: [`graphql::CREATE_ISSUES`] creates that many
15084 /// in one request, and a copy's tasks are created together only then, since their fields
15085 /// and edges are what is held back.
15086 fn create_batch_limit(&self) -> usize {
15087 if self.deferring().unwrap_or(false) {
15088 MUTATION_BATCH
15089 } else {
15090 1
15091 }
15092 }
15093
15094 // llmlint: ignore[invalid_states_unrepresentable] The signature is the plugin contract's additive `TaskSource::write_tasks`, which the manager approved taking `ItemWrite<Task>` as `write_task` does; a create-only input type would be a new contract type. A write naming an existing target is refused below before anything is sent.
15095 async fn write_tasks(&self, writes: &[ItemWrite<Task>]) -> Result<Vec<NativeId>, SourceError> {
15096 if writes.iter().any(|write| write.item.project.is_some()) {
15097 self.projectless()?;
15098 }
15099 if let Some(write) = writes.iter().find(|write| write.target.is_some()) {
15100 return Err(SourceError::Refused {
15101 message: format!(
15102 "write_tasks creates tasks, and the write of {} names the existing task {}; \
15103 next: update it with write_task",
15104 write.item.id,
15105 write.target.as_ref().map_or("", |target| target.0.as_str())
15106 ),
15107 });
15108 }
15109 if !self.deferring()? {
15110 let mut created = Vec::with_capacity(writes.len());
15111 for write in writes {
15112 match self.write_task(write).await {
15113 Ok(id) => created.push(id),
15114 Err(error) => {
15115 let mut left: Vec<&str> = Vec::new();
15116 for id in &created {
15117 if self.delete_issue(id).await.is_err() {
15118 left.push(id.0.as_str());
15119 }
15120 }
15121 return Err(left_behind(error, &left));
15122 }
15123 }
15124 }
15125 return Ok(created);
15126 }
15127 self.create_together(writes).await
15128 }
15129
15130 fn begin_deferred_writes(&self) {
15131 if let Ok(mut deferred) = self.deferred.lock() {
15132 deferred.get_or_insert_with(Deferred::default);
15133 }
15134 }
15135
15136 async fn finish_writes(&self) -> Result<(), SourceError> {
15137 self.send_held_updates().await?;
15138 self.send_held_back().await
15139 }
15140
15141 async fn write_task_with_assets(
15142 &self,
15143 write: &ItemWrite<Task>,
15144 _answers: Option<&BTreeMap<String, Value>>,
15145 assets: &onetaskgraph_plugin_api::AssetWrite,
15146 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15147 self.write_task_assets(write, Some(assets)).await
15148 }
15149
15150 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
15151 self.projectless()?;
15152 self.write_item(
15153 &Incoming {
15154 written: Written::Work(ItemKind::Project, &write.item.status),
15155 title: &write.item.title,
15156 content: write.item.content.as_deref(),
15157 assets: None,
15158 labels: &write.item.labels,
15159 metadata: &write.item.metadata,
15160 repositories: &write.item.repositories,
15161 classification: write.item.classification,
15162 parent: None,
15163 delivers: &[],
15164 delivered_by: &[],
15165 priority: None,
15166 },
15167 write.target.as_ref(),
15168 &write.depends_on,
15169 )
15170 .await
15171 .map(|written| written.id)
15172 }
15173
15174 fn task_body_size(
15175 &self,
15176 write: &ItemWrite<Task>,
15177 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15178 ) -> Option<onetaskgraph_plugin_api::BodySize> {
15179 Some(self.body_size(&self.task_incoming(write, assets).ok()?))
15180 }
15181
15182 fn project_body_size(
15183 &self,
15184 write: &ItemWrite<Project>,
15185 ) -> Option<onetaskgraph_plugin_api::BodySize> {
15186 Some(self.body_size(&Incoming {
15187 written: Written::Work(ItemKind::Project, &write.item.status),
15188 title: &write.item.title,
15189 content: write.item.content.as_deref(),
15190 assets: None,
15191 labels: &write.item.labels,
15192 metadata: &write.item.metadata,
15193 repositories: &write.item.repositories,
15194 classification: write.item.classification,
15195 parent: None,
15196 delivers: &[],
15197 delivered_by: &[],
15198 priority: None,
15199 }))
15200 }
15201
15202 fn document_body_size(
15203 &self,
15204 write: &ItemWrite<Document>,
15205 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
15206 ) -> Option<onetaskgraph_plugin_api::BodySize> {
15207 Some(self.body_size(&Incoming {
15208 written: Written::Document,
15209 title: &write.item.title,
15210 content: write.item.content.as_deref(),
15211 assets,
15212 labels: &write.item.labels,
15213 metadata: &write.item.metadata,
15214 repositories: &write.item.repositories,
15215 classification: write.item.classification,
15216 parent: write.item.project.as_ref(),
15217 delivers: &[],
15218 delivered_by: &[],
15219 priority: None,
15220 }))
15221 }
15222
15223 /// Create or update one document, which is one issue titled the way this board spells
15224 /// a document.
15225 ///
15226 /// Everything else is exactly a task write: caller metadata goes to the same canonical
15227 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
15228 /// or a field this board cannot carry is refused by name rather than dropped, a target
15229 /// naming an issue this board does not hold is refused rather than created, and an
15230 /// issue this call created is taken back when the rest of the write fails.
15231 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
15232 self.write_document_assets(write, None)
15233 .await
15234 .map(|written| written.id)
15235 }
15236
15237 async fn write_document_with_assets(
15238 &self,
15239 write: &ItemWrite<Document>,
15240 _answers: Option<&BTreeMap<String, Value>>,
15241 assets: &onetaskgraph_plugin_api::AssetWrite,
15242 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
15243 self.write_document_assets(write, Some(assets)).await
15244 }
15245
15246 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
15247 /// which reads nothing; then the board's `Status` option. Over an existing item that is
15248 /// read off the item, as the write reads it, and the item is held among this command's
15249 /// resolved records so the write that follows reuses that read rather than repeating it;
15250 /// an item that does not carry the field takes the board's fields, which are held once
15251 /// read. A create is checked against the board's fields only when this command already
15252 /// holds them, because a create reads them together with its repository, in one request,
15253 /// and refuses a missing option before it writes anything.
15254 async fn check_status_write(
15255 &self,
15256 kind: ItemKind,
15257 category: StatusCategory,
15258 target: Option<&NativeId>,
15259 ) -> Result<(), SourceError> {
15260 let status = self.resolved_target(kind, category)?;
15261 if status.option().is_none() {
15262 return Ok(());
15263 }
15264 let fields = match target {
15265 Some(target) => {
15266 // A target this board does not hold is the write's own refusal to make.
15267 let Some(item) = self.bound_item(target).await? else {
15268 return Ok(());
15269 };
15270 self.resolved_cache()?.insert(target.clone(), item.clone());
15271 // Only the `Status` field decides whether the write can land.
15272 self.fields_for(Some(&item), false, true, false)
15273 .await?
15274 .fields
15275 }
15276 None => {
15277 let held = self
15278 .board_cache()?
15279 .as_ref()
15280 .map(|board| board.fields.clone());
15281 match held.or_else(|| {
15282 self.fields_cache()
15283 .ok()
15284 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
15285 }) {
15286 Some(fields) => fields,
15287 None => return Ok(()),
15288 }
15289 }
15290 };
15291 self.column_for(&fields, kind, category, &status)
15292 .map(|_| ())
15293 }
15294
15295 /// What a write of `status` reads back as: the `Status` option its category maps to for
15296 /// `kind`, and for a closing category the closure that option and its fixed reason read
15297 /// as — exactly what [`BoardStatuses::status`] reports of the item once written. A category
15298 /// this instance has no option for is answered as given, since writing it is refused.
15299 fn status_as_written(&self, kind: ItemKind, status: &Status) -> Status {
15300 match self.statuses.target(kind, status.category) {
15301 StatusTarget::Column(name) => {
15302 self.statuses.status(kind, Some(name.as_str()), false, None)
15303 }
15304 StatusTarget::Terminal(name, reason) => {
15305 self.statuses
15306 .status(kind, Some(name.as_str()), true, Some(reason.reason()))
15307 }
15308 StatusTarget::Disabled(_) => status.clone(),
15309 }
15310 }
15311
15312 /// Set one task's status alone.
15313 ///
15314 /// An open target reopens a closed issue with an `updateIssue` carrying only its
15315 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
15316 /// terminal target selects its mapped option, then closes with its fixed reason. No
15317 /// request carries a title, a body or a label. The status
15318 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
15319 /// what a re-read reports.
15320 async fn set_task_status(
15321 &self,
15322 id: &NativeId,
15323 category: StatusCategory,
15324 ) -> Result<Option<Status>, SourceError> {
15325 self.set_status(id, ItemKind::Task, category).await
15326 }
15327
15328 /// Set one task's status and answer the task with it, reading the item once: the read the
15329 /// task is answered from is handed to the status write, which would otherwise read the
15330 /// same issue again — two requests on a fresh process, the read and the field write,
15331 /// where the default's `get_task` before `set_task_status` sent three.
15332 async fn set_task_status_reading(
15333 &self,
15334 id: &NativeId,
15335 category: StatusCategory,
15336 ) -> Result<Option<Task>, SourceError> {
15337 // Refused before anything is read, in the words the write itself refuses it.
15338 self.resolved_target(ItemKind::Task, category)?;
15339 let Some(item) = self
15340 .bound_item(id)
15341 .await?
15342 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
15343 else {
15344 return Ok(None);
15345 };
15346 let mut task = item.task()?;
15347 self.held_cache
15348 .lock()
15349 .map_err(|_| SourceError::Unavailable {
15350 message: "items read by issue reference were left inconsistent; run the \
15351 command again"
15352 .into(),
15353 })?
15354 .insert(id.clone(), item);
15355 let Some(status) = self.set_status(id, ItemKind::Task, category).await? else {
15356 return Ok(None);
15357 };
15358 task.status = status;
15359 Ok(Some(task))
15360 }
15361
15362 /// Set one project's status alone, by exactly the paired write a task's status takes, for
15363 /// the project kind: a terminal category selects the project kind's mapped option and then
15364 /// closes the issue with the category's fixed reason — an issue already closed with a
15365 /// reason that reads that category is left as it is — and an open category reopens a closed
15366 /// issue and then selects its option. A board draft is refused a terminal category as a
15367 /// task's is.
15368 async fn set_project_status(
15369 &self,
15370 id: &NativeId,
15371 category: StatusCategory,
15372 ) -> Result<Option<Status>, SourceError> {
15373 self.projectless()?;
15374 self.set_status(id, ItemKind::Project, category).await
15375 }
15376
15377 /// Every task and project whose card contradicts its issue's closure, read through the
15378 /// board's own `is:closed` filter; see [`TaskSource::status_contradictions`].
15379 async fn status_contradictions(&self) -> Result<Vec<StatusContradiction>, SourceError> {
15380 self.contradictions().await
15381 }
15382
15383 /// What this board holds for each issue reference, one request per fifty of them; see
15384 /// [`TaskSource::issue_holdings`].
15385 async fn issue_holdings(&self, issues: &[IssueRef]) -> Result<Vec<IssueHolding>, SourceError> {
15386 self.holdings(issues).await
15387 }
15388
15389 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
15390 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
15391 /// for `none`. Refused by an instance with no `priority_mapping`.
15392 async fn set_task_priority(
15393 &self,
15394 id: &NativeId,
15395 priority: Priority,
15396 ) -> Result<Option<Priority>, SourceError> {
15397 self.set_priority(id, priority).await
15398 }
15399
15400 /// Replace one task's content with a single body update that keeps the metadata slot
15401 /// byte for byte.
15402 async fn set_task_content(
15403 &self,
15404 id: &NativeId,
15405 content: &str,
15406 ) -> Result<Option<()>, SourceError> {
15407 self.replace_content(id, content).await
15408 }
15409
15410 /// Replace one task issue's content and its provenance slot entry with a single body
15411 /// update. The answers are not kept: see `replace_rendering`.
15412 async fn set_task_rendering(
15413 &self,
15414 id: &NativeId,
15415 content: &str,
15416 provenance: &Value,
15417 _answers: &BTreeMap<String, Value>,
15418 ) -> Result<Option<()>, SourceError> {
15419 self.replace_rendering(
15420 id,
15421 BoardKind::Work(ItemKind::Task),
15422 content,
15423 provenance,
15424 None,
15425 )
15426 .await
15427 .map(|written| written.map(|_| ()))
15428 }
15429
15430 /// Replace one design-document issue's content and its provenance slot entry, on exactly
15431 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15432 async fn set_document_rendering(
15433 &self,
15434 id: &NativeId,
15435 content: &str,
15436 provenance: &Value,
15437 _answers: &BTreeMap<String, Value>,
15438 ) -> Result<Option<()>, SourceError> {
15439 self.replace_rendering(id, BoardKind::Document, content, provenance, None)
15440 .await
15441 .map(|written| written.map(|_| ()))
15442 }
15443
15444 /// Replace one project issue's content and its provenance slot entry, on exactly the
15445 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
15446 async fn set_project_rendering(
15447 &self,
15448 id: &NativeId,
15449 content: &str,
15450 provenance: &Value,
15451 _answers: &BTreeMap<String, Value>,
15452 ) -> Result<Option<()>, SourceError> {
15453 self.projectless()?;
15454 self.replace_rendering(
15455 id,
15456 BoardKind::Work(ItemKind::Project),
15457 content,
15458 provenance,
15459 None,
15460 )
15461 .await
15462 .map(|written| written.map(|_| ()))
15463 }
15464
15465 /// Apply a targeted update with one read of the item and a write only for what differs:
15466 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
15467 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
15468 async fn update_task(
15469 &self,
15470 id: &NativeId,
15471 update: &TaskUpdate,
15472 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
15473 self.targeted_update(id, update).await
15474 }
15475
15476 /// Each issue's sub-issue parent and children and its links, read in batches.
15477 async fn read_relations(
15478 &self,
15479 ids: &[NativeId],
15480 ) -> Vec<Result<Option<onetaskgraph_plugin_api::Relations>, SourceError>> {
15481 self.relations_of(ids).await
15482 }
15483
15484 async fn set_parents(
15485 &self,
15486 writes: &[onetaskgraph_plugin_api::ParentWrite],
15487 ) -> Vec<Result<onetaskgraph_plugin_api::ParentAnswer, SourceError>> {
15488 self.parents_set(writes).await
15489 }
15490
15491 fn link_types(&self) -> onetaskgraph_plugin_api::LinkTypes {
15492 self.link_types.clone()
15493 }
15494
15495 async fn add_native_links(
15496 &self,
15497 links: &[onetaskgraph_plugin_api::NativeLink],
15498 ) -> Vec<Result<(), SourceError>> {
15499 self.native_links(links, relations::NativeWrite::Add).await
15500 }
15501
15502 async fn remove_native_links(
15503 &self,
15504 links: &[onetaskgraph_plugin_api::NativeLink],
15505 ) -> Vec<Result<(), SourceError>> {
15506 self.native_links(links, relations::NativeWrite::Remove)
15507 .await
15508 }
15509
15510 /// Every task read together — [`DETAIL_BATCH`] to a [`graphql::ISSUE_DETAILS`] request —
15511 /// and each update's hold and guard decided on that read; then each update the read lets
15512 /// through composed exactly as [`Self::update_task`] composes one, its board field writes
15513 /// and its issue update held back and sent in [`graphql::WRITE_TASKS`] requests: every
15514 /// field write first, then the issue update of every task whose field writes all landed.
15515 ///
15516 /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15517 /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15518 /// that batch is read by itself, the missing one answered missing and the rest as
15519 /// themselves. A batch of one, an update of a board draft, one naming dependencies and one
15520 /// of an item whose body this process has not read are each made on their own, as
15521 /// [`Self::update_task`] makes them, because each sends writes of its own in an order the
15522 /// batch does not hold. A field write GitHub refuses fails that task's update alone and
15523 /// sends nothing of its body.
15524 async fn update_tasks(
15525 &self,
15526 updates: &[onetaskgraph_plugin_api::BatchUpdate],
15527 ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<TaskUpdateOutcome>, SourceError>> {
15528 self.updates_batched(updates).await
15529 }
15530
15531 /// Replace one task's `delivered_by` with a single body update that changes the
15532 /// metadata slot and nothing outside it.
15533 async fn set_delivered_by(
15534 &self,
15535 id: &NativeId,
15536 delivered_by: &[TaskRef],
15537 ) -> Result<Option<()>, SourceError> {
15538 self.replace_delivered_by(id, delivered_by).await
15539 }
15540
15541 /// Set one key of one task issue's metadata with a single body update that changes the
15542 /// metadata slot and nothing outside it — no title, label, state or board field request —
15543 /// and sends nothing when the task already holds that value under the key.
15544 async fn set_task_metadata(
15545 &self,
15546 id: &NativeId,
15547 key: &MetadataKey,
15548 value: &Value,
15549 ) -> Result<Option<Task>, SourceError> {
15550 Ok(self
15551 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
15552 .await?
15553 .map(|item| item.task())
15554 .transpose()?)
15555 }
15556
15557 /// Set one key of one project issue's metadata, on exactly the terms of
15558 /// [`set_task_metadata`](TaskSource::set_task_metadata).
15559 async fn set_project_metadata(
15560 &self,
15561 id: &NativeId,
15562 key: &MetadataKey,
15563 value: &Value,
15564 ) -> Result<Option<Project>, SourceError> {
15565 self.projectless()?;
15566 Ok(self
15567 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
15568 .await?
15569 .map(|item| item.project()))
15570 }
15571
15572 /// Set one key of one design-document issue's metadata, on exactly the terms of
15573 /// [`set_task_metadata`](TaskSource::set_task_metadata).
15574 async fn set_document_metadata(
15575 &self,
15576 id: &NativeId,
15577 key: &MetadataKey,
15578 value: &Value,
15579 ) -> Result<Option<Document>, SourceError> {
15580 Ok(self
15581 .set_slot_key(id, BoardKind::Document, key, value)
15582 .await?
15583 .map(|item| item.document()))
15584 }
15585
15586 /// Take one key out of one issue's metadata slot with a single body update that changes the
15587 /// slot and nothing outside it, sending nothing when the slot does not hold the key.
15588 async fn remove_record_metadata(
15589 &self,
15590 record: onetaskgraph_plugin_api::MetadataRecord,
15591 id: &NativeId,
15592 key: &MetadataKey,
15593 ) -> Result<Option<Option<String>>, SourceError> {
15594 let kind = match record {
15595 onetaskgraph_plugin_api::MetadataRecord::Task => BoardKind::Work(ItemKind::Task),
15596 onetaskgraph_plugin_api::MetadataRecord::Project => BoardKind::Work(ItemKind::Project),
15597 onetaskgraph_plugin_api::MetadataRecord::Document => BoardKind::Document,
15598 };
15599 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
15600 return Ok(None);
15601 };
15602 if item.slot.contains_key(key.as_str()) {
15603 let mut slot = item.slot.clone();
15604 slot.remove(key.as_str());
15605 self.write_slot(&mut item, &slot).await?;
15606 self.remember_written(item.clone(), false)?;
15607 }
15608 Ok(Some(match record {
15609 onetaskgraph_plugin_api::MetadataRecord::Task => item.task()?.version,
15610 onetaskgraph_plugin_api::MetadataRecord::Project => item.project().version,
15611 onetaskgraph_plugin_api::MetadataRecord::Document => item.document().version,
15612 }))
15613 }
15614
15615 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
15616 self.delete_item(id).await
15617 }
15618
15619 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
15620 self.projectless()?;
15621 self.delete_item(id).await
15622 }
15623
15624 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
15625 self.delete_item(id).await
15626 }
15627
15628 /// One page of the task issue's own comments, walked by GitHub's own cursor.
15629 ///
15630 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
15631 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
15632 ///
15633 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
15634 /// board is the read of its comments. A draft this process already resolved is refused
15635 /// without one.
15636 async fn task_comments(
15637 &self,
15638 task: &NativeId,
15639 page: &PageRequest,
15640 ) -> Result<Option<Page<Comment>>, SourceError> {
15641 validate_page(page)?;
15642 let cached = self.resolved_cache()?.get(task).cloned();
15643 if let Some(item) = cached {
15644 if item.kind != BoardKind::Work(ItemKind::Task) {
15645 return Ok(None);
15646 }
15647 if item.content_kind == ContentKind::DraftIssue {
15648 return Err(self.draft_has_no_comments(task));
15649 }
15650 }
15651 // The first page a comment-activity read just read of this task, at the size asked, is
15652 // that read's answer: taken, so it answers once.
15653 if page.cursor.is_none()
15654 && page.limit >= MAX_PAGE_SIZE
15655 && let Some(held) = self.held_comments()?.remove(task)
15656 {
15657 return Ok(Some(held));
15658 }
15659 match self.issue_detail(task, page).await? {
15660 Some(TaskDetailRead {
15661 comments: Some(comments),
15662 ..
15663 }) => comments,
15664 _ => Ok(None),
15665 }
15666 }
15667
15668 /// Every id's task, with the first page of its comments when `comments` names it:
15669 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
15670 /// comments in one [`graphql::ISSUE_DETAIL`] request.
15671 async fn get_task_details(
15672 &self,
15673 ids: &[NativeId],
15674 comments: Option<&PageRequest>,
15675 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15676 if let Some(page) = comments
15677 && let Err(error) = validate_page(page)
15678 {
15679 return ids.iter().map(|_| Err(error.clone())).collect();
15680 }
15681 match (ids, comments) {
15682 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
15683 ([id], None) => vec![self.task_read(id).await],
15684 _ => self.issue_details(ids, comments).await,
15685 }
15686 }
15687
15688 /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
15689 /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
15690 /// request, so the dependency read a copy makes of each next is answered from this one.
15691 async fn get_task_details_for_copy(
15692 &self,
15693 ids: &[NativeId],
15694 comments: &PageRequest,
15695 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
15696 // A page past the first is a page of one task's comments, which the detail read of it
15697 // alone answers.
15698 if comments.cursor.is_some() {
15699 return self.get_task_details(ids, Some(comments)).await;
15700 }
15701 if let Err(error) = validate_page(comments) {
15702 return ids.iter().map(|_| Err(error.clone())).collect();
15703 }
15704 let mut read = Vec::with_capacity(ids.len());
15705 for batch in ids.chunks(COPY_DETAIL_BATCH) {
15706 match self
15707 .graphql(
15708 graphql::ISSUE_COPY_DETAILS,
15709 copy_detail_batch(batch, comments, false),
15710 )
15711 .await
15712 {
15713 Ok(data) => {
15714 for (slot, id) in batch.iter().enumerate() {
15715 let read_one = match data.get(format!("i{slot}")) {
15716 Some(node) => self.detail_of(id, node, true, None).await,
15717 None => Err(SourceError::Malformed {
15718 message: format!(
15719 "GitHub answered a batch read with no item for {}",
15720 id.0
15721 ),
15722 }),
15723 };
15724 read.push(read_one);
15725 }
15726 }
15727 Err(error) if unresolvable_node(&error) => {
15728 for id in batch {
15729 read.push(self.issue_detail(id, comments).await);
15730 }
15731 }
15732 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
15733 }
15734 }
15735 read
15736 }
15737
15738 /// Every id's item, whatever kind it is, read [`COPY_DETAIL_BATCH`] at a time with
15739 /// [`graphql::ISSUE_COPY_DETAILS`] — the document a copy reads the tasks it carries with —
15740 /// and held for the next read of each by id; see `read_ahead`.
15741 ///
15742 /// What this run wrote is never read: its own record answers, as it does for a read by id.
15743 /// Nor is an item a listing of its project carried whole earlier in this command — see
15744 /// `carried` — which is held in place of reading it.
15745 /// A batch GitHub refuses because one of its ids resolves to no node at all holds nothing,
15746 /// so each of its ids is read on its own and answered as that read answers; a draft, and an
15747 /// answer naming another issue than the one asked for, are left to their own reads too.
15748 async fn read_ahead_for_copy(&self, ids: &[NativeId]) -> Result<(), SourceError> {
15749 // The origins asked just before go out now, beside the members of the one project this
15750 // names when it names one; a project those origins answered whole is held for its next
15751 // read by id rather than read again here.
15752 let hinted = match ids {
15753 [one] => Some(one),
15754 _ => None,
15755 };
15756 self.send_pending_origins(hinted).await?;
15757 if let Some(project) = hinted {
15758 let answered = self.resolved_cache()?.get(project).cloned();
15759 if let Some(answered) = answered
15760 && answered.kind == BoardKind::Work(ItemKind::Project)
15761 {
15762 self.read_ahead()?.insert(project.clone(), Some(answered));
15763 return Ok(());
15764 }
15765 }
15766 let mut unread: Vec<NativeId> = Vec::new();
15767 // Read whole by a listing of its project in this command, which a mutation naming it
15768 // would have dropped: held as a batch would have held it, once every batch is answered.
15769 let mut listed: Vec<NativeId> = Vec::new();
15770 {
15771 let created = self.created()?;
15772 let held = self.read_ahead()?;
15773 let carried = self.carried()?;
15774 for id in ids {
15775 if unread.contains(id)
15776 || listed.contains(id)
15777 || held.contains_key(id)
15778 || created.iter().any(|own| own.id == *id)
15779 {
15780 continue;
15781 }
15782 // An issue a listing carried without the whole of what blocks it is read in a
15783 // batch too, which carries that, rather than having its dependency read sent
15784 // on its own once the copy reconciles its edges.
15785 let whole = carried.get(id).is_some_and(|item| {
15786 item.content_kind != ContentKind::Issue || item.blocked_by.is_some()
15787 });
15788 if whole {
15789 listed.push(id.clone());
15790 } else {
15791 unread.push(id.clone());
15792 }
15793 }
15794 }
15795 // Nothing of an item's comments is wanted here: the smallest page keeps the read to
15796 // what the item itself costs.
15797 let comments = PageRequest {
15798 cursor: None,
15799 limit: 1,
15800 };
15801 // Held only once every batch has been answered, so a read-ahead that fails part way
15802 // holds nothing — what a listing carried included, which stays carried — and every
15803 // item is read on its own, as the method promises.
15804 let mut read: Vec<(NativeId, Option<Resolved>)> = Vec::new();
15805 // The issues the first id names as sub-issues, as `ISSUE_COPY_DETAILS`'s `children`
15806 // answered them: what a project copy's orphan check asks, when that first id is its
15807 // project.
15808 let mut children: Option<(NativeId, Vec<(String, String)>)> = None;
15809 for (index, batch) in unread.chunks(COPY_DETAIL_BATCH).enumerate() {
15810 let data = match self
15811 .graphql(
15812 graphql::ISSUE_COPY_DETAILS,
15813 copy_detail_batch(batch, &comments, index == 0),
15814 )
15815 .await
15816 {
15817 Ok(data) => data,
15818 Err(error) if unresolvable_node(&error) => continue,
15819 Err(error) => return Err(error),
15820 };
15821 for (slot, id) in batch.iter().enumerate() {
15822 let Some(node) = data.get(format!("i{slot}")) else {
15823 continue;
15824 };
15825 let held = if node.is_null() {
15826 None
15827 } else {
15828 match optional_str(node, "__typename")? {
15829 Some("DraftIssue") => continue,
15830 Some("Issue") if required_str(node, "id")? != id.0 => continue,
15831 _ => {}
15832 }
15833 // Remembered below with the rest, so a later batch refused leaves no
15834 // dependency read answered from this one either.
15835 self.resolve_issue_as(node, false).await?
15836 };
15837 read.push((id.clone(), held));
15838 }
15839 if index == 0
15840 && let Some(node) = data.get("children").filter(|node| !node.is_null())
15841 && node.get("id").and_then(Value::as_str) == batch.first().map(|id| id.0.as_str())
15842 && node.pointer("/subIssues/pageInfo/hasNextPage") == Some(&Value::Bool(false))
15843 {
15844 // A list GitHub did not answer is not an empty one: it would answer the project's
15845 // task listing with nothing, and the orphan check with no orphans.
15846 let listed = node
15847 .pointer("/subIssues/nodes")
15848 .and_then(Value::as_array)
15849 .ok_or_else(|| SourceError::Malformed {
15850 message: format!(
15851 "GitHub answered the sub-issues of project {} with no list of them",
15852 batch[0].0
15853 ),
15854 })?
15855 .iter()
15856 .map(|child| {
15857 Ok((
15858 required_str(child, "id")?.to_owned(),
15859 required_str(child, "title")?.to_owned(),
15860 ))
15861 })
15862 .collect::<Result<Vec<_>, SourceError>>()?;
15863 children = Some((batch[0].clone(), listed));
15864 }
15865 }
15866 // The project's tasks are known from this read alone when every child it named is held
15867 // from it, but for a document, which its title says is one and which a task listing
15868 // never answers: then a listing of the project's tasks is answered from them.
15869 if let Some((project, listed)) = children
15870 && read.iter().any(|(id, held)| {
15871 *id == project
15872 && held
15873 .as_ref()
15874 .is_some_and(|held| held.kind == BoardKind::Work(ItemKind::Project))
15875 })
15876 {
15877 let mut tasks = Vec::new();
15878 let mut whole = true;
15879 for (child, title) in &listed {
15880 match read.iter().find(|(id, _)| id.0 == *child) {
15881 Some((_, Some(held))) if held.parent.as_ref() == Some(&project) => {
15882 if held.kind == BoardKind::Work(ItemKind::Task) {
15883 tasks.push(held.clone());
15884 }
15885 }
15886 _ if title.starts_with(DESIGN_TITLE_PREFIX) => {}
15887 _ => whole = false,
15888 }
15889 }
15890 if whole {
15891 self.task_children()?.insert(project, tasks);
15892 }
15893 }
15894 let mut resolved = self.resolved_cache()?;
15895 let mut ahead = self.read_ahead()?;
15896 let mut carried = self.carried()?;
15897 for item in read.iter().filter_map(|(_, held)| held.as_ref()) {
15898 resolved.insert(item.id.clone(), item.clone());
15899 }
15900 // Taken only if still carried: a mutation naming it while the batches ran dropped it,
15901 // and its next read is then its own.
15902 for id in &listed {
15903 let Some(item) = carried.remove(id) else {
15904 continue;
15905 };
15906 // The whole of what it is blocked by came with it, so the dependency read a copy
15907 // makes next is answered from this one, as a copy's detail read is.
15908 if item.blocked_by.is_some() {
15909 resolved.insert(item.id.clone(), item.clone());
15910 }
15911 read.push((id.clone(), Some(item)));
15912 }
15913 for (id, held) in read {
15914 ahead.insert(id, held);
15915 }
15916 Ok(())
15917 }
15918
15919 /// Add one comment to the task's issue, as the account the token belongs to.
15920 ///
15921 /// The author is refused before anything is sent — not even the task is read — because
15922 /// no answer GitHub could give would make posting under another name than the one asked
15923 /// for the right outcome.
15924 async fn add_comment(
15925 &self,
15926 task: &NativeId,
15927 comment: &NewComment,
15928 ) -> Result<Option<Comment>, SourceError> {
15929 if let Some(author) = &comment.author {
15930 return Err(SourceError::Refused {
15931 message: format!(
15932 "source {} cannot post a comment as {author:?}: GitHub records the account \
15933 the token signs in as the author of every comment; next: leave --author \
15934 out, and the comment is posted as that account",
15935 self.name
15936 ),
15937 });
15938 }
15939 let Some(issue) = self.commented_issue(task).await? else {
15940 return Ok(None);
15941 };
15942 let data = self
15943 .graphql(
15944 graphql::ADD_COMMENT,
15945 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
15946 )
15947 .await?;
15948 let subject = data
15949 .pointer("/addComment/subject")
15950 .filter(|value| !value.is_null())
15951 .ok_or_else(|| SourceError::Malformed {
15952 message: "GitHub comment addition returned no subject".into(),
15953 })?;
15954 if required_str(subject, "id")? != issue.0 {
15955 return Err(SourceError::Malformed {
15956 message: "GitHub comment addition answered about another issue".into(),
15957 });
15958 }
15959 let added = data
15960 .pointer("/addComment/commentEdge/node")
15961 .filter(|value| !value.is_null())
15962 .ok_or_else(|| SourceError::Malformed {
15963 message: "GitHub comment addition returned no comment".into(),
15964 })?;
15965 let added = comment_from(added)?;
15966 self.remember_commented(&issue)?;
15967 Ok(Some(added))
15968 }
15969
15970 /// Every task commented on read together — [`DETAIL_BATCH`] to a
15971 /// [`graphql::ISSUE_DETAILS`] request — and each comment's hold decided on that read; then
15972 /// every comment it lets through added by aliased `addComment`s of one
15973 /// [`graphql::ADD_COMMENTS`] request per [`COMMENT_BATCH`]. GitHub answers a refusal of one
15974 /// alias at that alias's path, so it fails that comment alone.
15975 ///
15976 /// **Where GitHub would refuse the batch as a whole, each item is read on its own:** a
15977 /// batched node read naming an id that resolves to nothing is refused whole, so each id of
15978 /// that batch is read by itself, the missing one answered missing. A batch of one is the
15979 /// comment alone, as [`Self::add_comment`] adds it.
15980 async fn add_comments(
15981 &self,
15982 comments: &[onetaskgraph_plugin_api::BatchComment],
15983 ) -> Vec<Result<onetaskgraph_plugin_api::BatchAnswer<Comment>, SourceError>> {
15984 self.comments_batched(comments).await
15985 }
15986
15987 async fn edit_comment(
15988 &self,
15989 task: &NativeId,
15990 comment: &NativeId,
15991 body: &CommentBody,
15992 ) -> Result<Option<Comment>, SourceError> {
15993 let Some(issue) = self.commented_issue(task).await? else {
15994 return Ok(None);
15995 };
15996 let Some(held) = self.comment_on(&issue, comment).await? else {
15997 return Ok(None);
15998 };
15999 // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
16000 let body = CommentOrigin::kept(&held, body)?;
16001 let data = self
16002 .graphql(
16003 graphql::UPDATE_COMMENT,
16004 json!({"input":{"id":comment.0,"body":body.as_str()}}),
16005 )
16006 .await?;
16007 let edited = data
16008 .pointer("/updateIssueComment/issueComment")
16009 .filter(|value| !value.is_null())
16010 .ok_or_else(|| SourceError::Malformed {
16011 message: "GitHub comment update returned no comment".into(),
16012 })?;
16013 let edited = comment_from(edited)?;
16014 if edited.id != *comment {
16015 return Err(SourceError::Malformed {
16016 message: "GitHub comment update returned the wrong comment".into(),
16017 });
16018 }
16019 self.remember_commented(&issue)?;
16020 Ok(Some(edited))
16021 }
16022
16023 async fn delete_comment(
16024 &self,
16025 task: &NativeId,
16026 comment: &NativeId,
16027 ) -> Result<Option<NativeId>, SourceError> {
16028 let Some(issue) = self.commented_issue(task).await? else {
16029 return Ok(None);
16030 };
16031 if !self.comment_is_on(&issue, comment).await? {
16032 return Ok(None);
16033 }
16034 self.held_comments()?.remove(&issue);
16035 let data = self
16036 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
16037 .await?;
16038 // The payload says nothing about the comment it removed, so what is checked is that
16039 // GitHub answered the mutation at all rather than leaving it unanswered.
16040 data.get("deleteIssueComment")
16041 .filter(|value| !value.is_null())
16042 .ok_or_else(|| SourceError::Malformed {
16043 message: "GitHub comment deletion returned no payload".into(),
16044 })?;
16045 Ok(Some(comment.clone()))
16046 }
16047
16048 /// Every request this source has recorded, and what each of GitHub's two budgets was
16049 /// attributed — read off the same accounting the session report is rendered from, so
16050 /// the two cannot count one request two ways.
16051 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
16052 Ok(Some(self.ledger.snapshot().metering()))
16053 }
16054
16055 /// Drop every item, search answer and board read this source holds, so the next command
16056 /// reads the board as a person has since left it.
16057 ///
16058 /// Every one of those is held on the assumption that nothing but this source writes the
16059 /// board while a command runs, which stops being true the moment the command is over: a
16060 /// body a person edited would be overwritten from the record held here, and a card they
16061 /// moved would be read as still where this source left it. The board's own field
16062 /// definitions go too, because a person can add or delete a `Status` option and a write
16063 /// resolved against the held list would not re-read on a miss. What stays is what stays
16064 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
16065 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
16066 /// accounting [`metering`](TaskSource::metering) answers from.
16067 ///
16068 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
16069 /// refused, because clearing it is what puts it right.
16070 async fn end_command(&self) -> Result<(), SourceError> {
16071 fn clear<T: Default>(held: &Mutex<T>) {
16072 *held
16073 .lock()
16074 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
16075 held.clear_poison();
16076 }
16077 clear(&self.created);
16078 clear(&self.updated);
16079 clear(&self.commented);
16080 clear(&self.board_cache);
16081 clear(&self.search_cache);
16082 clear(&self.narrowed_cache);
16083 clear(&self.search_next);
16084 clear(&self.resolved_cache);
16085 clear(&self.read_ahead);
16086 clear(&self.pending_origins);
16087 clear(&self.member_classes);
16088 clear(&self.carried);
16089 clear(&self.held_cache);
16090 clear(&self.children_cache);
16091 clear(&self.task_children);
16092 clear(&self.written);
16093 clear(&self.fields_cache);
16094 clear(&self.held_comments);
16095 clear(&self.task_writes);
16096 clear(&self.written_tokens);
16097 self.expected.end_command();
16098 Ok(())
16099 }
16100}
16101
16102/// One member of a project as a classification-only read answers it: its classification and
16103/// the repositories its work changes.
16104type MemberClass = (Classification, Vec<Repository>);
16105
16106/// Each project's sub-issues as one command read them, keyed by the selector they were asked
16107/// for under, beside the project that selector named; see `children_cache`.
16108type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
16109
16110/// One issue comment as the contract carries it.
16111///
16112/// `author` is absent both when GitHub answers `null` for an account that no longer exists
16113/// and when it answers an actor with no login, because either way the source did not say who
16114/// wrote it — which is what an absent author means, rather than an author called nothing.
16115///
16116/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
16117/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
16118/// and is read back with the marker taken out and reported as its origin.
16119fn comment_from(value: &Value) -> Result<Comment, SourceError> {
16120 let actor = value.get("author").unwrap_or(&Value::Null);
16121 // A bot's login is spelled as GitHub's own pages spell it, `<login>[bot]`, so a reader —
16122 // and every copy of the comment — can tell it from a person's.
16123 let bot = actor.get("__typename").and_then(Value::as_str) == Some("Bot");
16124 Ok(Comment {
16125 origin: None,
16126 id: NativeId(required_str(value, "id")?.to_owned()),
16127 author: optional_str(actor, "login")?.map(|login| {
16128 if bot && !login.ends_with("[bot]") {
16129 format!("{login}[bot]")
16130 } else {
16131 login.to_owned()
16132 }
16133 }),
16134 created_at: optional_time(value, "createdAt")?,
16135 updated_at: optional_time(value, "updatedAt")?,
16136 body: required_str(value, "body")?.to_owned(),
16137 url: optional_str(value, "url")?.map(str::to_owned),
16138 }
16139 .unmarked())
16140}
16141
16142/// The page of comments one issue node carries, resumed from `after`.
16143fn comment_page(
16144 node: &Value,
16145 issue: &str,
16146 after: Option<&str>,
16147) -> Result<Page<Comment>, SourceError> {
16148 let connection = node
16149 .get("comments")
16150 .filter(|value| !value.is_null())
16151 .ok_or_else(|| SourceError::Malformed {
16152 message: format!("GitHub issue {issue} answered with no comments connection"),
16153 })?;
16154 let items = optional_nodes(Some(connection), "issue comments")?
16155 .into_iter()
16156 .flatten()
16157 .map(comment_from)
16158 .collect::<Result<Vec<_>, _>>()?;
16159 let next = next_cursor(connection)?;
16160 if let Some(next) = &next {
16161 validate_cursor_progress(after, &next.0)?;
16162 }
16163 Ok(Page { items, next })
16164}
16165
16166/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
16167/// end — `None` when it carried none, or a page with more past it.
16168fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
16169 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
16170 return Ok(None);
16171 };
16172 if next_cursor(connection)?.is_some() {
16173 return Ok(None);
16174 }
16175 Ok(Some(
16176 optional_nodes(Some(connection), "blocked-by issues")?
16177 .into_iter()
16178 .flatten()
16179 .cloned()
16180 .collect(),
16181 ))
16182}
16183
16184/// Where the recorded tail of a dependency walk resumes; see
16185/// [`GitHubProjectsSource::recorded_edges`].
16186const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
16187
16188/// The board text field this source keeps a copy's origin in.
16189///
16190/// Named after the key it holds, and held to that name by the guard below rather than by
16191/// a reader noticing.
16192const ORIGIN_FIELD: &str = "onetaskgraph.origin";
16193
16194/// The metadata key that field holds.
16195///
16196/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
16197/// constructs or interprets the qualified id it carries. This source names it only to
16198/// route it — a short, typed value belongs in a typed field rather than in the body slot
16199/// a caller's own prose shares.
16200///
16201/// Restated rather than imported, because no plugin crate may depend on the engine. What
16202/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
16203/// target in `check`: it reads the engine's own literal and fails naming the file and the
16204/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
16205/// that creates a second item every run instead of finding the one it wrote — and that is
16206/// too late to learn it.
16207const ORIGIN_KEY: &str = "onetaskgraph.origin";
16208
16209/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
16210///
16211/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
16212/// is derived from the far end, never written down on the near item — so only a forward
16213/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
16214/// it did not come from, and it is told so rather than answered with an empty page that
16215/// reads as a walk which ended.
16216fn recorded_offset(
16217 cursor: Option<&str>,
16218 direction: Direction,
16219) -> Result<Option<usize>, SourceError> {
16220 cursor
16221 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
16222 .map(|offset| {
16223 if direction != Direction::DependsOn {
16224 return Err(SourceError::Config {
16225 message: format!(
16226 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
16227 reverse dependency read never issues; resume it in the direction \
16228 that reported it"
16229 ),
16230 });
16231 }
16232 offset.parse().map_err(|_| SourceError::Config {
16233 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
16234 })
16235 })
16236 .transpose()
16237}
16238
16239/// What a dependency read's own answer says of a board draft: `None` when the item is not a
16240/// draft, or the answer did not carry its board item; `Some(None)` for a draft whose one board
16241/// item is not on this board, which this source does not hold; and `Some(Some(slot))`, the
16242/// draft's metadata, for one it does — its board item naming this board by its number and by
16243/// the id the same answer read of the board, as [`GitHubProjectsSource::draft_by_id`] decides.
16244fn draft_slot(
16245 data: &Value,
16246 id: &NativeId,
16247 project_number: u32,
16248) -> Result<Option<Option<BTreeMap<String, Value>>>, SourceError> {
16249 let Some(draft) = data
16250 .get("node")
16251 .filter(|node| node.get("__typename").and_then(Value::as_str) == Some("DraftIssue"))
16252 else {
16253 return Ok(None);
16254 };
16255 let Some(items) = data.pointer("/draft/projectV2Items") else {
16256 return Ok(None);
16257 };
16258 if required_str(draft, "id")? != id.0 {
16259 return Err(SourceError::Malformed {
16260 message: format!("GitHub answered a different draft for {}", id.0),
16261 });
16262 }
16263 let nodes = items
16264 .get("nodes")
16265 .and_then(Value::as_array)
16266 .ok_or_else(|| SourceError::Malformed {
16267 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
16268 })?;
16269 let info = items
16270 .get("pageInfo")
16271 .ok_or_else(|| SourceError::Malformed {
16272 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
16273 })?;
16274 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
16275 return Err(SourceError::Malformed {
16276 message: format!(
16277 "GitHub draft {} reports more board items than the one GitHub links a draft to",
16278 id.0
16279 ),
16280 });
16281 }
16282 let board = data
16283 .pointer("/board/projectV2/id")
16284 .and_then(Value::as_str)
16285 .ok_or_else(|| SourceError::Malformed {
16286 message: format!(
16287 "GitHub answered draft {} without this board's own id beside it",
16288 id.0
16289 ),
16290 })?;
16291 let mut held = false;
16292 for node in nodes {
16293 let project = node.get("project").ok_or_else(|| SourceError::Malformed {
16294 message: format!("GitHub draft {} board item has no project", id.0),
16295 })?;
16296 let number = project
16297 .get("number")
16298 .and_then(Value::as_u64)
16299 .ok_or_else(|| SourceError::Malformed {
16300 message: format!(
16301 "GitHub draft {} board item has no numeric project number",
16302 id.0
16303 ),
16304 })?;
16305 let project_id = required_str(project, "id")?;
16306 held |= number == u64::from(project_number) && project_id == board;
16307 }
16308 if !held {
16309 return Ok(Some(None));
16310 }
16311 Ok(Some(Some(
16312 metadata_body(optional_str(draft, "body")?.map(str::to_owned))?.1,
16313 )))
16314}
16315
16316fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
16317 let mut page = offset_page(edges, offset, limit.max(1));
16318 page.next = page
16319 .next
16320 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
16321 page
16322}
16323
16324/// The kind of one issue reached through a dependency connection.
16325///
16326/// The same questions the board scan asks, over the fields the dependency document
16327/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
16328/// then anything with sub-issues or the marker is a project.
16329///
16330/// # Errors
16331///
16332/// A far end this board holds as a document is refused rather than reported. The two
16333/// answers that are not refusals would both be wrong: reporting it as a task names an id
16334/// no task read of this source can find, and reporting it as a project names one no
16335/// project read can. There is no third value to return — `ItemKind` has no document
16336/// variant, because nothing may point at a document — so the relationship itself is what
16337/// the person is told about.
16338fn related_kind(value: &Value, meaning: SubIssues) -> Result<ItemKind, SourceError> {
16339 let id = required_str(value, "id")?;
16340 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
16341 return Err(SourceError::Refused {
16342 message: format!(
16343 "GitHub issue {id} is a document of this board — its title begins \
16344 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
16345 on by one; next: remove that issue's blocking relationship on this board"
16346 ),
16347 });
16348 }
16349 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
16350 if parent.is_some() || meaning == SubIssues::Parent {
16351 return Ok(ItemKind::Task);
16352 }
16353 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
16354 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
16355 message: format!("GitHub issue {id}: {message}"),
16356 })?;
16357 let sub_issues = sub_issue_total(value)?;
16358 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
16359 ItemKind::Project
16360 } else {
16361 ItemKind::Task
16362 })
16363}
16364
16365/// The `stateInput` that closes an issue for `reason`.
16366fn closed_input(reason: ClosedState) -> Value {
16367 json!({"value":"CLOSED","stateReason":reason.reason()})
16368}
16369
16370/// The `IssueStateUpdateInput` one status target asks for.
16371///
16372/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
16373/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
16374/// a currently-closed issue: without that the item would read back `Unknown` and a copy
16375/// would report a change forever. A document has no status at all, and asks for neither.
16376fn state_input(target: Option<&StatusTarget>) -> Value {
16377 match target {
16378 Some(StatusTarget::Terminal(_, reason)) => closed_input(*reason),
16379 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
16380 // A document has no status, so a write of one says nothing about the issue's open
16381 // or closed state rather than forcing it open: `stateInput` is what carries that
16382 // instruction, and an explicit null asks for no change to it.
16383 None => Value::Null,
16384 }
16385}
16386
16387/// The metadata one write stores in the item's body slot.
16388///
16389/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
16390/// rather than carried: the kind marker so an empty project stays readable, the
16391/// repository list only when it is not exactly the issue's own repository, and the far
16392/// ends no relationship here can name.
16393///
16394/// The copy origin is the one typed field that is also mirrored here, and only as a
16395/// mirror: it lands in the board's origin field as well, which stays the one every reader
16396/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
16397/// and catches up with a write in seconds rather than minutes — can find the item by it.
16398/// A reader of the release before this one drops the slot's copy and reads the field, so an
16399/// item written here still reads with exactly one origin there.
16400fn slot_metadata(
16401 incoming: &Incoming<'_>,
16402 own_repository: Option<&Repository>,
16403 fallback: &[DependencyEdge],
16404) -> BTreeMap<String, Value> {
16405 let mut metadata = incoming.metadata.clone();
16406 match metadata.remove(ORIGIN_KEY) {
16407 Some(Value::String(origin)) if !origin.is_empty() => {
16408 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
16409 }
16410 _ => {}
16411 }
16412 match incoming.written.kind() {
16413 BoardKind::Work(kind) => metadata.insert(
16414 ItemKind::METADATA_KEY.to_owned(),
16415 Value::String(kind.marker().to_owned()),
16416 ),
16417 // A document is told by its title, so it carries no kind marker: that key names
16418 // what a dependency endpoint points at, and nothing may point at a document.
16419 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
16420 };
16421 incoming.classification.record(&mut metadata);
16422 let derivable = own_repository
16423 .map(|own| incoming.repositories == [own.clone()])
16424 .unwrap_or(incoming.repositories.is_empty());
16425 if derivable {
16426 metadata.remove(Repository::METADATA_KEY);
16427 } else {
16428 metadata.insert(
16429 Repository::METADATA_KEY.to_owned(),
16430 Value::Array(
16431 incoming
16432 .repositories
16433 .iter()
16434 .map(|repository| Value::String(repository.as_str().to_owned()))
16435 .collect(),
16436 ),
16437 );
16438 }
16439 // The typed lists are what land, whatever the caller's own metadata held under their
16440 // keys: a key of either name travelling beside the field would otherwise be a second
16441 // answer to the same question, and the field is the one the contract names.
16442 for (key, entries) in [
16443 (TaskRef::DELIVERS_KEY, incoming.delivers),
16444 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
16445 ] {
16446 set_task_list(&mut metadata, key, entries);
16447 }
16448 record_edges(&mut metadata, fallback);
16449 metadata
16450}
16451
16452/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
16453/// one slot's metadata, or no such key when there are none.
16454fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
16455 if fallback.is_empty() {
16456 metadata.remove(DependencyEdge::RECORDED_KEY);
16457 } else {
16458 metadata.insert(
16459 DependencyEdge::RECORDED_KEY.to_owned(),
16460 Value::Array(
16461 fallback
16462 .iter()
16463 .map(|edge| {
16464 serde_json::to_value(&edge.to).expect("an endpoint renders as JSON")
16465 })
16466 .collect(),
16467 ),
16468 );
16469 }
16470}
16471
16472/// Every label one item carries, from its content's own connection and nowhere else.
16473///
16474/// There is no second place to read one from: no document this source sends selects the
16475/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
16476/// cannot carry one at all. The module documentation records the three schema facts that
16477/// settle it.
16478fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
16479 optional_nodes(content.get("labels"), "content labels")?
16480 .into_iter()
16481 .flatten()
16482 .map(|v| {
16483 Ok(Label {
16484 id: NativeId(required_str(v, "id")?.to_owned()),
16485 name: required_str(v, "name")?.to_owned(),
16486 color: optional_str(v, "color")?.map(str::to_owned),
16487 })
16488 })
16489 .collect()
16490}
16491
16492/// Each board field value one item holds, as the change token's facets name it: the field's
16493/// name, with the single-select option's name or the text. A value of a type no read here
16494/// selects, and one naming no field, says nothing a person can rename and is left out.
16495fn facet_fields(field_values: &[Value]) -> Vec<(String, String)> {
16496 field_values
16497 .iter()
16498 .filter_map(|value| {
16499 let field = value.pointer("/field/name")?.as_str()?;
16500 let held = value.get("name").or_else(|| value.get("text"))?.as_str()?;
16501 Some((field.to_owned(), held.to_owned()))
16502 })
16503 .collect()
16504}
16505
16506/// The digest of the facets of the issue `content`, whose board item's values are
16507/// `field_values` — or `None` when the response did not carry, whole, every member they are read
16508/// from: the title, the parent, the sub-issue summary and the body its kind is read from, every
16509/// label, and every field value.
16510///
16511/// What a write's own response spells its token from, by exactly the rules a read takes them by.
16512fn facets_of(content: &Value, field_values: &Value) -> Option<change_token::FacetsDigest> {
16513 let ended = |connection: &Value| {
16514 connection.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(false))
16515 };
16516 let labels = content.get("labels").filter(|labels| ended(labels))?;
16517 if !ended(field_values) {
16518 return None;
16519 }
16520 let parent = content.get("parent")?;
16521 let (_, slot) = metadata_body(content.get("body")?.as_str().map(str::to_owned)).ok()?;
16522 let kind = BoardKind::of(
16523 content.get("title")?.as_str()?,
16524 !parent.is_null(),
16525 sub_issue_total(content).ok()?,
16526 ItemKind::from_metadata(&slot).ok()?,
16527 );
16528 let labels = self::labels(&json!({"labels": labels})).ok()?;
16529 let nodes = field_values.get("nodes")?.as_array()?;
16530 Some(change_token::Facets::new(kind.facet(), facet_fields(nodes), &labels).digest())
16531}
16532
16533/// The definition of each board field one item's values are values of, in the shape a read
16534/// of the board's own `fields` gives one.
16535///
16536/// A value names its field through a fragment on that field's own type, so the type is
16537/// known from which kind of value it is: a single-select value's field is a
16538/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
16539/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
16540fn field_definitions(field_values: &[Value]) -> Vec<Value> {
16541 field_values
16542 .iter()
16543 .filter_map(|value| {
16544 let field = value.get("field")?.as_object()?;
16545 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
16546 let typename = if value.get("text").is_some() {
16547 "ProjectV2Field"
16548 } else if value.get("name").is_some() {
16549 "ProjectV2SingleSelectField"
16550 } else {
16551 return None;
16552 };
16553 let mut defined = field.clone();
16554 defined.insert("__typename".to_owned(), json!(typename));
16555 // Only a text field holds a text value, so the value says the field's type too.
16556 if typename == "ProjectV2Field" {
16557 defined.insert("dataType".to_owned(), json!("TEXT"));
16558 }
16559 Some(Value::Object(defined))
16560 })
16561 .collect()
16562}
16563
16564fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
16565 let Some(node) = field_values
16566 .iter()
16567 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
16568 else {
16569 return Ok(None);
16570 };
16571 Ok(optional_str(node, "text")?.map(str::to_owned))
16572}
16573
16574fn valid_github_owner(owner: &str) -> bool {
16575 !owner.is_empty()
16576 && owner.len() <= 39
16577 && !owner.starts_with('-')
16578 && !owner.ends_with('-')
16579 && !owner.contains("--")
16580 && owner
16581 .bytes()
16582 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
16583}
16584
16585/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
16586/// neither of the two names a path segment already means.
16587fn valid_github_repository_name(name: &str) -> bool {
16588 !name.is_empty()
16589 && name.len() <= 100
16590 && name != "."
16591 && name != ".."
16592 && name
16593 .bytes()
16594 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
16595}
16596
16597fn valid_environment_name(name: &str) -> bool {
16598 let mut bytes = name.bytes();
16599 bytes
16600 .next()
16601 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
16602 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
16603}
16604
16605/// How many sub-issues one issue has.
16606///
16607/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
16608/// absent or non-integer one is a response this source cannot read — and reading it as
16609/// zero would classify a project as a task, which is exactly the mistake the marker
16610/// exists to keep from happening quietly.
16611fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
16612 let summary = issue
16613 .get("subIssuesSummary")
16614 .ok_or_else(|| SourceError::Malformed {
16615 message: "GitHub issue is missing subIssuesSummary".into(),
16616 })?;
16617 summary
16618 .get("total")
16619 .and_then(Value::as_u64)
16620 .ok_or_else(|| SourceError::Malformed {
16621 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
16622 })
16623}
16624
16625/// One issue's own `number`.
16626///
16627/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
16628/// an issue in this module asks for it. So a read of one that comes back without it, or
16629/// with something that is not an unsigned integer, is a response this source cannot read —
16630/// absence here is **not** "this issue has no number". A draft is the content that has
16631/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
16632/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
16633fn issue_number(issue: &Value) -> Result<u64, SourceError> {
16634 issue
16635 .get("number")
16636 .and_then(Value::as_u64)
16637 .ok_or_else(|| SourceError::Malformed {
16638 message: "GitHub issue number is missing or is not an unsigned integer".into(),
16639 })
16640}
16641
16642/// The `number` a creating mutation answered with, and `None` when it answered without one;
16643/// why a missing one is tolerated is at the call in `create_and_file_issue`.
16644fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
16645 match created.get("number") {
16646 None | Some(Value::Null) => Ok(None),
16647 Some(value) => value
16648 .as_u64()
16649 .map(Some)
16650 .ok_or_else(|| SourceError::Malformed {
16651 message: "GitHub created issue number is not an unsigned integer".into(),
16652 }),
16653 }
16654}
16655
16656fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16657 value
16658 .get(field)
16659 .and_then(Value::as_str)
16660 .ok_or_else(|| SourceError::Malformed {
16661 message: format!("GitHub response is missing string field {field}"),
16662 })
16663}
16664
16665fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
16666 let found = required_str(value, field)?;
16667 if found.trim().is_empty() {
16668 return Err(SourceError::Malformed {
16669 message: format!("GitHub response has blank string field {field}"),
16670 });
16671 }
16672 Ok(found)
16673}
16674
16675/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
16676/// needs one — Linear spells them too, in its own description field.
16677///
16678/// Restated rather than shared, because a plugin crate depends on the contract crate and
16679/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
16680/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
16681/// source round-trips its own writes perfectly well under its own spelling.
16682const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
16683const METADATA_CLOSE: &str = "\n-->";
16684
16685/// What the composer puts between a non-empty visible body and the slot, and the one thing
16686/// the parser takes off the visible body when it takes the slot off — exactly once, so every
16687/// other trailing byte of the body comes back as it was written.
16688// 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.
16689const METADATA_SEPARATOR: &str = "\n\n";
16690
16691/// The visible body and the metadata slot at the end of it.
16692///
16693/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
16694/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
16695/// own content and is left alone. The visible body is everything before the slot less the
16696/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
16697fn metadata_body(
16698 body: Option<String>,
16699) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
16700 let Some(body) = body else {
16701 return Ok((None, BTreeMap::new()));
16702 };
16703 let Some(slot) = slot_span(&body)? else {
16704 return Ok((Some(body), BTreeMap::new()));
16705 };
16706 let metadata =
16707 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
16708 SourceError::Malformed {
16709 message: format!(
16710 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
16711 ),
16712 }
16713 })?;
16714 let before = &body[..slot.start];
16715 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
16716 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
16717}
16718
16719/// Where the metadata slot sits in one body, as byte offsets into it.
16720struct SlotSpan {
16721 /// Where [`METADATA_OPEN`] begins.
16722 start: usize,
16723 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
16724 encoded_start: usize,
16725 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
16726 encoded_end: usize,
16727 /// Just past [`METADATA_CLOSE`].
16728 end: usize,
16729}
16730
16731/// The slot at the very end of `body`, or `None` when it has none.
16732///
16733/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
16734/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
16735/// slot.
16736fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
16737 let Some(start) = body.rfind(METADATA_OPEN) else {
16738 return Ok(None);
16739 };
16740 let encoded_start = start + METADATA_OPEN.len();
16741 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
16742 return Err(SourceError::Malformed {
16743 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
16744 });
16745 };
16746 let encoded_end = encoded_start + relative_end;
16747 let end = encoded_end + METADATA_CLOSE.len();
16748 if !body[end..].trim().is_empty() {
16749 return Ok(None);
16750 }
16751 Ok(Some(SlotSpan {
16752 start,
16753 encoded_start,
16754 encoded_end,
16755 end,
16756 }))
16757}
16758
16759/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
16760/// slot as it was.
16761///
16762/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
16763/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
16764/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
16765/// or alone in an empty body — and a body with no slot that is given no metadata is
16766/// returned as it is.
16767fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
16768 let encoded = if metadata.is_empty() {
16769 None
16770 } else {
16771 Some(
16772 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16773 message: error.to_string(),
16774 })?,
16775 )
16776 };
16777 Ok(match (slot_span(body)?, encoded) {
16778 (Some(slot), Some(encoded)) => format!(
16779 "{}{encoded}{}",
16780 &body[..slot.encoded_start],
16781 &body[slot.encoded_end..]
16782 ),
16783 (Some(slot), None) => {
16784 let before = &body[..slot.start];
16785 format!(
16786 "{}{}",
16787 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
16788 &body[slot.end..]
16789 )
16790 }
16791 (None, None) => body.to_owned(),
16792 (None, Some(encoded)) if body.is_empty() => {
16793 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16794 }
16795 (None, Some(encoded)) => {
16796 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16797 }
16798 })
16799}
16800
16801/// `body` with everything before its metadata slot replaced by `content`, and the slot
16802/// itself kept byte for byte.
16803///
16804/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
16805/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
16806/// `content` is empty — so a read of the result reports `content` as the visible body and
16807/// the slot's metadata exactly as it was.
16808fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
16809 let Some(slot) = slot_span(body)? else {
16810 return Ok(content.to_owned());
16811 };
16812 let kept = &body[slot.start..];
16813 Ok(if content.is_empty() {
16814 kept.to_owned()
16815 } else {
16816 format!("{content}{METADATA_SEPARATOR}{kept}")
16817 })
16818}
16819
16820/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
16821fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
16822 if entries.is_empty() {
16823 metadata.remove(key);
16824 } else {
16825 metadata.insert(
16826 key.to_owned(),
16827 Value::Array(
16828 entries
16829 .iter()
16830 .map(|entry| Value::String(entry.as_str().to_owned()))
16831 .collect(),
16832 ),
16833 );
16834 }
16835}
16836
16837fn compose_body(
16838 content: Option<&str>,
16839 metadata: &BTreeMap<String, Value>,
16840) -> Result<Option<String>, SourceError> {
16841 let visible = content.unwrap_or_default();
16842 if metadata.is_empty() {
16843 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
16844 }
16845 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
16846 message: error.to_string(),
16847 })?;
16848 Ok(Some(if visible.is_empty() {
16849 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16850 } else {
16851 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
16852 }))
16853}
16854
16855fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
16856 value
16857 .get(field)
16858 .and_then(Value::as_bool)
16859 .ok_or_else(|| SourceError::Malformed {
16860 message: format!("GitHub response is missing boolean field {field}"),
16861 })
16862}
16863fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
16864 match value.get(field) {
16865 None | Some(Value::Null) => Ok(None),
16866 Some(value) => value
16867 .as_str()
16868 .map(Some)
16869 .ok_or_else(|| SourceError::Malformed {
16870 message: format!("GitHub response field {field} is not a string or null"),
16871 }),
16872 }
16873}
16874fn optional_nodes<'a>(
16875 connection: Option<&'a Value>,
16876 name: &str,
16877) -> Result<Option<&'a Vec<Value>>, SourceError> {
16878 match connection {
16879 None | Some(Value::Null) => Ok(None),
16880 Some(value) => value
16881 .get("nodes")
16882 .and_then(Value::as_array)
16883 .map(Some)
16884 .ok_or_else(|| SourceError::Malformed {
16885 message: format!("GitHub {name}.nodes is not an array"),
16886 }),
16887 }
16888}
16889fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
16890 let page_info = connection
16891 .get("pageInfo")
16892 .ok_or_else(|| SourceError::Malformed {
16893 message: format!("GitHub {name} has no pageInfo"),
16894 })?;
16895 if required_bool(page_info, "hasNextPage")? {
16896 return Err(SourceError::Malformed {
16897 message: format!(
16898 "GitHub {name} exceeds the supported nested connection size of {size}"
16899 ),
16900 });
16901 }
16902 Ok(())
16903}
16904fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
16905 optional_str(value, field)?
16906 .map(|timestamp| {
16907 timestamp.parse().map_err(|error| SourceError::Malformed {
16908 message: format!("GitHub response field {field} is not a timestamp: {error}"),
16909 })
16910 })
16911 .transpose()
16912}
16913fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
16914 if page.limit == 0 {
16915 Err(SourceError::Config {
16916 message: "page limit must be at least 1".into(),
16917 })
16918 } else {
16919 Ok(())
16920 }
16921}
16922fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
16923 let page = connection
16924 .get("pageInfo")
16925 .filter(|value| value.is_object())
16926 .ok_or_else(|| SourceError::Malformed {
16927 message: "GitHub connection is missing pageInfo".into(),
16928 })?;
16929 if required_bool(page, "hasNextPage")? {
16930 let cursor = required_str(page, "endCursor")?;
16931 validate_cursor_progress(None, cursor)?;
16932 Ok(Some(Cursor(cursor.into())))
16933 } else {
16934 Ok(None)
16935 }
16936}
16937fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
16938 if next.is_empty() || previous == Some(next) {
16939 Err(SourceError::Malformed {
16940 message: "GitHub pagination cursor is empty or did not advance".into(),
16941 })
16942 } else {
16943 Ok(())
16944 }
16945}
16946/// The version of this plugin's opaque narrowing-search cursor.
16947pub const SEARCH_CURSOR_VERSION: u32 = 4;
16948
16949#[derive(Serialize, Deserialize)]
16950#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
16951enum SearchConnection {
16952 Initial {},
16953 Continuing { after: Cursor },
16954 Exhausted {},
16955}
16956impl SearchConnection {
16957 fn after(&self) -> Option<&str> {
16958 match self {
16959 Self::Continuing { after } => Some(&after.0),
16960 _ => None,
16961 }
16962 }
16963 fn exhausted(&self) -> bool {
16964 matches!(self, Self::Exhausted { .. })
16965 }
16966 /// Whether a cursor naming this position, `offset` rows into its page, is one this
16967 /// plugin could have handed out: a page is resumed only part of the way through it — an
16968 /// offset of a whole page or more would skip rows nobody was given — an initial page
16969 /// only once some of it was handed out, and an exhausted connection has no page to be
16970 /// part of the way through.
16971 fn valid_resume(&self, offset: usize) -> bool {
16972 let within = offset < SEARCH_PAGE_SIZE as usize;
16973 match self {
16974 Self::Initial { .. } => offset > 0 && within,
16975 Self::Continuing { after } => !after.0.is_empty() && within,
16976 Self::Exhausted { .. } => offset == 0,
16977 }
16978 }
16979}
16980
16981/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
16982#[derive(Serialize, Deserialize)]
16983#[serde(deny_unknown_fields)]
16984struct SearchPosition {
16985 version: u32,
16986 connection: SearchConnection,
16987 /// How many rows of the page `connection` starts were already handed out.
16988 #[serde(default, skip_serializing_if = "is_zero")]
16989 offset: usize,
16990 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16991 seen: Vec<NativeId>,
16992 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16993 own: Vec<NativeId>,
16994}
16995impl Default for SearchPosition {
16996 fn default() -> Self {
16997 Self {
16998 version: SEARCH_CURSOR_VERSION,
16999 connection: SearchConnection::Initial {},
17000 offset: 0,
17001 seen: Vec::new(),
17002 own: Vec::new(),
17003 }
17004 }
17005}
17006
17007fn is_zero(offset: &usize) -> bool {
17008 *offset == 0
17009}
17010
17011fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
17012 cursor.map_or(Ok(0), |c| {
17013 c.0.parse().map_err(|_| SourceError::Config {
17014 message: "page cursor is invalid".into(),
17015 })
17016 })
17017}
17018fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
17019 if offset > items.len() {
17020 return Page::last(vec![]);
17021 }
17022 let tail = items.split_off(offset);
17023 let mut selected = tail;
17024 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
17025 selected.truncate(limit);
17026 Page {
17027 items: selected,
17028 next,
17029 }
17030}
17031
17032impl GitHubProjectsSource {
17033 /// The body [`Self::write_item`] would compose for `incoming`, sized in the characters
17034 /// [`BODY_LIMIT`] counts, without a request: its slot as `slot_metadata` writes it, every
17035 /// asset reference rewritten to its recorded URL or — for one not uploaded yet — to an
17036 /// address as long as GitHub's, and the heading markup this board's bodies carry.
17037 ///
17038 /// What the write reads rather than knows is taken as it most often lands: the issue in
17039 /// the one repository the item names, else the configured one, and every edge on the
17040 /// board's own relationships rather than in the slot.
17041 fn body_size(&self, incoming: &Incoming<'_>) -> onetaskgraph_plugin_api::BodySize {
17042 let own = match incoming.repositories {
17043 [named] => Some(named.clone()),
17044 _ => self
17045 .repository
17046 .as_ref()
17047 .and_then(|target| Repository::try_from(target.origin()).ok()),
17048 };
17049 let mut slot = slot_metadata(incoming, own.as_ref(), &[]);
17050 let content = match incoming.assets {
17051 Some(assets) => {
17052 let mut uploads = onetaskgraph_plugin_api::AssetUploads::default();
17053 for asset in &assets.assets {
17054 let url = assets
17055 .recorded_assets
17056 .as_ref()
17057 .and_then(|recorded| recorded.reusable(&asset.name, &asset.sha256))
17058 .unwrap_or(PREDICTED_ATTACHMENT_URL)
17059 .to_owned();
17060 uploads.0.insert(
17061 asset.name.clone(),
17062 onetaskgraph_plugin_api::AssetUpload {
17063 sha256: asset.sha256.clone(),
17064 url,
17065 },
17066 );
17067 }
17068 incoming.content.map(|content| {
17069 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads)
17070 })
17071 }
17072 None => incoming.content.map(str::to_owned),
17073 };
17074 let marked = content.as_deref().map(|content| self.marked(content));
17075 let size = compose_body(marked.as_deref(), &slot)
17076 .ok()
17077 .flatten()
17078 .map_or(0, |body| body.chars().count());
17079 onetaskgraph_plugin_api::BodySize {
17080 size: u64::try_from(size).unwrap_or(u64::MAX),
17081 limit: Some(BODY_LIMIT),
17082 }
17083 }
17084
17085 async fn write_task_assets(
17086 &self,
17087 write: &ItemWrite<Task>,
17088 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17089 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17090 self.write_item(
17091 &self.task_incoming(write, assets)?,
17092 write.target.as_ref(),
17093 &write.depends_on,
17094 )
17095 .await
17096 }
17097
17098 /// One task write as [`Self::write_item`] takes it, once what it delivers and its priority
17099 /// are known to be ones this source can write.
17100 fn task_incoming<'a>(
17101 &self,
17102 write: &'a ItemWrite<Task>,
17103 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
17104 ) -> Result<Incoming<'a>, SourceError> {
17105 let near = write.target.as_ref().unwrap_or(&write.item.id);
17106 for (key, entries) in [
17107 (TaskRef::DELIVERS_KEY, &write.item.delivers),
17108 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
17109 ] {
17110 TaskRef::listed(key, near, Some(&self.name), entries.clone())
17111 .map_err(|message| SourceError::Refused { message })?;
17112 }
17113 if self.priorities.is_none() && write.item.priority != Priority::None {
17114 return Err(self.holds_no_priority());
17115 }
17116 Ok(Incoming {
17117 written: Written::Work(ItemKind::Task, &write.item.status),
17118 title: &write.item.title,
17119 content: write.item.content.as_deref(),
17120 assets,
17121 labels: &write.item.labels,
17122 metadata: &write.item.metadata,
17123 repositories: &write.item.repositories,
17124 classification: write.item.classification,
17125 parent: write.item.project.as_ref(),
17126 delivers: &write.item.delivers,
17127 delivered_by: &write.item.delivered_by,
17128 priority: self.priorities.as_ref().map(|_| write.item.priority),
17129 })
17130 }
17131 async fn write_document_assets(
17132 &self,
17133 write: &ItemWrite<Document>,
17134 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
17135 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
17136 // A document takes part in no dependency graph, so there is no far end to write
17137 // natively and none to record: a caller naming one is told so rather than having it
17138 // stored under the reserved key, where a later read would report an edge the
17139 // contract says cannot exist.
17140 if !write.depends_on.is_empty() {
17141 return Err(SourceError::Refused {
17142 message: format!(
17143 "this write names {} dependencies for a document, and a document takes \
17144 part in no dependency graph; next: put the dependency on the task or \
17145 project the document is about",
17146 write.depends_on.len()
17147 ),
17148 });
17149 }
17150 self.write_item(
17151 &Incoming {
17152 written: Written::Document,
17153 title: &write.item.title,
17154 content: write.item.content.as_deref(),
17155 assets,
17156 labels: &write.item.labels,
17157 metadata: &write.item.metadata,
17158 repositories: &write.item.repositories,
17159 classification: write.item.classification,
17160 parent: write.item.project.as_ref(),
17161 delivers: &[],
17162 delivered_by: &[],
17163 priority: None,
17164 },
17165 write.target.as_ref(),
17166 &[],
17167 )
17168 .await
17169 }
17170}
17171
17172/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
17173/// itself, for the two things no journey can observe.
17174///
17175/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
17176/// with and without the call, that a settlement, a board listing and a metadata search each
17177/// read afresh after it — the resolved records, the written-item overlay, the board and its
17178/// search, and the narrowed searches. What they cannot reach is the held field definitions,
17179/// because a status write naming an option a person deleted is refused the same whether or
17180/// not the list is held, and a poisoned lock, because nothing outside the source can panic
17181/// while one of its locks is held. So these assert those directly, and every other holder
17182/// beside them so a holder added later without a clear in the call fails here.
17183#[cfg(test)]
17184mod end_command_tests {
17185 use super::*;
17186
17187 struct Token;
17188
17189 impl SecretResolver for Token {
17190 fn get(&self, var: &str) -> Option<SecretString> {
17191 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
17192 }
17193 }
17194
17195 fn source() -> GitHubProjectsSource {
17196 let config = serde_json::from_value(json!({
17197 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
17198 // Nothing here is sent: the source is only built and its state inspected.
17199 "endpoint": "http://127.0.0.1:9/graphql",
17200 }))
17201 .expect("a usable configuration");
17202 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
17203 .expect("the source builds")
17204 }
17205
17206 /// One issue as a board read answers it.
17207 fn resolved(source: &GitHubProjectsSource) -> Resolved {
17208 source
17209 .resolve(&json!({
17210 "id": "ITEM-1",
17211 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
17212 "body": "what a person may since have edited", "state": "OPEN",
17213 "stateReason": null, "url": null, "number": 1,
17214 "subIssuesSummary": {"total": 0},
17215 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
17216 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
17217 }))
17218 .expect("the item reads")
17219 .expect("an issue")
17220 }
17221
17222 /// Hold something in every holder the call clears, and the repository id it keeps.
17223 fn fill(source: &GitHubProjectsSource) {
17224 let item = resolved(source);
17225 source.created.lock().unwrap().push(item.clone());
17226 source.updated.lock().unwrap().push(item.clone());
17227 *source.board_cache.lock().unwrap() = Some(Board {
17228 id: "PVT-board".into(),
17229 fields: json!({"nodes": []}),
17230 items: vec![item.clone()],
17231 });
17232 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
17233 source
17234 .narrowed_cache
17235 .lock()
17236 .unwrap()
17237 .insert("status:todo".into(), vec![item.clone()]);
17238 source.children_cache.lock().unwrap().insert(
17239 NativeId("P-1".into()),
17240 (NativeId("P-1".into()), vec![item.clone()]),
17241 );
17242 source
17243 .search_next
17244 .lock()
17245 .unwrap()
17246 .insert("status:todo".into(), Some("cursor".into()));
17247 source
17248 .resolved_cache
17249 .lock()
17250 .unwrap()
17251 .insert(item.id.clone(), item);
17252 *source.fields_cache.lock().unwrap() = Some(BoardFields {
17253 id: BoardId::parse("PVT-board").unwrap(),
17254 fields: json!({"nodes": []}),
17255 });
17256 source
17257 .repository_cache
17258 .lock()
17259 .unwrap()
17260 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
17261 }
17262
17263 fn assert_dropped(source: &GitHubProjectsSource) {
17264 assert!(source.created().unwrap().is_empty(), "created");
17265 assert!(source.updated().unwrap().is_empty(), "updated");
17266 assert!(source.board_cache().unwrap().is_none(), "board");
17267 assert!(source.search_cache.lock().unwrap().is_none(), "search");
17268 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
17269 assert!(
17270 source.children_cache.lock().unwrap().is_empty(),
17271 "project children"
17272 );
17273 assert!(
17274 source.search_next.lock().unwrap().is_empty(),
17275 "search paging"
17276 );
17277 assert!(
17278 source.resolved_cache().unwrap().is_empty(),
17279 "resolved records"
17280 );
17281 assert!(source.fields_cache().unwrap().is_none(), "board fields");
17282 assert_eq!(
17283 source.repository_cache().unwrap().len(),
17284 1,
17285 "a repository's node id stays valid and is kept"
17286 );
17287 }
17288
17289 fn end(source: &GitHubProjectsSource) {
17290 tokio::runtime::Builder::new_current_thread()
17291 .build()
17292 .unwrap()
17293 .block_on(source.end_command())
17294 .expect("the command ends");
17295 }
17296
17297 #[test]
17298 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
17299 let source = source();
17300 fill(&source);
17301 end(&source);
17302 assert_dropped(&source);
17303 }
17304
17305 #[test]
17306 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
17307 fn poison<T: Send>(held: &Mutex<T>) {
17308 std::thread::scope(|scope| {
17309 let _ = scope
17310 .spawn(|| {
17311 let _guard = held.lock().unwrap();
17312 panic!("a failure while the lock is held");
17313 })
17314 .join();
17315 });
17316 assert!(held.is_poisoned());
17317 }
17318 let source = source();
17319 fill(&source);
17320 poison(&source.created);
17321 poison(&source.updated);
17322 poison(&source.board_cache);
17323 poison(&source.search_cache);
17324 poison(&source.narrowed_cache);
17325 poison(&source.search_next);
17326 poison(&source.resolved_cache);
17327 poison(&source.fields_cache);
17328 assert!(
17329 source.resolved_cache().is_err(),
17330 "a poisoned lock is refused before the call"
17331 );
17332 end(&source);
17333 assert_dropped(&source);
17334 }
17335}
17336
17337#[cfg(test)]
17338mod unprojectable_fields_tests {
17339 use super::*;
17340
17341 /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
17342 fn documented() -> std::collections::BTreeSet<String> {
17343 let document = std::fs::read_to_string(
17344 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
17345 )
17346 .expect("docs/metadata.md is readable");
17347 let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
17348 let lead = "a `field` equal, ignoring case, to ";
17349 let start = document.find(lead).expect("the refused-field sentence") + lead.len();
17350 let sentence = &document[start..];
17351 let sentence = &sentence[..sentence
17352 .find("; and two entries naming one field")
17353 .expect("the refused-field sentence ends at the duplicate rule")];
17354 let (own, github) = sentence
17355 .split_once(" or a field GitHub owns on every board (")
17356 .expect("the sentence names GitHub's own fields");
17357 own.split('`')
17358 .skip(1)
17359 .step_by(2)
17360 .chain(
17361 github
17362 .strip_suffix(')')
17363 .expect("GitHub's own fields close the sentence")
17364 .split(", "),
17365 )
17366 .map(str::to_owned)
17367 .collect()
17368 }
17369
17370 #[test]
17371 fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
17372 let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
17373 .iter()
17374 .map(|&name| name.to_owned())
17375 .collect();
17376 assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
17377 let instance = SourceName::new("work").unwrap();
17378 for name in UNPROJECTABLE_FIELDS {
17379 for spelled in [name.to_owned(), name.to_uppercase()] {
17380 let entry = MetadataFieldConfig {
17381 field: spelled.clone(),
17382 key: "team.machine".to_owned(),
17383 path: Vec::new(),
17384 };
17385 let refused = MetadataField::resolve(vec![entry], &instance)
17386 .expect_err("an unprojectable field is refused");
17387 assert!(
17388 refused.to_string().contains(&format!("{name:?}")),
17389 "{spelled}: {refused}"
17390 );
17391 }
17392 }
17393 }
17394}