onetaskgraph_github_projects/lib.rs
1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83//! **A caller's value can also be projected onto a board text field**, so the board can filter
84//! on it: each [`GitHubProjectsConfig::metadata_fields`] entry names a text field, a top-level
85//! metadata key and a path inside its value. The slot stays the value's one home — every read
86//! reports metadata from it alone — and the field follows it on every write of an item, a
87//! create, an update and a copy alike: a string is written, and not sent again while the field
88//! holds it — an empty string included, which is written as itself; nothing there or `null`
89//! clears a field holding a value; any other
90//! JSON type is refused before any mutation. It rides the ordered [`graphql::UPDATE_FIELDS`]
91//! write beside `Status`, `Priority` and the origin, so it adds no request; a key set on its
92//! own moves its field first and the body last, as every write of an existing item does. A
93//! board without the field, or with a non-text field of that name, is refused before any
94//! mutation pointing at `sources fields`, whose [`GitHubProjectsSource::fields`] creates it as
95//! a text field and never changes a field that is there.
96//!
97// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
98//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
99//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
100//! option of the board's one `Status` field for a task and for a project: a bare name is
101//! the option for both kinds, `null` disables the category for both, and `{task, project}`
102//! names it per kind. A category the mapping does not mention keeps its shipped default for
103//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
104//! gets the shipped default for the kind it leaves out. Two categories one kind would read
105//! back from one option are refused as the configuration is read, ignoring case, while one
106//! option may stand for different categories of the two kinds. Writes go by the kind of the
107//! item written: a status that kind has no option for, or whose option the board lacks, is
108//! refused before any mutation, naming the source, the kind, the category and the key
109//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
110//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
111//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
112//! before selecting its option. Reads give a closed issue's reason precedence over its
113//! option, while an open issue's option decides its category through its own kind's
114//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
115//! The guarded [`GitHubProjectsSource::status_options`] and
116//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
117//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
118//! existing option id and verify the field and item assignments immediately afterwards.
119//! They ask for both kinds' options, counting a terminal category's mapped option as
120//! configured because a terminal write refuses without it. No ordinary source read or
121//! write calls that mutation, whose
122//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
123//! and a mistake destroys every item's status. A status this board cannot represent is a
124//! refusal naming the status and the instance instead.
125//!
126//! `unknown` has no shipped option because this source cannot preserve an open-ended
127//! status word: it writes an existing board option and never
128//! creates an option. An operator may map `unknown` to one existing option, in which case
129//! every unknown word lands on that option and reads back as `unknown` under the option's
130//! name. This differs from `local-md`, which writes and reads the original word itself.
131//!
132//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
133//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
134//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
135//! finished tasks were only moved to a "Done" column would read 0% complete forever.
136// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
137//!
138//! # What this source declares, field by field
139//!
140//! One verdict per field of [`Capabilities`], and what `Native` means when this source
141//! says it. *Proven* means a shared journey drives it against the real
142//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
143//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
144//! [`capabilities`](TaskSource::capabilities) from parting.
145//!
146//! | Field | Verdict |
147//! | --- | --- |
148//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
149//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
150//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
151//! | `assets` | **Supported and native.** Image references travel as GitHub user attachments in the repository the task or document issue lives in. Uploads are authenticated and verified before the issue body is written. |
152//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
153//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
154//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
155//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
156//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
157//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
158//! | `filter_by_metadata` | **Supported, and asked of GitHub** — through the body and never through a projected `metadata_fields` text field, which is the board's to filter on and not this source's to read. A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
159//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
160//! | `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. |
161//! | `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. |
162//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
163//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
164//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
165//!
166//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
167//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
168//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
169//! and the raw bytes as the request body. The numeric repository id is read at most once
170//! per repository per source instance. A loopback API endpoint also selects the loopback
171//! uploads host; production needs no assets repository, commits or extra configuration.
172//! Every returned JSON `url` is read with the same token before a body is written: only
173//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
174//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
175//! asset and HTTP status and says the token type may not be accepted by the upload
176//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
177//! fine-grained PATs or OAuth tokens.
178//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
179//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
180//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
181//! and verified afresh, in the repository the issue already lives in on an update.
182//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
183//! Uploads take the same mutation spacing as content writes. Both uploads and verification
184//! reads wait out classified rate limits within the configured per-call retry budget,
185//! on the supplied clock; verifying reads take no mutation slot.
186//!
187//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
188//! source has documents and that its tasks have comments, both of which hold — and the three
189//! facts behind the uniform `Native` on the
190//! predicates beside it are recorded below rather than re-derived, because a reader who
191//! takes `Native` to mean *the remote service filters* will read that uniformity as a
192//! lie.
193//!
194//! First, the plugin contract defines `Support::Native` as *the source applies this
195//! predicate itself*, and says nothing about where it applies it. What the declaration
196//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
197//! — so that the engine may push it down and apply nothing of its own.
198//!
199//! Second, this source can keep that promise for every predicate at no additional API
200//! cost, because whichever of the reads below answers a query has already read every
201//! candidate that query will return before it filters anything. Filtering those items is
202//! in-process work over data already in hand.
203//!
204//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
205//! applied in process over what that question returned. A project filter has a relationship — a
206//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
207//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
208//! a search for metadata values, is the board-scoped issue search carrying the text and each
209//! value as quoted phrases; an origin is the board's own field filter over its origin field
210//! beside the same search for the id. **The text search narrows, and that is this source's
211//! declared semantics:** GitHub matches whole words where the substring rule this source and
212//! the local Markdown source confirm with would match inside one, so an item holding the text
213//! only inside a longer word is never a candidate. Every item returned does contain the text.
214//! A project query's text, and a document query's scoped to no project, is that same search
215//! and narrows on the same terms, its candidates confirmed by their kind as well.
216//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
217//! so those three are applied in process over the candidates, and a query carrying none of
218//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
219//! engine compensate for work this source has already done, and declaring `projects` native
220//! while ignoring the filter (which this source once did) silently returns another project's
221//! tasks, because the engine trusts the declaration and applies nothing locally.
222//!
223//! # The three ways this source reaches an item, and what each costs
224//!
225//! A board read is charged for what its *nested* connections could return rather than for
226//! what was asked, so one whole-board read costs the same whether the question was about
227//! one project or about all of them. That is why a question about one project is never
228//! answered by reading the board:
229//!
230//! | The question | What is sent | What it costs |
231//! | --- | --- | --- |
232//! | 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 |
233//! | 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 |
234//! | 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 |
235//! | 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 |
236//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
237//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
238//! | 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 |
239//! | 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 |
240//! | 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 |
241//! | 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 |
242//! | 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 |
243//! | 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 |
244//!
245//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
246//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
247//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
248//! equal to declared points equal to the row. They include the origin lookup and the
249//! field/repository discovery a create needs. A bound re-copy changes status, priority,
250//! content and metadata; comment recount means a subsequent detail read. Each request here
251//! costs one declared point. A membership beyond the embedded page can additionally require
252//! the one-point membership recovery described above. A bound re-copy of a task filed under a
253//! project adds one read, the engine confirming that project's link by its own id once per
254//! command; and the same-source far ends a write newly names — those that do not already block
255//! the item, whose own read answered for them — are read together by their own ids,
256//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
257//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
258//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
259//!
260//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
261//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
262//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
263//! holds both halves.
264//!
265//! **An existing item is written body last.** A bound re-copy and a `task update` send its
266//! board fields first — the `Status` option and the `Priority` together, in one request — then
267//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
268//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
269//! undoing an earlier field when a later one fails, so that order is what makes a write
270//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
271//! the one piece of metadata written before the body, an origin a copy re-points, is put back
272//! when a later write is refused — and when putting it back is refused too, the write's own
273//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
274//! of those writes in turn, whole and as one aliased field failing after the one before it.
275//!
276//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
277//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
278//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
279//!
280//! - **A board is accepted at creation but its item is not answered, so a create still files
281//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
282//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
283//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
284//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
285//! against a real board on 2026-10-01 with a create sending the board there and reading the
286//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
287//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
288//! refused "Content already exists in this project" — GitHub had filed the issue after
289//! answering, and refuses a second filing rather than answering with the item it holds. A
290//! create therefore sends no `projectV2Ids` and files the issue with
291//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
292//! real is the read before it: the board's fields and the repository's id together, in
293//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
294//! - **A comment still reads its target first, so a comment is 2 requests.**
295//! `AddCommentInput.subjectId: ID!` is declared there with
296//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
297//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
298//! project's issue, a document's issue, an issue on no board of this source and a pull
299//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
300//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
301//! refuses those by name.
302//!
303//! | Verb | Requests / points | Documents |
304//! | --- | --- | --- |
305//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
306//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
307//! | bound copy | 3 | ISSUE (with the board's fields and the issue's `blockedBy`, so no BOARD_FIELDS or ISSUE_DEPENDENCIES), UPDATE_FIELDS, then UPDATE_ISSUE last |
308//! | 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 |
309//! | 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 |
310//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
311//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
312//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
313//! | 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 |
314//! | recount | 1 | ISSUE_DETAIL |
315//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
316//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
317//! | content | 2 | ISSUE, UPDATE_ISSUE |
318//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
319//! | 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 |
320//! | record only | 1 | ISSUE |
321//!
322//! <!-- github-search-paging:start -->
323//! Board-scoped text, metadata, project-name and comment-activity searches send every
324//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
325//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
326//! needs rows. A page is never resized to the rows still needed: GitHub orders one
327//! search differently at different page sizes, so one fixed size makes a paged walk
328//! send exactly the requests one whole read sends, and the answer's order is the order
329//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
330//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
331//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
332//! returned and fetched pages: a limit is sliced from the pages it needs, and local
333//! confirmation can require more candidates than matching rows. Walking all pages
334//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
335//! cursor and how far into that page the last answer stopped, and resumes in the same
336//! process or a new one, without duplicates or gaps. It carries no rows: one process
337//! sends each page's search once, and a new process re-reads only the page it resumes
338//! in, then sends a further page once, never as a re-read, only when its limit still
339//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
340//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
341//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
342//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
343//! not required to include the original process's writes still omitted by the index.
344//! <!-- github-search-paging:end -->
345//!
346//! The board half of an issue — its board item's id, its `Status` option and this
347//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
348//! an item reached any of those ways resolves through the same
349//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
350//! same status, the same labels and the same qualified id. That connection comes back a
351//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
352//! on the page in hand and — only if that page reports more of the connection — in the
353//! last row's read of that one issue's memberships, resumed from the page's own cursor and
354//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
355//! report, which is what keeps an id naming another repository's issue from being answered
356//! as an item of this board; and because the page is where the search starts rather than
357//! where it ends, that answer is one about a connection read to exhaustion and never about
358//! an unread page. Nothing costs the extra read but an issue on more boards than a page
359//! holds: an issue this board really does not hold reports no next page, so its
360//! memberships are already exhausted where they arrived.
361//!
362//! **No document here selects the board's own `Labels` field, and nothing is lost by
363//! that.** An item's labels are read from its content alone, wherever that content is
364//! reached: the three documents above select `Issue.labels` on the fragment, and
365//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
366//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
367//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
368//! create one, and `ProjectV2FieldValue` — the whole of what
369//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
370//! it from the content, for every content type it exists on, and there is nothing it can
371//! hold that the content does not already say: for an `Issue` it *is* that issue's own
372//! labels, so selecting it beside them unions a set with itself.
373//!
374//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
375//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
376//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
377//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
378//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
379//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
380//! label set, and that set is the fixture issue's own, by
381//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
382//! item whose content is a draft reports an empty set, by
383//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
384//! selection is held over [`graphql::DOCUMENTS`] by
385//! `no_document_selects_the_boards_own_labels_field`.
386//!
387//! The whole-board row is still the board's own item connection, and deliberately: a
388//! **draft** board item is not an issue, so no search can list one, and the reads that have
389//! to answer for the whole board are the ones whose cost is the board's size anyway.
390//!
391//! **A question about one item this source already names by id never lists the board.**
392//! Whether that item is on this board, and what its board fields are, is answered by reading
393//! that item — its own `Issue.projectItems`, walked to exhaustion by
394//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
395//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
396//! holds. That covers a write's destination, the project a new item is filed under, a
397//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
398//! and the delete that takes back an item a copy made. What such a write needs of the board
399//! and the item does not carry — the board's id, the `Status` and origin field definitions —
400//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
401//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
402//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
403//! minutes. Scanning this host's 842-item board has refused a document copy and an update
404//! even though the items' own reads named that board. A scan there gives the wrong answer
405//! as well as paying for every page. So a `board.items` lookup does not belong on any of
406//! those paths.
407//!
408//! **What a read may return is capped too, and that cap is on the document rather than on
409//! the board.** GitHub limits the number of nodes **one query may return** to
410//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
411//! an error naming the connection the count crossed at, not a slow or a partial result.
412//! Every board this source reads is refused the same way, so no board is too big for these
413//! documents and none is small enough to save one that is over.
414//!
415//! The count is arithmetic over the document's own text: each connection contributes the
416//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
417//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
418//! restate them — `github-graphql-node-count` implements them, and
419//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
420//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
421//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
422//! same text on every run and fails naming any that reaches the limit — so a connection
423//! added to a shared fragment is caught there rather than by GitHub.
424//!
425//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
426//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
427//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
428//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
429//! effectively squared there, which is why it is the one the limit is most sensitive to.
430//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
431//! page of memberships misses is recovered by one further read rather than refused, so it
432//! buys a bound every read pays for at the price of a request only a multi-board issue
433//! pays.
434//!
435//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
436//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
437//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
438//! `cost` is rate-limit points, metered per hour across everything one credential does; it
439//! is what the two limiters [`Limiter`] tells apart meter, and a document under
440//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
441//! that second number, and `tests/point_cost.rs` pins every document in
442//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
443//! one under, the pin itself is the check. The credentialed lane reconciles both figures
444//! against GitHub's own, off a probe it already sends.
445//!
446//! **What is pinned that way is a per-document price and never a session's.** The record in
447//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
448//! **requests** and **worst-case nodes** — and neither is points. What one whole session
449//! consumes of the hourly point allowance is observable only from a credentialed run's own
450//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
451//! and what `tests/live.rs` prints at the end of every run.
452//!
453//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
454//!
455//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
456//! enumerations of a board can supply one alone.** Resolving a node id is strongly
457//! consistent, so a read by id and a project's own sub-issues are already current. The
458//! other two are not, and they are behind by different amounts and in different directions:
459//!
460//! - GitHub's **issue search** is an index and answers a write made moments ago with the
461//! value from before it — usually for a second or two.
462//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
463//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
464//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
465//! for *minutes*, while `Issue.projectItems` names the same membership at once.
466//!
467//! That second one is a measurement rather than a caution. This repository's own
468//! credentialed journey writes a project and waits for the board to report it, then writes a
469//! task and waits for the same thing seconds later on the same board: the project wait is
470//! answered through the search and converged in two or three attempts in each of three runs,
471//! and the task wait is answered through `ProjectV2.items` and converged in none of them
472//! inside thirty. Separately, an item added to a second and larger board was read back by
473//! `Issue.projectItems` on that board's own id while every one of that connection's nine
474//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
475//! through the lagging one alone is what had a board read deny an issue that had certainly
476//! landed on it.
477//!
478//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
479//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
480//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
481//! search reports what the projection is behind on. What closes the last
482//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
483//! source answers is completed with what this process itself wrote, so an item created
484//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
485//! nothing is written down, and the record dies with the process. **A wait that has to
486//! observe GitHub's own data cannot be answered from that record** — which is why the
487//! credentialed journey asks through a source built afresh, and why the union above rather
488//! than a longer wait is what makes such a wait converge.
489//!
490//! **A narrowed read is the same bargain, stated for each of the three predicates it
491//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
492//! than walking the board, and every such answer is completed with what this process wrote —
493//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
494//! each filtered by the same predicates as the rest — so an item this command wrote a moment
495//! ago is returned by a query that matches it whether or not the index has caught up. An item
496//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
497//! consistent. What is left is stated rather than papered over:
498//!
499//! | Read | Finds | Behind by |
500//! | --- | --- | --- |
501//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
502//! | 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 |
503//! | 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 |
504//! | origin, third read | this process's own writes | nothing |
505//!
506//! So an origin carrier another process added within the last second or two, before either
507//! index has it, can be missing from an origin query, and one written by the release before
508//! this one — its origin in the field alone — can be missing for as long as the board's own
509//! item connection is behind on it. A copy that must not duplicate its own earlier write
510//! relies on the link it records, not on either index. **A board draft is not an issue**, so
511//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
512//! lists one, the origin lookup drops any the board's own field filter names, and one this
513//! process wrote is not added back either.
514//!
515//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
516//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
517//! body's metadata slot, so the issue search can find it in seconds. The field is
518//! authoritative: this source reads an item's origin from the field alone, so a slot that
519//! disagrees with it, or holds one where the field holds none, is never read as a second
520//! origin — and the release before this one reads the slot, drops that key's copy for the
521//! field's, and sees the same one origin.
522//!
523//! Filtering happens before paging, so a page of a filtered result is a page of the
524//! survivors rather than the survivors of a page. Label matching and the substring rule a
525//! text candidate is confirmed by answer the same question the same way the local Markdown
526//! source's do; which candidates a text search has to confirm is GitHub's word match, which
527//! is the one place the two sources can answer the same text differently.
528//!
529//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
530//! has one source, `capabilities`, and the note above is the reasoning behind it rather
531//! than a second copy of it: without the three facts recorded here a reader takes the
532//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
533//! crate's own capabilities test, which pins every field of it against a fully spelled-out
534//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
535//! fails to compile there rather than going unasserted. -->
536//! The fixture-server tests above run wherever this crate is selected; the credentialed
537//! lane runs in the same required check, beside them, and can fail it — it verifies the
538//! 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,
539//! one filed under neither, a label on one of the three and a closed status on another —
540//! because that shape is what tells an honoured predicate from an ignored one: a board
541//! holding a single project answers a project filter the same way whether or not this
542//! source applies it, which is exactly how the defect above went unseen.
543//!
544//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
545//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
546//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
547//! session or request, independently of the live demand. Fix that variable in the machine's
548//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
549//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
550//! policy crate; absent credentials or nominations otherwise skip.
551//!
552//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
553//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
554//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
555//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
556//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
557//! after its run reads back as `completed`, immediately before the listed-artifact batch.
558//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
559//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
560//! and the board's `onetaskgraph.origin` field untouched.
561//!
562//! # What a session of requests costs, and where the report is
563//!
564//! This source records **every** request it sends into [`accounting::Accounting`], at
565//! `send_once` — the one place a request leaves this crate, which is why a read path added
566//! later is counted without anybody remembering to count it. That is the whole of what this
567//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
568//! session's spend is arrived at, and what it deliberately does not know are set out.
569//!
570//! What one whole session of the live journey costs, counted that way against this crate's
571//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
572//! reduction it came out of, and with what it does and does not say about rate-limit points.
573//!
574//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
575//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
576//! code path — no environment variable, no feature, no build configuration — because an
577//! instrument nobody switches on measures nothing, and
578//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
579//! source's counts the whole session rather than this source's share. The credentialed lane
580//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
581//! passed or failed.
582//!
583//! **A live session refuses to start unless the account can afford it.** Before it does any
584//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
585//! GitHub documents as not counting against the REST rate limit and which answers both of
586//! its budgets at once — and starts only if, for each of them, what remains minus this
587//! session's estimated cost is still at least
588//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
589//! allowance. A session that cannot **declines**: it did not run, so it is
590//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
591//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
592//! waiting for the budget to come back. The estimate is derived offline from
593//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
594//! which is also where the published rule that model rests on is cited; the accounting
595//! above records the gate's own read like any other request, and
596//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
597//!
598//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
599//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
600//! text, which is what lets it run on every platform and on a pull request from a fork with
601//! no credential — and that is what actually stops a regression merging. But an offline
602//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
603//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
604//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
605//! number of nodes this query may return"* and whose `cost` is what that document would
606//! spend, both for a document **without executing it**, and the lane asks it for every query
607//! document this source sends, under the largest bindings this source sends, and fails when
608//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
609//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
610//! one; the offline pins still cover it. It records what those calls reported about the
611//! account's own allowance, because whether asking is free is a thing to observe rather than
612//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
613//! and `cost` is metered against an hourly allowance the accounting above reads off a
614//! credentialed run's own response headers.
615//!
616//! **GitHub has two rate limiters and this source is refused by both, so nothing here
617//! treats them as one thing.** The primary budget is the hourly allowance `gh api
618//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
619//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
620//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
621//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
622//! one part of.
623#![deny(missing_docs)]
624
625use std::collections::BTreeMap;
626use std::sync::{Arc, Mutex};
627use std::time::Duration;
628
629use chrono::{DateTime, Utc};
630use onetaskgraph_plugin_api::{
631 Capabilities, Classification, Comment, CommentBody, CommentOrigin, Cursor, DependencyEdge,
632 DependencyEndpoint, DependencyKind, DependencySupport, Direction, Document, DocumentQuery,
633 Health, ItemKind, ItemWrite, Label, LabelFilter, Location, MetadataKey, Metering, NativeId,
634 NewComment, Page, PageRequest, Priority, Project, ProjectFilter, ProjectQuery, Repository,
635 SecretResolver, SharedClock, SourceError, SourceName, SourcePlugin, Status, StatusCategory,
636 StatusMapping, Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate,
637 TaskUpdateOutcome, TextFields, TextQuery, UnmappedStatus, UpdatedField, VersionCoverage,
638 WriteJournal, WriteSupport, system_clock,
639};
640use reqwest::{Client, StatusCode, Url};
641use schemars::{Schema, schema_for};
642use secrecy::{ExposeSecret, SecretString};
643use serde::{Deserialize, Serialize};
644use serde_json::{Value, json};
645
646pub mod accounting;
647mod assets;
648pub mod change_token;
649mod expected;
650pub use expected::{CONTEXT_VERSION, WHOLE_MEMBERS, context_shapes};
651mod visibility;
652
653use accounting::Accounting;
654
655/// The registry name for this plugin.
656pub const KIND: &str = "github-projects";
657/// GitHub's maximum connection page size.
658pub const MAX_PAGE_SIZE: u32 = 100;
659/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
660/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
661/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
662pub const SEARCH_PAGE_SIZE: u32 = 20;
663/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
664/// its comments: the largest batch the node-count model prices at one point.
665///
666/// Each aliased item is resolved once, and what GitHub charges for it is the connections
667/// under it — its labels, its page of board memberships, the field values of each of those
668/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
669/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
670/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
671/// one point and fails if one item more would still be priced at one.
672pub const DETAIL_BATCH: usize = 24;
673/// How many items one [`graphql::ISSUE_COPY_DETAILS`] request reads: each with the first page
674/// of its comments and its `blockedBy`, the largest batch the node-count model prices at one
675/// point.
676///
677/// The six requests' worth an item of [`DETAIL_BATCH`] costs, and its `blockedBy` connection
678/// besides: seven. Twenty-one items come to 147, which rounds to one point; twenty-two come to
679/// 154, which rounds to two. `tests/point_cost.rs` prices the document at one point and fails
680/// if one item more would still be priced at one.
681pub const COPY_DETAIL_BATCH: usize = 21;
682
683/// The most nodes any one document this source sends may be asked to return.
684///
685/// GitHub's own published per-query ceiling, taken from
686/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
687/// workspace cannot hold a stale copy of somebody else's number. A query above it is
688/// **refused before it is executed**, whoever is asking and whatever board they are
689/// asking about — so this is a bound on the documents rather than a budget that runs out.
690///
691/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
692/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
693/// everything the credential does — two numbers against two limits, and this constant
694/// bounds only the first. The second is computed offline too, per document:
695/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
696/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
697/// lane. There is no constant like this one to hold a price under, because points are an
698/// hourly allowance rather than a per-call bound.
699///
700/// Neither is a session's price. What `session-cost.md` records of a whole session is its
701/// **requests** and its **worst-case nodes**; what a whole session spends in points is
702/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
703/// [`accounting`]. The module section on the three ways this source reaches an item says how
704/// the count is arrived at, and which of the page sizes below decide it.
705pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
706
707/// Nested connection size for the connections that hang off one item.
708///
709/// It multiplies through every document that reaches an item under a page — the count
710/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
711/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
712/// every document under these constants and fails naming any that reaches the limit, so
713/// raising this is caught there rather than by GitHub.
714const NESTED_PAGE_SIZE: u32 = 50;
715/// How many of one issue's board memberships are read when an issue is reached directly.
716///
717/// An issue reached through a search or through its own node id carries its board half in
718/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
719/// under a page of issues, so every point of it multiplies through the whole document and
720/// is paid for whether or not any issue is on a second board — which is why it is
721/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
722///
723/// **Three, because what a page misses is now recovered rather than refused**, and the
724/// recovery is what the value is chosen against. An issue whose entry for this board sits
725/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
726/// that page's own cursor — so the value trades a bound every read pays for a request only
727/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
728/// boards would pay that request *per issue*, which is order N against the one page per
729/// hundred issues a read costs today. At three it is only reached by an issue on four or
730/// more boards at once, which keeps the recovery path exceptional rather than routine for
731/// a plausible deployment.
732const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
733/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
734/// its two connections for.
735///
736/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
737/// second is a duplicate a copy already takes the first of. Both connections are walked to
738/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
739/// never what the answer is. It is small because every point of it is paid on every lookup,
740/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
741/// worst-case nodes than the one whole-board read they replaced.
742const ORIGIN_PAGE_SIZE: u32 = 3;
743
744pub use github_graphql_node_count::{NodeCountError, Variables};
745
746/// The largest value this source can bind to each page-size variable its documents name.
747///
748/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
749/// constant above it wherever a caller's own limit could reach it — `$first` at
750/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
751/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
752/// not one configuration of it, which is what makes a bound computed under it a bound on
753/// every read.
754pub fn largest_page_sizes() -> Variables {
755 Variables::from([
756 ("first".to_owned(), MAX_PAGE_SIZE),
757 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
758 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
759 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
760 ])
761}
762
763/// The most nodes `document` could be asked to return, by GitHub's published rules.
764///
765/// Computed offline from the document's own text under [`largest_page_sizes`] — no
766/// network, no credential and no schema — by
767/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
768/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
769/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
770///
771/// # Errors
772///
773/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
774/// no single operation, or binds a page size this source does not name — each of which is
775/// a defect in the document rather than a number.
776pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
777 node_count(document, &largest_page_sizes())
778}
779
780/// The most rate-limit points one call of `document` could spend, by GitHub's published
781/// rules.
782///
783/// Computed offline from the document's own text under [`largest_page_sizes`] — no
784/// network, no credential and no schema — by
785/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
786/// This is `cost`, metered **per hour** against the allowance one credential shares across
787/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
788/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
789/// under, so what `tests/point_cost.rs` does with it is pin every document in
790/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
791/// figures against GitHub's own reported `cost`.
792///
793/// # Errors
794///
795/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
796/// no single operation, or binds a page size this source does not name — each of which is
797/// a defect in the document rather than a number.
798pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
799 github_graphql_node_count::point_cost(document, &largest_page_sizes())
800}
801
802/// The most nodes `document` could be asked to return under `variables`.
803///
804/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
805/// [`accounting`] is this under the bindings one request really sent — one spelling of the
806/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
807/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
808///
809/// # Errors
810///
811/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
812/// no single operation, or binds a page size `variables` does not name.
813pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
814 github_graphql_node_count::node_count(document, variables)
815}
816
817/// The issue-title prefix that makes a board issue a document.
818///
819/// A GitHub Projects board has no document type — it holds issues — so the discriminator
820/// is the title, and this is the whole of it: an issue whose title begins with these bytes
821/// is a document and every other issue is the task or project the sub-issue rule makes it.
822///
823/// It is spelled **once**, here, and read rather than restated everywhere else — including
824/// by the shared journeys, which take it from this constant so a board fixture cannot
825/// drift from what this source reads. `docs/metadata.md` records the two consequences that
826/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
827/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
828/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
829pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
830
831/// Exact GraphQL query documents issued by this plugin.
832///
833/// Keeping the production documents here lets the pinned-schema test validate the same
834/// bytes that are sent to GitHub, rather than a test-only copy which could drift
835/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
836/// field, and its guarded caller always supplies the complete existing option set with ids.
837pub mod graphql {
838 /// The board half of one item: the field values every document here reads it from.
839 ///
840 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
841 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
842 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
843 /// *the same value*, because
844 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
845 /// one path. Three spellings of it is what would drift, so there is one.
846 ///
847 /// The `Status` option and this source's own origin text field are the whole of it. It
848 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
849 /// content, so it holds nothing the content's own `labels` do not already say, and it
850 /// would sit a label connection two page sizes deep.
851 macro_rules! board_item_values {
852 () => {
853 r#"fieldValues(first:$nestedFirst){nodes{
854 ... on ProjectV2ItemFieldSingleSelectValue{name field{
855 ... on ProjectV2SingleSelectField{id name options{id name}}
856 }}
857 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
858 }pageInfo{hasNextPage}}"#
859 };
860 }
861
862 /// The variable every document reading an issue declares for the relationship events its
863 /// change token counts, with its value as the default, so no caller binds it.
864 ///
865 /// A variable rather than a literal list because every argument a production document
866 /// passes is a declared variable, which `tests/schema.rs` holds them to, and every member of
867 /// its default to the pinned enum. Its default is the six event types GitHub records a
868 /// relationship added or removed with: a dependency on either end, as `blockedBy` and
869 /// `blocking`, so one a person makes from the far end moves this end's count too; and a
870 /// project membership on the task, as its parent, which GitHub records there whichever
871 /// issue the move was made from. A sub-issue's events on its parent are not counted: a
872 /// project carries no task token, and a copy writes nothing of a task its own sub-issues
873 /// decide. [`super::change_token`] says why the count is in the token.
874 macro_rules! relationship_events_variable {
875 () => {
876 ",$relationshipEvents:[IssueTimelineItemsItemType!]=[BLOCKED_BY_ADDED_EVENT,BLOCKED_BY_REMOVED_EVENT,BLOCKING_ADDED_EVENT,BLOCKING_REMOVED_EVENT,PARENT_ISSUE_ADDED_EVENT,PARENT_ISSUE_REMOVED_EVENT]"
877 };
878 }
879
880 /// Internal live-lane support: fresh token-bearing facts for its own disposable issue.
881 /// Uses the same relationship event defaults as the plugin's issue reads.
882 pub const LIVE_CHANGE_TOKEN: &str = concat!(
883 "query LiveChangeToken($id:ID!,$boardItems:Int!=100",
884 relationship_events_variable!(),
885 r#"){node(id:$id){...on Issue{
886 updatedAt repository{nameWithOwner}
887 commentCount:comments{totalCount}
888 relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
889 projectItems(first:$boardItems){nodes{updatedAt project{id}}pageInfo{hasNextPage}}
890 }}}"#,
891 );
892
893 /// Everything this source reads about one issue, wherever it reaches that issue.
894 ///
895 /// A macro rather than a constant so the three documents below can `concat!` it: one
896 /// spelling of these fields is what makes an issue read through the board-scoped
897 /// search, through its own node id, and through its project's sub-issue relationship
898 /// resolve to *the same* item, which is the whole of what
899 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
900 ///
901 /// `projectItems` is what carries the board half of an issue: the board item's own id
902 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
903 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
904 /// issue rather than on the board, which is what makes the cost of a read proportional
905 /// to what was asked for instead of to the board's size.
906 ///
907 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
908 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
909 /// not on that page: a page here is where the search for the entry starts rather than
910 /// where it ends.
911 ///
912 /// It does **not** select the board's `Labels` field value, and that is the whole of
913 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
914 /// a label connection there sits under `fieldValues` under `projectItems` under a page
915 /// of issues, spending `$nestedFirst` twice down one path, and took
916 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
917 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
918 /// above, and that connection is where every label this source reports comes from. No
919 /// document in this module selects the board field any longer, [`BOARD`] included; the
920 /// module documentation records why nothing it could have held is lost.
921 macro_rules! board_issue {
922 () => {
923 concat!(
924 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} commentCount:comments{totalCount} relationshipEvents:timelineItems(itemTypes:$relationshipEvents){totalCount}
925 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
926 projectItems(first:$boardItems){nodes{id updatedAt project{id number}
927 "#,
928 board_item_values!(),
929 r#"}pageInfo{hasNextPage endCursor}}}"#
930 )
931 };
932 }
933
934 /// Every issue of one board, found by a search scoped to that board.
935 ///
936 /// This is how the projects a board holds are listed, and it selects no `items`
937 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
938 /// container walked page by page, so nothing nested inside a board item is paid for.
939 /// Which of the issues it returns is a project is then read off `parent` — GitHub
940 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
941 /// discriminator has to be applied to the field, which is a scalar on the issue and
942 /// costs nothing.
943 pub const SEARCH_ISSUES: &str = concat!(
944 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
945 relationship_events_variable!(),
946 r#"){
947 search(query:$search,type:$type,first:$first,after:$after){
948 pageInfo{hasNextPage endCursor}
949 nodes{__typename ...BoardIssue}
950 }
951 }"#,
952 board_issue!()
953 );
954
955 /// What a dependency read selects of each far end: enough to say which kind of item it
956 /// is, its body included for the kind marker.
957 macro_rules! related_issue {
958 () => {
959 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
960 };
961 }
962
963 /// One issue by its own node id, which is what a qualified id names here — with what a
964 /// write of it needs and the issue does not carry in `board_issue!`: the field
965 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
966 ///
967 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
968 /// answers a write made moments ago with the value from before it, and resolving a node
969 /// id does not.
970 ///
971 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
972 /// reads it by its own id, and with them that one read answers everything the write
973 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
974 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
975 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
976 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
977 /// they sit under one item, and this read is still one point.
978 pub const ISSUE: &str = concat!(
979 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
980 relationship_events_variable!(),
981 r#"){
982 node(id:$id){__typename ...BoardIssue ... on Issue{
983 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
984 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
985 ... on ProjectV2Field{__typename id name dataType}
986 }pageInfo{hasNextPage}}}}}
987 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
988 }}
989 }"#,
990 board_issue!(),
991 related_issue!()
992 );
993
994 /// One project's tasks: the sub-issues of the issue that project is, each with a page of
995 /// what blocks it.
996 ///
997 /// The work this costs is the project's own size. Nothing about it grows as the board
998 /// gains projects, or as those projects gain tasks.
999 ///
1000 /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
1001 /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
1002 /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
1003 /// costs grow with its tasks rather than with the pages of them. Carried here, a task
1004 /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
1005 /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
1006 /// It adds a connection under each issue of the page — one rate-limit point per page,
1007 /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
1008 /// answer questions that never read an edge.
1009 pub const SUB_ISSUES: &str = concat!(
1010 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1011 relationship_events_variable!(),
1012 r#"){
1013 node(id:$id){__typename
1014 ... on Issue{subIssues(first:$first,after:$after){
1015 pageInfo{hasNextPage endCursor}
1016 nodes{__typename ...BoardIssue ... on Issue{
1017 blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
1018 }}
1019 }}}
1020 }"#,
1021 board_issue!(),
1022 related_issue!()
1023 );
1024
1025 /// What a read of the board's own `items` selects of each item's content.
1026 ///
1027 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
1028 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
1029 /// content by one spelling.
1030 macro_rules! board_item_content {
1031 () => {
1032 r#" content{
1033 ... 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}}}
1034 ... on PullRequest{__typename id}
1035 ... on DraftIssue{__typename id title body createdAt updatedAt}
1036 }"#
1037 };
1038 }
1039
1040 /// Reads the board's fields and one page of its items.
1041 pub const BOARD: &str = concat!(
1042 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!"#,
1043 relationship_events_variable!(),
1044 r#"){
1045 owner:repositoryOwner(login:$owner){
1046 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
1047 }
1048 } fragment Board on ProjectV2 { id title
1049 fields(first:$nestedFirst){nodes{
1050 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1051 ... on ProjectV2Field{__typename id name dataType}
1052 }pageInfo{hasNextPage}}
1053 items(first:$first,after:$after){nodes{id updatedAt "#,
1054 board_item_values!(),
1055 board_item_content!(),
1056 r#"} pageInfo{hasNextPage endCursor}}
1057 }"#
1058 );
1059
1060 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
1061 /// the board.
1062 ///
1063 /// **`originItems`** is the board's own items narrowed by its own field filter —
1064 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
1065 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
1066 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1067 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1068 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1069 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1070 /// fresh `addProjectV2ItemById` the way that connection does.
1071 ///
1072 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1073 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1074 /// indexes that comment, and the index catches up with a write in a second or two rather
1075 /// than in minutes, so it finds a carrier another process wrote that the first read is
1076 /// still behind on.
1077 ///
1078 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1079 /// — and resumes from its own cursor; a connection already walked to its end is resumed
1080 /// from its last cursor, which answers an empty page. Every candidate either read returns
1081 /// is confirmed against its own origin field before it is reported, so a token match of
1082 /// the search or anything else the filter admits never is.
1083 ///
1084 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1085 /// own whole reads counts this one among them.
1086 pub const ORIGIN_LOOKUP: &str = concat!(
1087 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1088 relationship_events_variable!(),
1089 r#"){
1090 originItems:repositoryOwner(login:$owner){
1091 ... on ProjectV2Owner{projectV2(number:$number){
1092 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id updatedAt "#,
1093 board_item_values!(),
1094 board_item_content!(),
1095 r#"} pageInfo{hasNextPage endCursor}}
1096 }}
1097 }
1098 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1099 pageInfo{hasNextPage endCursor}
1100 nodes{__typename ...BoardIssue}
1101 }
1102 }"#,
1103 board_issue!()
1104 );
1105
1106 /// The board's own id and field definitions, and not one of its items.
1107 ///
1108 /// What a write needs of the board when the item it writes does not say: the id a field
1109 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1110 /// origin fields. It selects no `items`, so what it costs is the board's field list
1111 /// however many items the board holds — and it decides nothing about which items those
1112 /// are, which is the question a read of one item by its own id answers instead.
1113 ///
1114 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1115 /// board's item reads by their root counts this one among them.
1116 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1117 boardFields:repositoryOwner(login:$owner){
1118 ... on ProjectV2Owner{projectV2(number:$number){id
1119 fields(first:$nestedFirst){nodes{
1120 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1121 ... on ProjectV2Field{__typename id name dataType}
1122 }pageInfo{hasNextPage}}
1123 }}
1124 }
1125 }"#;
1126
1127 /// One board draft by its own node id, with the board item it sits in.
1128 ///
1129 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1130 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1131 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1132 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1133 /// board listing hands it to, and nothing has to list the board to find one.
1134 pub const DRAFT: &str = concat!(
1135 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1136 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1137 projectV2Items(first:$boardItems){nodes{id project{id number}
1138 "#,
1139 board_item_values!(),
1140 r#"}pageInfo{hasNextPage endCursor}}}}
1141 }"#
1142 );
1143
1144 /// One issue's board memberships alone, walked past the page a read of it carried.
1145 ///
1146 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1147 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1148 /// boards than that page holds may have this board's entry past its end. This asks that
1149 /// one issue for its memberships and nothing else — the caller already holds the issue —
1150 /// so an answer of "this board does not hold it" is only ever given about a connection
1151 /// read to exhaustion.
1152 ///
1153 /// It selects the board item's id, its project number and the same
1154 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1155 /// very same resolver: an issue recovered this way reports the same title, the same
1156 /// status, the same labels and the same qualified id as one whose entry was on the
1157 /// page.
1158 ///
1159 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1160 /// multiplies through it and the membership connection can be walked at
1161 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1162 /// further request for any issue a person really keeps.
1163 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1164 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1165 node(id:$id){
1166 ... on Issue{projectItems(first:$first,after:$after){
1167 nodes{id updatedAt project{id number}
1168 "#,
1169 board_item_values!(),
1170 r#"}
1171 pageInfo{hasNextPage endCursor}}}
1172 }
1173 }"#
1174 );
1175 /// Whether the board's Project is public — half of what decides whether a write here can
1176 /// be read by anybody. Needs the `read:project` scope.
1177 pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1178 /// Resolves the configured repository's node id, which creating an issue requires.
1179 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1180 /// What creating an issue needs and has not read yet: the board's own id and field
1181 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1182 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1183 ///
1184 /// Sent at the point a create knows which repository it is for, when neither half is
1185 /// already known to this process; a create needing only one of them sends that one's own
1186 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1187 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1188 /// status.
1189 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1190 boardFields:repositoryOwner(login:$owner){
1191 ... on ProjectV2Owner{projectV2(number:$number){id
1192 fields(first:$nestedFirst){nodes{
1193 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1194 ... on ProjectV2Field{__typename id name dataType}
1195 }pageInfo{hasNextPage}}
1196 }}
1197 }
1198 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1199 }"#;
1200 /// Reads both dependency directions for one issue, with each far end's own kind — and
1201 /// the issue's own body, which is where an edge to another source is recorded, so that
1202 /// half of a dependency read needs no second read of the issue or of the board.
1203 pub const ISSUE_DEPENDENCIES: &str = concat!(
1204 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1205 ... on Issue{body
1206 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1207 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1208 }}}"#,
1209 related_issue!()
1210 );
1211 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1212 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1213 /// answered when it was.
1214 pub const CREATE_ISSUE: &str =
1215 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1216 /// Puts an existing issue on the configured board.
1217 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1218 /// Updates an issue's visible fields and its open or closed state in one call.
1219 pub const UPDATE_ISSUE: &str =
1220 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1221 /// Updates an existing draft's user-visible fields.
1222 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1223 /// Updates a text or single-select value on one project item.
1224 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}}}}}}}}"#;
1225 /// Writes up to six board fields and clears up to three in one ordered mutation: every
1226 /// aliased field is included by its own boolean, as [`FIELD_WRITE_SLOTS`](super::FIELD_WRITE_SLOTS)
1227 /// and [`FIELD_CLEAR_SLOTS`](super::FIELD_CLEAR_SLOTS) name them, writes before clears.
1228 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}}}"#;
1229 /// Clears one project item's value of one field, which is what a `none` priority is.
1230 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}}}}}}}}"#;
1231 /// Creates one field: a single-select one with its options, or a text one for a projected
1232 /// metadata value. Only the guarded field setup may use this document, and only for a
1233 /// field the board lacks.
1234 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1235 /// Replaces a single-select field's options. Only the guarded field setup — the
1236 /// `status-options` and `fields` operations — may use this document, because GitHub
1237 /// treats the input as the complete option list.
1238 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1239 /// A fresh snapshot of the Status field and every board item's assignment.
1240 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}}}}}}"#;
1241 /// Files one issue under another as a sub-issue, which is what project membership is.
1242 pub const ADD_SUB_ISSUE: &str =
1243 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1244 /// Takes one issue back out of its parent.
1245 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1246 /// Adds GitHub's native issue blocked-by relationship.
1247 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1248 /// Removes one native issue blocked-by relationship.
1249 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1250 /// Deletes one issue, which takes its board item with it.
1251 ///
1252 /// The engine sends this in one situation only: undoing a copy that could not finish,
1253 /// over the items that same copy created. Deleting the issue removes the board item
1254 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1255 pub const DELETE_ISSUE: &str =
1256 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1257
1258 /// Everything this source reads about one issue comment, wherever it reaches one.
1259 ///
1260 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1261 /// and a comment just edited are handed to one mapper, so they are selected by one
1262 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1263 /// longer exists, and `login` is the one member every kind of actor carries.
1264 macro_rules! issue_comment {
1265 () => {
1266 "id author{login} createdAt updatedAt body url"
1267 };
1268 }
1269
1270 /// One task's comments: a page of its issue's own `comments` connection.
1271 ///
1272 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1273 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1274 /// list every time somebody edited it; left unordered the connection answers in the order
1275 /// the comments were written, which is the order GitHub documents for the same collection
1276 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1277 /// node count and the caller's own page size is pushed straight down.
1278 pub const ISSUE_COMMENTS: &str = concat!(
1279 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1280 issue_comment!(),
1281 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1282 );
1283 /// One issue by its own node id, with a page of its comments: what `task show` and a
1284 /// comment listing read, in one request.
1285 ///
1286 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1287 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1288 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1289 /// connection there would multiply through both of those documents' price, and neither
1290 /// needs one.
1291 pub const ISSUE_DETAIL: &str = concat!(
1292 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1293 relationship_events_variable!(),
1294 r#"){
1295 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1296 issue_comment!(),
1297 r#"}pageInfo{hasNextPage endCursor}}}}
1298 }"#,
1299 board_issue!()
1300 );
1301
1302 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1303 /// page of its comments when `$comments` asks for them.
1304 macro_rules! issue_details_alias {
1305 ($n:literal) => {
1306 concat!(
1307 "\n i",
1308 stringify!($n),
1309 ":node(id:$id",
1310 stringify!($n),
1311 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1312 issue_comment!(),
1313 "}pageInfo{hasNextPage endCursor}}}}"
1314 )
1315 };
1316 }
1317
1318 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1319 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1320 ///
1321 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1322 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1323 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1324 /// neither — so every connection under it would be priced at nothing and the pin in
1325 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1326 /// one-item read the model already prices, so the batch costs what its aliases cost.
1327 ///
1328 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1329 /// slots it has no item for to the last item it does, and reads that item again; the
1330 /// price is the document's, whatever its variables, so a short batch costs what a full
1331 /// one does and nothing more.
1332 pub const ISSUE_DETAILS: &str = concat!(
1333 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!"#,
1334 relationship_events_variable!(),
1335 r#"){"#,
1336 issue_details_alias!(0),
1337 issue_details_alias!(1),
1338 issue_details_alias!(2),
1339 issue_details_alias!(3),
1340 issue_details_alias!(4),
1341 issue_details_alias!(5),
1342 issue_details_alias!(6),
1343 issue_details_alias!(7),
1344 issue_details_alias!(8),
1345 issue_details_alias!(9),
1346 issue_details_alias!(10),
1347 issue_details_alias!(11),
1348 issue_details_alias!(12),
1349 issue_details_alias!(13),
1350 issue_details_alias!(14),
1351 issue_details_alias!(15),
1352 issue_details_alias!(16),
1353 issue_details_alias!(17),
1354 issue_details_alias!(18),
1355 issue_details_alias!(19),
1356 issue_details_alias!(20),
1357 issue_details_alias!(21),
1358 issue_details_alias!(22),
1359 issue_details_alias!(23),
1360 "\n }",
1361 board_issue!()
1362 );
1363
1364 /// One alias of [`ISSUE_COPY_DETAILS`]: the item a batch's `$id<n>` names, with the first
1365 /// page of its comments and the issues it is blocked by.
1366 macro_rules! issue_copy_details_alias {
1367 ($n:literal) => {
1368 concat!(
1369 "\n i",
1370 stringify!($n),
1371 ":node(id:$id",
1372 stringify!($n),
1373 "){__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{",
1374 issue_comment!(),
1375 "}pageInfo{hasNextPage endCursor}} blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}}}"
1376 )
1377 };
1378 }
1379
1380 /// [`ISSUE_DETAILS`] as a copy reads the tasks it carries: each item with the first page of
1381 /// its comments and its `blockedBy` — the edges a copy reads of every task next, which
1382 /// [`ISSUE`] carries and [`ISSUE_DETAILS`] does not — [`COPY_DETAIL_BATCH`](super::COPY_DETAIL_BATCH)
1383 /// at a time, so a copy of a changed task costs this read and nothing else.
1384 ///
1385 /// A document of its own rather than a widening of [`ISSUE_DETAILS`], whose batch size is
1386 /// what `task show-many` is priced by and which reads no edge.
1387 pub const ISSUE_COPY_DETAILS: &str = concat!(
1388 r#"query($id0:ID!,$id1:ID!,$id2:ID!,$id3:ID!,$id4:ID!,$id5:ID!,$id6:ID!,$id7:ID!,$id8:ID!,$id9:ID!,$id10:ID!,$id11:ID!,$id12:ID!,$id13:ID!,$id14:ID!,$id15:ID!,$id16:ID!,$id17:ID!,$id18:ID!,$id19:ID!,$id20:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1389 relationship_events_variable!(),
1390 r#"){"#,
1391 issue_copy_details_alias!(0),
1392 issue_copy_details_alias!(1),
1393 issue_copy_details_alias!(2),
1394 issue_copy_details_alias!(3),
1395 issue_copy_details_alias!(4),
1396 issue_copy_details_alias!(5),
1397 issue_copy_details_alias!(6),
1398 issue_copy_details_alias!(7),
1399 issue_copy_details_alias!(8),
1400 issue_copy_details_alias!(9),
1401 issue_copy_details_alias!(10),
1402 issue_copy_details_alias!(11),
1403 issue_copy_details_alias!(12),
1404 issue_copy_details_alias!(13),
1405 issue_copy_details_alias!(14),
1406 issue_copy_details_alias!(15),
1407 issue_copy_details_alias!(16),
1408 issue_copy_details_alias!(17),
1409 issue_copy_details_alias!(18),
1410 issue_copy_details_alias!(19),
1411 issue_copy_details_alias!(20),
1412 "\n }",
1413 board_issue!(),
1414 related_issue!()
1415 );
1416
1417 /// Which issue one comment is on, read before that comment is edited or removed.
1418 ///
1419 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1420 /// comment id given against the wrong task would change a comment on another issue.
1421 pub const COMMENT_ISSUE: &str =
1422 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id body issue{id}}}}"#;
1423 /// Adds one comment to an issue, signed as the account the token belongs to.
1424 pub const ADD_COMMENT: &str = concat!(
1425 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1426 issue_comment!(),
1427 r#"}}}}"#
1428 );
1429 /// Replaces the body of one issue comment.
1430 pub const UPDATE_COMMENT: &str = concat!(
1431 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1432 issue_comment!(),
1433 r#"}}}"#
1434 );
1435 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1436 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1437
1438 /// What a mutation of a write the store's cache expects selects of the issue it wrote:
1439 /// everything [`ISSUE_DETAIL`] reads of it, so its response shows the task whole — its
1440 /// change token's every part and the first page of its comments.
1441 ///
1442 /// Sent **only** while that write runs, in place of the document named beside each
1443 /// below, so every other write's documents, prices and request counts are what they
1444 /// were. GitHub's token waits for a task to settle (`change_token::SETTLED`), and a
1445 /// mutation's response is stamped seconds after the change it made, so against GitHub
1446 /// itself a response rarely determines a version; the cache is then left unserveable
1447 /// for the task, and its next read refreshes it.
1448 macro_rules! whole_issue {
1449 () => {
1450 concat!(
1451 r#"__typename ...BoardIssue ... on Issue{comments(first:$first){nodes{"#,
1452 issue_comment!(),
1453 r#"}pageInfo{hasNextPage endCursor}}}"#
1454 )
1455 };
1456 }
1457
1458 /// The variables every document of `whole_issue!` declares beside its own.
1459 macro_rules! whole_variables {
1460 () => {
1461 concat!(
1462 r#",$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!"#,
1463 relationship_events_variable!()
1464 )
1465 };
1466 }
1467
1468 /// [`ADD_COMMENT`], selecting the whole issue commented on.
1469 pub const ADD_COMMENT_WHOLE: &str = concat!(
1470 r#"mutation($input:AddCommentInput!"#,
1471 whole_variables!(),
1472 r#"){addComment(input:$input){subject{id "#,
1473 whole_issue!(),
1474 r#"} commentEdge{node{"#,
1475 issue_comment!(),
1476 r#"}}}}"#,
1477 board_issue!()
1478 );
1479 /// [`UPDATE_COMMENT`], selecting the whole issue the comment is on.
1480 pub const UPDATE_COMMENT_WHOLE: &str = concat!(
1481 r#"mutation($input:UpdateIssueCommentInput!"#,
1482 whole_variables!(),
1483 r#"){updateIssueComment(input:$input){issueComment{"#,
1484 issue_comment!(),
1485 r#" issue{"#,
1486 whole_issue!(),
1487 r#"}}}}"#,
1488 board_issue!()
1489 );
1490 /// [`UPDATE_ISSUE`], selecting the whole issue updated.
1491 pub const UPDATE_ISSUE_WHOLE: &str = concat!(
1492 r#"mutation($input:UpdateIssueInput!"#,
1493 whole_variables!(),
1494 r#"){updateIssue(input:$input){issue{id "#,
1495 whole_issue!(),
1496 r#"}}}"#,
1497 board_issue!()
1498 );
1499 /// [`UPDATE_FIELD`], selecting the whole issue behind the board item written.
1500 pub const UPDATE_FIELD_WHOLE: &str = concat!(
1501 r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1502 whole_variables!(),
1503 r#"){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1504 whole_issue!(),
1505 r#"}}}}"#,
1506 board_issue!()
1507 );
1508 /// [`CLEAR_FIELD`], selecting the whole issue behind the board item cleared.
1509 pub const CLEAR_FIELD_WHOLE: &str = concat!(
1510 r#"mutation($input:ClearProjectV2ItemFieldValueInput!,$readPriority:Boolean!,$priorityName:String!"#,
1511 whole_variables!(),
1512 r#"){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id fieldValueByName(name:$priorityName) @include(if:$readPriority){... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{id name options{id name}}}}} content{"#,
1513 whole_issue!(),
1514 r#"}}}}"#,
1515 board_issue!()
1516 );
1517
1518 /// The document sent in `document`'s place while a write the store's cache expects runs,
1519 /// when it has one.
1520 #[must_use]
1521 pub fn whole_twin(document: &str) -> Option<&'static str> {
1522 [
1523 (ADD_COMMENT, ADD_COMMENT_WHOLE),
1524 (UPDATE_COMMENT, UPDATE_COMMENT_WHOLE),
1525 (UPDATE_ISSUE, UPDATE_ISSUE_WHOLE),
1526 (UPDATE_FIELD, UPDATE_FIELD_WHOLE),
1527 (CLEAR_FIELD, CLEAR_FIELD_WHOLE),
1528 ]
1529 .into_iter()
1530 .find(|(plain, _)| *plain == document)
1531 .map(|(_, whole)| whole)
1532 }
1533
1534 /// Every document above, with what this source is doing when it sends one.
1535 ///
1536 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1537 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1538 /// document added later with "talking to GitHub" and never say so.
1539 ///
1540 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1541 /// const` here that this list omits, so the two cannot part — which is the same guard
1542 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1543 pub const DOCUMENTS: [(&str, &str); 41] = [
1544 (SEARCH_ISSUES, "searching this board's issues"),
1545 (
1546 LIVE_CHANGE_TOKEN,
1547 "reading fresh change-token facts for the live lane's disposable issue",
1548 ),
1549 (ISSUE, "reading one issue"),
1550 (
1551 ISSUE_BOARD_ITEMS,
1552 "reading one issue's board memberships past the page it came with",
1553 ),
1554 (SUB_ISSUES, "reading a project's tasks"),
1555 (BOARD, "reading the board"),
1556 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1557 (BOARD_FIELDS, "reading the board's fields"),
1558 (DRAFT, "reading one draft"),
1559 (REPOSITORY, "reading the destination repository"),
1560 (
1561 PROJECT_VISIBILITY,
1562 "reading whether the board's project is public",
1563 ),
1564 (
1565 CREATION_CONTEXT,
1566 "reading the board's fields and the destination repository",
1567 ),
1568 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1569 (CREATE_ISSUE, "creating an issue"),
1570 (ADD_TO_BOARD, "adding an issue to the board"),
1571 (UPDATE_ISSUE, "updating an issue"),
1572 (UPDATE_DRAFT, "updating a draft item"),
1573 (UPDATE_FIELD, "writing a board field"),
1574 (UPDATE_FIELDS, "writing board fields together"),
1575 (CLEAR_FIELD, "clearing a board field"),
1576 (
1577 CREATE_FIELD,
1578 "creating a board single-select field with its options",
1579 ),
1580 (
1581 STATUS_OPTIONS_SNAPSHOT,
1582 "snapshotting board Status options and assignments",
1583 ),
1584 (
1585 STATUS_OPTIONS_UPDATE,
1586 "safely replacing the board Status option list",
1587 ),
1588 (ADD_SUB_ISSUE, "filing an issue under its project"),
1589 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1590 (ADD_BLOCKED_BY, "recording a dependency"),
1591 (REMOVE_BLOCKED_BY, "removing a dependency"),
1592 (DELETE_ISSUE, "deleting an issue"),
1593 (ISSUE_COMMENTS, "reading a task's comments"),
1594 (ISSUE_DETAIL, "reading one issue with its comments"),
1595 (
1596 ISSUE_DETAILS,
1597 "reading a batch of issues with their comments",
1598 ),
1599 (
1600 ISSUE_COPY_DETAILS,
1601 "reading a batch of issues with their comments and dependencies for a copy",
1602 ),
1603 (COMMENT_ISSUE, "reading which issue a comment is on"),
1604 (ADD_COMMENT, "adding a comment"),
1605 (UPDATE_COMMENT, "editing a comment"),
1606 (DELETE_COMMENT, "deleting a comment"),
1607 (
1608 ADD_COMMENT_WHOLE,
1609 "adding a comment, answered with the whole issue",
1610 ),
1611 (
1612 UPDATE_COMMENT_WHOLE,
1613 "editing a comment, answered with the whole issue",
1614 ),
1615 (
1616 UPDATE_ISSUE_WHOLE,
1617 "updating an issue, answered with it whole",
1618 ),
1619 (
1620 UPDATE_FIELD_WHOLE,
1621 "writing a board field, answered with the whole issue",
1622 ),
1623 (
1624 CLEAR_FIELD_WHOLE,
1625 "clearing a board field, answered with the whole issue",
1626 ),
1627 ];
1628}
1629
1630/// One aliased mutation field of [`graphql::UPDATE_FIELDS`]: the key its answer comes back
1631/// under, the variable carrying its input, and the boolean variable that includes it.
1632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1633pub struct FieldSlot {
1634 /// The key of the answer's `data` this field's payload is under.
1635 pub alias: &'static str,
1636 /// The variable carrying this field's input.
1637 pub variable: &'static str,
1638 /// The boolean variable whose `true` sends this field.
1639 pub include: &'static str,
1640}
1641
1642/// The value writes [`graphql::UPDATE_FIELDS`] carries, in the order GitHub runs them.
1643pub const FIELD_WRITE_SLOTS: [FieldSlot; 6] = [
1644 FieldSlot {
1645 alias: "updateProjectV2ItemFieldValue",
1646 variable: "input",
1647 include: "writeFirst",
1648 },
1649 FieldSlot {
1650 alias: "second",
1651 variable: "second",
1652 include: "writeSecond",
1653 },
1654 FieldSlot {
1655 alias: "third",
1656 variable: "third",
1657 include: "writeThird",
1658 },
1659 FieldSlot {
1660 alias: "fourth",
1661 variable: "fourth",
1662 include: "writeFourth",
1663 },
1664 FieldSlot {
1665 alias: "fifth",
1666 variable: "fifth",
1667 include: "writeFifth",
1668 },
1669 FieldSlot {
1670 alias: "sixth",
1671 variable: "sixth",
1672 include: "writeSixth",
1673 },
1674];
1675
1676/// The clears [`graphql::UPDATE_FIELDS`] carries, run after every write of the same request.
1677pub const FIELD_CLEAR_SLOTS: [FieldSlot; 3] = [
1678 FieldSlot {
1679 alias: "cleared",
1680 variable: "clear",
1681 include: "writeClear",
1682 },
1683 FieldSlot {
1684 alias: "clearedSecond",
1685 variable: "clearSecond",
1686 include: "writeClearSecond",
1687 },
1688 FieldSlot {
1689 alias: "clearedThird",
1690 variable: "clearThird",
1691 include: "writeClearThird",
1692 },
1693];
1694
1695/// Which of GitHub's two rate limiters refused a request.
1696///
1697/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1698/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1699/// the whole reason this is carried rather than collapsed into "rate limited".
1700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1701enum Limiter {
1702 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1703 Primary,
1704 /// The burst limiter over content-generating requests, which nothing reports.
1705 Secondary,
1706}
1707
1708/// The wordings GitHub answers a secondary rate limit with.
1709///
1710/// It sends them under a forbidden status, under a too-many-requests status, and inside
1711/// the `errors` of a *successful* response, which is why the text is what this matches on
1712/// rather than the status. `abuse detection` is the wording GitHub used before the
1713/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1714/// what a burst of content creation is refused with.
1715///
1716/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1717/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1718/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1719/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1720pub const SECONDARY_WORDINGS: [&str; 5] = [
1721 "secondary rate limit",
1722 "temporarily blocked from content creation",
1723 "abuse detection",
1724 "submitted too quickly",
1725 "exceeded a secondary",
1726];
1727
1728/// The wordings GitHub answers an exhausted primary budget with.
1729///
1730/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1731/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1732/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1733/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1734/// two phrases is a substring of it, so without it that answer read as a refusal that will
1735/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1736/// one reason.
1737pub const PRIMARY_WORDINGS: [&str; 4] = [
1738 "api rate limit exceeded",
1739 "api rate limit already exceeded",
1740 "rate limit exceeded",
1741 "rate_limited",
1742];
1743
1744/// What a response *says about itself*, which is the only place a refusal can be read.
1745///
1746/// Deliberately not the whole response body. A board is a place people write about their
1747/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1748/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1749/// reported. So the item data is never read: what is read is GitHub's own REST-style
1750/// `message` envelope, which is what a forbidden status carries, and the `message` and
1751/// `type` of each GraphQL error, which is where a *successful* response says it.
1752///
1753/// A body that is not JSON at all has nothing structured to read, so only a failing
1754/// response's own text is taken — a successful response that is not JSON is malformed
1755/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1756fn refusal_wording(status: StatusCode, body: &str) -> String {
1757 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1758 return if status.is_success() {
1759 String::new()
1760 } else {
1761 body.to_owned()
1762 };
1763 };
1764 let mut said: Vec<&str> = parsed
1765 .get("message")
1766 .and_then(Value::as_str)
1767 .into_iter()
1768 .collect();
1769 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1770 for error in errors {
1771 said.extend(
1772 ["message", "type"]
1773 .into_iter()
1774 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1775 );
1776 }
1777 }
1778 said.join("; ")
1779}
1780
1781impl Limiter {
1782 /// Which limiter refused this response, or `None` when none of them did.
1783 ///
1784 /// The wording is read first and the status only decides what carries none of it,
1785 /// because GitHub answers a secondary limit with a forbidden status far more often
1786 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1787 /// really is a credential this token lacks.
1788 ///
1789 /// A response is a refusal because of its status or its own wording. A spent budget
1790 /// only ever explains one; it never turns an answer into a refusal.
1791 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1792 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1793 if SECONDARY_WORDINGS
1794 .iter()
1795 .any(|wording| normalized.contains(wording))
1796 {
1797 return Some(Self::Secondary);
1798 }
1799 if status == StatusCode::TOO_MANY_REQUESTS {
1800 return Some(Self::Primary);
1801 }
1802 // An exhausted budget *explains* a response that failed; it does not make one that
1803 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1804 // request the budget allowed as well as on the ones it then refuses, so reading
1805 // the header alone threw away a good answer — and, once refusals were retried,
1806 // replayed a request that had already taken effect.
1807 if !status.is_success() && budget_exhausted {
1808 return Some(Self::Primary);
1809 }
1810 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1811 // `errors` of an HTTP 200, where nothing about the status says so at all.
1812 if status.is_success()
1813 && PRIMARY_WORDINGS
1814 .iter()
1815 .any(|wording| normalized.contains(wording))
1816 {
1817 return Some(Self::Primary);
1818 }
1819 None
1820 }
1821
1822 /// What this limiter is called where an operator can look it up.
1823 const fn name(self) -> &'static str {
1824 match self {
1825 Self::Primary => "GitHub's primary API rate limit",
1826 Self::Secondary => "GitHub's secondary rate limit",
1827 }
1828 }
1829
1830 /// What the endpoint an operator would go and check says about this limiter.
1831 const fn where_to_look(self) -> &'static str {
1832 match self {
1833 Self::Primary => {
1834 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1835 comes back."
1836 }
1837 Self::Secondary => {
1838 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1839 primary budget and does not report this one, so budget showing there says \
1840 nothing about this refusal, and every further attempt extends it."
1841 }
1842 }
1843 }
1844
1845 /// The next step this limiter actually calls for.
1846 const fn what_to_do(self) -> &'static str {
1847 match self {
1848 Self::Primary => {
1849 "wait for the reset `gh api rate_limit` reports, then run the command again."
1850 }
1851 Self::Secondary => {
1852 "leave this board alone for a few minutes, then run the command again — or \
1853 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1854 }
1855 }
1856 }
1857}
1858
1859/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1860#[derive(Debug, Clone, Copy)]
1861struct Limited {
1862 limiter: Limiter,
1863 hint: Option<u64>,
1864}
1865
1866impl Limited {
1867 /// What the caller is told once this source has waited as long as it may.
1868 ///
1869 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1870 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1871 /// about *which* limiter it was makes it a different kind of failure. What differs is
1872 /// the operator's next step, and that is what the message carries — a secondary
1873 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1874 /// budget looks fine, and then back to retry the very burst that was refused.
1875 fn exhausted(
1876 self,
1877 doing: &str,
1878 waits: u32,
1879 waited: Duration,
1880 needed: Duration,
1881 budget: Duration,
1882 ) -> SourceError {
1883 SourceError::RateLimited {
1884 retry_after_seconds: self.hint,
1885 message: Some(format!(
1886 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1887 again, and the next wait of {} would take it past the {} one call may spend \
1888 waiting. {} next: {}",
1889 self.limiter.name(),
1890 plural(waits, "refusal"),
1891 seconds(waited),
1892 seconds(needed),
1893 seconds(budget),
1894 self.limiter.where_to_look(),
1895 self.limiter.what_to_do(),
1896 )),
1897 }
1898 }
1899}
1900
1901/// One HTTP attempt's result, with what its response said about the rate limit.
1902///
1903/// The two travel together so the record and the outcome are written from the same place:
1904/// what a response said about the budget is only readable while that response is in hand,
1905/// and what the attempt *meant* is only decidable once its body has been read.
1906struct Attempted {
1907 result: Result<Value, Attempt>,
1908 limits: accounting::RateLimit,
1909 /// GitHub's own reported cost for this call, for a document that asked for it.
1910 reported_cost: Option<u64>,
1911 response_outcome: accounting::ResponseOutcome,
1912}
1913
1914/// One attempt's outcome: an error to report, or a rate limit to wait out.
1915enum Attempt {
1916 Failed(SourceError),
1917 Limited(Limited),
1918}
1919
1920fn plural(count: u32, thing: &str) -> String {
1921 if count == 1 {
1922 format!("{count} {thing}")
1923 } else {
1924 format!("{count} {thing}s")
1925 }
1926}
1927
1928fn seconds(duration: Duration) -> String {
1929 format!("{:.1}s", duration.as_secs_f64())
1930}
1931
1932/// The member [`stamp_served`] writes onto every node of a response that says when it last
1933/// changed: the `Date` that response was served at. No GitHub type has a field of this name.
1934const SERVED_AT: &str = "onetaskgraphServedAt";
1935
1936/// Write `served` onto every object of `data` carrying an `updatedAt`, so whatever reads a
1937/// node's time later knows the server's clock on the response that read it — which is what
1938/// decides whether that node's change token is given (see [`change_token::SETTLED`]).
1939fn stamp_served(data: &mut Value, served: &str) {
1940 match data {
1941 Value::Object(members) => {
1942 if members.contains_key("updatedAt") {
1943 members.insert(SERVED_AT.to_owned(), Value::String(served.to_owned()));
1944 }
1945 for member in members.values_mut() {
1946 stamp_served(member, served);
1947 }
1948 }
1949 Value::Array(items) => {
1950 for item in items {
1951 stamp_served(item, served);
1952 }
1953 }
1954 _ => {}
1955 }
1956}
1957
1958/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1959///
1960/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1961/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1962/// header, and neither is what makes a response a refusal — so the whole cost of one this
1963/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1964/// instead. Refusing the response over the header would turn a readable refusal into an
1965/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1966fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1967 value
1968 .and_then(|value| value.to_str().ok())
1969 .and_then(|value| value.trim().parse::<u64>().ok())
1970}
1971
1972/// Every mutation this source sends creates content — an issue, a board item, a field of
1973/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1974/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1975/// and what the keyword says are the same set. That is what makes the keyword a sound test
1976/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1977/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1978fn is_mutation(query: &str) -> bool {
1979 query.trim_start().starts_with("mutation")
1980}
1981
1982/// What this source was doing, for a diagnostic that has to say so.
1983///
1984/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1985/// a document added without a description is caught by that list's own gate instead of
1986/// falling through to the vague arm below.
1987fn operation_description(query: &str) -> &'static str {
1988 graphql::DOCUMENTS
1989 .iter()
1990 .find(|(document, _)| *document == query)
1991 .map_or("talking to GitHub", |(_, doing)| *doing)
1992}
1993
1994/// GitHub's published ceiling on content-generating requests, per minute.
1995///
1996/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1997/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1998/// from it, so a pacing value checked only against itself cannot go stale here.
1999pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
2000/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
2001/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
2002/// governs; it is pinned beside its sibling so the gate would notice either one moving.
2003pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
2004/// Shortest interval between two content-creating mutations, in milliseconds.
2005///
2006/// GitHub documents two secondary limits on content-generating requests:
2007/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
2008/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
2009/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
2010/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
2011/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
2012/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
2013/// deliberately *not* what this paces at. An installation that wants the hourly bound
2014/// honoured for a long sequence of copies says so through
2015/// `pacing.min_mutation_interval_ms`.
2016pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
2017/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
2018///
2019/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
2020/// own advice for a secondary limit — wait, and wait longer each time — without spending
2021/// the first minute of a transient refusal doing nothing.
2022pub const RETRY_BACKOFF_MS: u64 = 1_000;
2023/// Total time one call may spend waiting out rate limits before it reports a failure.
2024///
2025/// Two minutes is long enough to ride out the refusals a paced copy still collects and
2026/// short enough that a command an operator is watching returns. The bound is what makes
2027/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
2028/// the limiter, not in a process nobody can tell from a wedged one.
2029pub const RETRY_BUDGET_MS: u64 = 120_000;
2030
2031fn default_token_env() -> String {
2032 "GH_PROJECTS_TOKEN".to_owned()
2033}
2034fn default_endpoint() -> String {
2035 "https://api.github.com/graphql".to_owned()
2036}
2037
2038/// The name of a `Status` single-select option on the board.
2039///
2040/// Validated on the way in rather than checked later, so a blank option name — which
2041/// nothing on a board can be — is a state this type cannot hold.
2042#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2043#[serde(try_from = "String")]
2044#[schemars(extend("minLength" = 1))]
2045pub struct ColumnName(String);
2046
2047impl ColumnName {
2048 /// The option name, as the board spells it.
2049 fn as_str(&self) -> &str {
2050 &self.0
2051 }
2052}
2053
2054impl TryFrom<String> for ColumnName {
2055 type Error = String;
2056
2057 fn try_from(name: String) -> Result<Self, Self::Error> {
2058 if name.trim().is_empty() {
2059 return Err("a status_mapping option name cannot be blank".to_owned());
2060 }
2061 Ok(Self(name))
2062 }
2063}
2064
2065/// The two closed states this product can mean.
2066///
2067/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
2068/// work nor abandoned work, so nothing here ever writes it.
2069#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
2070#[serde(rename_all = "kebab-case")]
2071pub enum ClosedState {
2072 /// `COMPLETED` — precisely done.
2073 Completed,
2074 /// `NOT_PLANNED` — precisely cancelled.
2075 NotPlanned,
2076}
2077
2078impl ClosedState {
2079 const fn reason(self) -> &'static str {
2080 match self {
2081 Self::Completed => "COMPLETED",
2082 Self::NotPlanned => "NOT_PLANNED",
2083 }
2084 }
2085}
2086
2087/// Configuration for one GitHub Projects v2 board.
2088///
2089/// It serializes to a document this same type reads back as itself, so a caller writing a
2090/// configuration — a consumer filing onto a board it configures — can build one here rather
2091/// than spell the shape again; an empty `metadata_fields` is left out, as one never set.
2092#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2093#[serde(default, deny_unknown_fields)]
2094pub struct GitHubProjectsConfig {
2095 /// Login of the user or organization which owns the board.
2096 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
2097 /// The project number shown in the board's GitHub URL.
2098 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
2099 // 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.
2100 /// `owner/name` of the repository this source creates an issue in when the item's own
2101 /// `repositories` field does not decide it.
2102 ///
2103 /// An item naming exactly one repository is created there; a task or a document naming
2104 /// none or several is created in its parent project's repository; and a project, or a
2105 /// task or document with no parent, naming none or several is created here. A board
2106 /// has no repository of its own and `createIssue` requires one, so a write without
2107 /// this is refused naming the field. Reads never need it.
2108 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
2109 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2110 /// Environment variable containing a fine-grained token with Projects and Issues
2111 /// read/write plus Pull requests read-only access for every repository represented on
2112 /// the board.
2113 #[serde(default = "default_token_env")]
2114 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
2115 /// GraphQL endpoint. GitHub Enterprise installations may override it.
2116 #[serde(default = "default_endpoint")]
2117 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
2118 /// Per-instance mapping from a status category to the option of the board's one
2119 /// `Status` field it lands on, for a task and for a project.
2120 ///
2121 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
2122 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
2123 /// where a kind left out leaves the category unmapped for that kind. A category this
2124 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
2125 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
2126 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
2127 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
2128 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
2129 /// either kind. No two categories may name one option for the same kind, ignoring case.
2130 /// `unknown` may name one existing option; every unknown word then lands on it and
2131 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
2132 /// each unknown word because it never creates board options.
2133 #[serde(default)]
2134 pub status_mapping: StatusMapping,
2135 /// Per-instance mapping from a task's priority to an option of this board's
2136 /// single-select field named `Priority`.
2137 ///
2138 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
2139 /// other priority is refused before it reaches this board. Present, each of `urgent`,
2140 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
2141 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
2142 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
2143 /// no two levels may name one option. Reads and writes never create the field or an
2144 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
2145 /// the board lacks is refused pointing there.
2146 #[serde(default)]
2147 pub priority_mapping: Option<PriorityMappingConfig>,
2148 /// Caller metadata values this source also writes to board text fields, so the board can
2149 /// filter on them.
2150 ///
2151 /// Absent or empty, nothing is projected. Each entry names a board text `field`, a
2152 /// top-level metadata `key` and an optional `path` of object keys walked inside that key's
2153 /// value. On every item write — create, update and copy, of a task, a project or a
2154 /// document — a string found there is written to the field, and nothing is sent when the
2155 /// field already holds it; nothing there, or `null`, clears the field when it holds a
2156 /// value; any other JSON type is refused before any mutation. The metadata comment in the
2157 /// issue body stays the one home of the value, and every read reports metadata from it.
2158 /// Reads and writes never create the field: `onetaskgraph sources fields <source>
2159 /// --apply` does, as a text field, and a write to a board lacking it, or holding a
2160 /// non-text field of that name, is refused pointing there.
2161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2162 // Kept in the schema as `"default": []` although a serialized configuration leaves an empty
2163 // list out, so both SDKs model an absent list as an empty one rather than as `null`.
2164 #[schemars(!skip_serializing_if)]
2165 pub metadata_fields: Vec<MetadataFieldConfig>,
2166 /// How fast this source writes, and how long it waits out a rate-limit refusal.
2167 ///
2168 /// Every field keeps its shipped default when it is absent, and the defaults are
2169 /// GitHub's own published limits rather than taste. See [`Pacing`].
2170 #[serde(default)]
2171 pub pacing: PacingConfig,
2172}
2173
2174/// Which option of the board's `Priority` field each priority lands on.
2175///
2176/// One member per level rather than a map, so a key that is not a level is refused where
2177/// the configuration is read, naming the levels there are. `none` is not a member: it is no
2178/// value in the field, not an option of it.
2179#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2180#[serde(default, deny_unknown_fields)]
2181pub struct PriorityMappingConfig {
2182 /// The option `urgent` lands on; `Urgent` when absent.
2183 pub urgent: Option<PriorityOptionName>,
2184 /// The option `high` lands on; `High` when absent.
2185 pub high: Option<PriorityOptionName>,
2186 /// The option `medium` lands on; `Medium` when absent.
2187 pub medium: Option<PriorityOptionName>,
2188 /// The option `low` lands on; `Low` when absent.
2189 pub low: Option<PriorityOptionName>,
2190}
2191
2192/// The name of an option of the board's `Priority` single-select field.
2193///
2194/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
2195/// blank name.
2196#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2197#[serde(try_from = "String")]
2198#[schemars(extend("minLength" = 1))]
2199pub struct PriorityOptionName(String);
2200
2201impl PriorityOptionName {
2202 /// The option name, as the board spells it.
2203 fn as_str(&self) -> &str {
2204 &self.0
2205 }
2206}
2207
2208impl TryFrom<String> for PriorityOptionName {
2209 type Error = String;
2210
2211 fn try_from(name: String) -> Result<Self, Self::Error> {
2212 if name.trim().is_empty() {
2213 return Err("a priority_mapping option name cannot be blank".to_owned());
2214 }
2215 Ok(Self(name))
2216 }
2217}
2218
2219/// The name of the board field a priority is held in.
2220pub const PRIORITY_FIELD: &str = "Priority";
2221
2222/// The four priorities a board option can hold, in the order a new `Priority` field lists
2223/// them. `none` is not among them: it is the field holding no value.
2224///
2225/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
2226/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
2227/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
2228/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
2229pub const PRIORITY_LEVELS: [Priority; 4] = [
2230 Priority::Urgent,
2231 Priority::High,
2232 Priority::Medium,
2233 Priority::Low,
2234];
2235
2236/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
2237/// see that list for what this pins.
2238#[must_use]
2239pub const fn level_position(priority: Priority) -> Option<usize> {
2240 match priority {
2241 Priority::None => None,
2242 Priority::Urgent => Some(0),
2243 Priority::High => Some(1),
2244 Priority::Medium => Some(2),
2245 Priority::Low => Some(3),
2246 }
2247}
2248
2249/// This instance's complete priority-to-option mapping, read in both directions.
2250///
2251/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
2252/// two levels name one option.
2253#[derive(Debug, Clone)]
2254struct PriorityMapping {
2255 options: [PriorityOptionName; 4],
2256}
2257
2258impl PriorityMapping {
2259 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2260 let shipped = |name: &str| PriorityOptionName(name.to_owned());
2261 let mapping = Self {
2262 options: [
2263 config.urgent.unwrap_or_else(|| shipped("Urgent")),
2264 config.high.unwrap_or_else(|| shipped("High")),
2265 config.medium.unwrap_or_else(|| shipped("Medium")),
2266 config.low.unwrap_or_else(|| shipped("Low")),
2267 ],
2268 };
2269 for (index, option) in mapping.options.iter().enumerate() {
2270 if let Some(earlier) = mapping.options[..index]
2271 .iter()
2272 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
2273 {
2274 return Err(SourceError::Config {
2275 message: format!(
2276 "priority_mapping of source {instance} sends both {} and {} to the board \
2277 option {:?}; one option cannot read back as two priorities",
2278 PRIORITY_LEVELS[earlier],
2279 PRIORITY_LEVELS[index],
2280 option.as_str()
2281 ),
2282 });
2283 }
2284 }
2285 Ok(mapping)
2286 }
2287
2288 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
2289 fn option(&self, priority: Priority) -> Option<&str> {
2290 level_position(priority).map(|index| self.options[index].as_str())
2291 }
2292
2293 /// The priority a board option name reports, or `None` when nothing maps to it.
2294 fn priority_of(&self, option: &str) -> Option<Priority> {
2295 self.options
2296 .iter()
2297 .position(|name| name.as_str().eq_ignore_ascii_case(option))
2298 .map(|index| PRIORITY_LEVELS[index])
2299 }
2300
2301 /// Every mapped option name, in the order a new `Priority` field lists them.
2302 fn names(&self) -> impl Iterator<Item = &str> {
2303 self.options.iter().map(PriorityOptionName::as_str)
2304 }
2305}
2306
2307/// One metadata value this source projects onto a board text field.
2308#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2309#[serde(deny_unknown_fields)]
2310pub struct MetadataFieldConfig {
2311 /// The name of the board text field the value is written to.
2312 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.
2313 /// The top-level metadata key the value is read from. A key under the reserved
2314 /// `onetaskgraph.` prefix is refused.
2315 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.
2316 /// Object keys walked inside that key's value, outermost first. Absent or empty, the
2317 /// key's own value is the one projected. Left out of a serialized entry when empty.
2318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2319 // Kept in the schema as `"default": []`, for the reason `metadata_fields`' is.
2320 #[schemars(!skip_serializing_if)]
2321 pub path: Vec<String>,
2322}
2323
2324/// The board fields no metadata value may be projected onto, compared ignoring case: the
2325/// three this source writes itself, and those GitHub owns on every board.
2326// 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.
2327const UNPROJECTABLE_FIELDS: [&str; 13] = [
2328 STATUS_FIELD,
2329 PRIORITY_FIELD,
2330 ORIGIN_FIELD,
2331 "Title",
2332 "Assignees",
2333 "Labels",
2334 "Linked pull requests",
2335 "Milestone",
2336 "Repository",
2337 "Reviewers",
2338 "Parent issue",
2339 "Sub-issues progress",
2340 "Type",
2341];
2342
2343/// One validated [`MetadataFieldConfig`] entry.
2344#[derive(Debug, Clone)]
2345struct MetadataField {
2346 field: ProjectedField,
2347 key: ProjectedKey,
2348 path: Vec<String>,
2349}
2350
2351/// The name of a board text field a metadata value is projected onto: never blank, never one
2352/// this source or GitHub writes, and never one another entry of the same source names.
2353#[derive(Debug, Clone, PartialEq, Eq)]
2354struct ProjectedField(String);
2355
2356/// The top-level metadata key a projected value is read from: never blank, and never under
2357/// the namespace this product reserves.
2358#[derive(Debug, Clone, PartialEq, Eq)]
2359struct ProjectedKey(String);
2360
2361impl std::ops::Deref for ProjectedField {
2362 type Target = str;
2363 fn deref(&self) -> &str {
2364 &self.0
2365 }
2366}
2367
2368impl std::ops::Deref for ProjectedKey {
2369 type Target = str;
2370 fn deref(&self) -> &str {
2371 &self.0
2372 }
2373}
2374
2375/// What one item's metadata says one projected field should hold.
2376#[derive(Debug, Clone, PartialEq, Eq)]
2377enum Projected {
2378 /// A string to hold.
2379 Text(String),
2380 /// Nothing: the key or a step of the path is absent, or the value is `null`.
2381 Nothing,
2382}
2383
2384impl MetadataField {
2385 /// Validate every entry of one instance's `metadata_fields`, refusing the first that
2386 /// cannot stand, naming the source and the entry.
2387 fn resolve(
2388 configured: Vec<MetadataFieldConfig>,
2389 instance: &SourceName,
2390 ) -> Result<Vec<Self>, SourceError> {
2391 let mut resolved: Vec<Self> = Vec::with_capacity(configured.len());
2392 for (index, entry) in configured.into_iter().enumerate() {
2393 let refuse = |why: String| SourceError::Config {
2394 message: format!(
2395 "metadata_fields[{index}] of source {instance} (field {:?}, key {:?}) {why}",
2396 entry.field, entry.key
2397 ),
2398 };
2399 if entry.field.trim().is_empty() {
2400 return Err(refuse("names a blank field".to_owned()));
2401 }
2402 if entry.key.trim().is_empty() {
2403 return Err(refuse("names a blank key".to_owned()));
2404 }
2405 if entry.key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
2406 return Err(refuse(format!(
2407 "names a key under the reserved \"{}.\" prefix, which this product keeps \
2408 for its own keys; next: project a caller key instead",
2409 MetadataKey::RESERVED_NAMESPACE
2410 )));
2411 }
2412 if let Some(owned) = UNPROJECTABLE_FIELDS
2413 .iter()
2414 .find(|owned| owned.eq_ignore_ascii_case(&entry.field))
2415 {
2416 return Err(refuse(format!(
2417 "names the board field {owned:?}, which this source or GitHub already \
2418 writes; next: name a text field of your own"
2419 )));
2420 }
2421 if let Some(earlier) = resolved
2422 .iter()
2423 .position(|other| other.field.eq_ignore_ascii_case(&entry.field))
2424 {
2425 return Err(refuse(format!(
2426 "names the same board field as metadata_fields[{earlier}]; one field \
2427 cannot hold two values"
2428 )));
2429 }
2430 resolved.push(Self {
2431 field: ProjectedField(entry.field),
2432 key: ProjectedKey(entry.key),
2433 path: entry.path,
2434 });
2435 }
2436 Ok(resolved)
2437 }
2438
2439 /// The path as a refusal spells it: `[]` for the key's own value.
2440 fn spelled_path(&self) -> String {
2441 serde_json::to_string(&self.path).unwrap_or_else(|_| format!("{:?}", self.path))
2442 }
2443
2444 /// What `metadata` says this field should hold, or the refusal for a value no text field
2445 /// can hold.
2446 fn projected(
2447 &self,
2448 metadata: &BTreeMap<String, Value>,
2449 instance: &SourceName,
2450 ) -> Result<Projected, SourceError> {
2451 let mut value = metadata.get(&*self.key);
2452 for step in &self.path {
2453 value = value.and_then(|held| held.get(step.as_str()));
2454 }
2455 let found = match value {
2456 None | Some(Value::Null) => return Ok(Projected::Nothing),
2457 Some(Value::String(text)) => return Ok(Projected::Text(text.clone())),
2458 Some(Value::Bool(_)) => "a boolean",
2459 Some(Value::Number(_)) => "a number",
2460 Some(Value::Array(_)) => "an array",
2461 Some(Value::Object(_)) => "an object",
2462 };
2463 Err(SourceError::Refused {
2464 message: format!(
2465 "source {instance} projects metadata key {:?} at path {} onto its board text \
2466 field {:?}, and this item holds {found} there; only a string, null or nothing \
2467 can be written to a text field; next: store a string there, or remove the \
2468 value",
2469 &*self.key,
2470 self.spelled_path(),
2471 &*self.field
2472 ),
2473 })
2474 }
2475}
2476
2477/// What one item's `Priority` field says, read through this instance's mapping.
2478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
2479enum HeldPriority {
2480 /// A priority this source reports: an option the mapping names, or no value (`none`).
2481 Read(Priority),
2482 /// An option the mapping does not name, which is never read as a level or as `none`.
2483 Unmapped(String),
2484}
2485
2486/// How fast this source writes, and how long it waits out a rate-limit refusal.
2487///
2488/// Configurable because a GitHub Enterprise installation sets its own limits and an
2489/// operator who has already been refused may want to go slower still — not because the
2490/// defaults are guesses.
2491#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2492#[serde(default, deny_unknown_fields)]
2493pub struct PacingConfig {
2494 /// Shortest interval between two content-creating mutations, in milliseconds.
2495 ///
2496 /// Zero sends them as fast as they are asked for, which is what a fixture server on
2497 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
2498 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.
2499 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
2500 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
2501 /// zero while there is a budget to spend, because a schedule of zero-length waits
2502 /// consumes none of it and so never ends.
2503 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.
2504 /// Total time one call may spend waiting out rate limits, in milliseconds.
2505 ///
2506 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
2507 /// the bound is what makes this a wait rather than a hang.
2508 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.
2509}
2510
2511/// The largest any pacing setting may be, in milliseconds.
2512///
2513/// One hour. GitHub's own harshest published bound on content-generating requests works
2514/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
2515/// anything a real limit asks for, and past it the settings stop describing pacing at all:
2516/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
2517/// and an interval beyond it is a command that never sends its second mutation. It also
2518/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
2519/// what a `Duration` can hold on every platform.
2520pub const MAX_PACING_MS: u64 = 3_600_000;
2521
2522/// [`PacingConfig`] with every default resolved and every value checked, which is what the
2523/// source holds.
2524#[derive(Debug, Clone, Copy)]
2525struct Pacing {
2526 min_mutation_interval: Duration,
2527 retry_backoff: Duration,
2528 retry_budget: Duration,
2529}
2530
2531impl Pacing {
2532 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
2533 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
2534 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
2535 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
2536 message: format!(
2537 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
2538 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
2539 GitHub's own harshest published limit"
2540 ),
2541 }),
2542 Some(value) => Ok(Duration::from_millis(value)),
2543 None => Ok(Duration::from_millis(default)),
2544 };
2545 let retry_backoff = bounded(
2546 config.retry_backoff_ms,
2547 RETRY_BACKOFF_MS,
2548 "retry_backoff_ms",
2549 )?;
2550 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
2551 if retry_backoff.is_zero() && !retry_budget.is_zero() {
2552 return Err(SourceError::Config {
2553 message: format!(
2554 "pacing.retry_backoff_ms of source {instance} is 0 while \
2555 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
2556 none of that budget, so it would retry a refusal forever. Set a backoff of \
2557 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
2558 waiting at all",
2559 retry_budget.as_millis()
2560 ),
2561 });
2562 }
2563 Ok(Self {
2564 min_mutation_interval: bounded(
2565 config.min_mutation_interval_ms,
2566 MIN_MUTATION_INTERVAL_MS,
2567 "min_mutation_interval_ms",
2568 )?,
2569 retry_backoff,
2570 retry_budget,
2571 })
2572 }
2573}
2574
2575/// Factory for [`GitHubProjectsSource`].
2576#[derive(Debug, Clone, Copy, Default)]
2577pub struct Plugin;
2578
2579impl SourcePlugin for Plugin {
2580 fn kind(&self) -> &'static str {
2581 KIND
2582 }
2583 /// The endpoint, the owner and the board's number: what names one board.
2584 fn remote_identity(&self, config: &Value) -> Option<String> {
2585 let config: GitHubProjectsConfig = serde_json::from_value(config.clone()).ok()?;
2586 Some(format!(
2587 "{}\n{}\n{}",
2588 config.endpoint, config.owner, config.project_number
2589 ))
2590 }
2591 fn config_schema(&self) -> Schema {
2592 schema_for!(GitHubProjectsConfig)
2593 }
2594 fn build(
2595 &self,
2596 name: &SourceName,
2597 config: &Value,
2598 secrets: &dyn SecretResolver,
2599 ) -> Result<Box<dyn TaskSource>, SourceError> {
2600 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2601 }
2602
2603 fn build_with_clock(
2604 &self,
2605 name: &SourceName,
2606 config: &Value,
2607 secrets: &dyn SecretResolver,
2608 clock: SharedClock,
2609 ) -> Result<Box<dyn TaskSource>, SourceError> {
2610 self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2611 }
2612}
2613
2614impl Plugin {
2615 /// Build a source recording every request it sends into an accounting the caller holds.
2616 ///
2617 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2618 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2619 /// session total rather than two — see [`accounting`] and
2620 /// [`GitHubProjectsSource::recording_into`].
2621 ///
2622 /// # Errors
2623 ///
2624 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2625 /// [`SourceError::Config`] for configuration this plugin cannot use and
2626 /// [`SourceError::Auth`] for a credential it cannot find.
2627 pub fn build_recording_into(
2628 &self,
2629 name: &SourceName,
2630 config: &Value,
2631 secrets: &dyn SecretResolver,
2632 ledger: Arc<Accounting>,
2633 ) -> Result<Box<dyn TaskSource>, SourceError> {
2634 self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2635 }
2636
2637 fn build_recording_with_clock(
2638 &self,
2639 name: &SourceName,
2640 config: &Value,
2641 secrets: &dyn SecretResolver,
2642 ledger: Arc<Accounting>,
2643 clock: SharedClock,
2644 ) -> Result<Box<dyn TaskSource>, SourceError> {
2645 let config: GitHubProjectsConfig =
2646 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2647 message: format!("source {name}: {e}"),
2648 })?;
2649 let prefix = format!("source {name}: ");
2650 let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2651 .map_err(|error| match error {
2652 // The shared `StatusMapping::distinct` names the source itself.
2653 SourceError::Config { message } if message.starts_with(&prefix) => {
2654 SourceError::Config { message }
2655 }
2656 SourceError::Config { message } => SourceError::Config {
2657 message: format!("{prefix}{message}"),
2658 },
2659 SourceError::Auth { message } => SourceError::Auth {
2660 message: format!("source {name}: {message}"),
2661 },
2662 other => other,
2663 })?;
2664 source.clock = clock;
2665 Ok(Box::new(source))
2666 }
2667}
2668
2669/// Where a status category lands on this board, once configuration is resolved.
2670#[derive(Debug, Clone, PartialEq, Eq)]
2671enum StatusTarget {
2672 /// Not usable against this instance for this kind, and why.
2673 Disabled(UnmappedStatus),
2674 /// The board's `Status` option of this name.
2675 Column(ColumnName),
2676 /// A closed issue, with both its board option and the reason that says which closed it means.
2677 // 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.
2678 Terminal(ColumnName, ClosedState),
2679}
2680
2681/// Every status category, in the order the vocabulary declares them.
2682///
2683/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2684/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2685/// added to the shared vocabulary fails to compile until it is named there, and this
2686/// crate's suite reconciles this list against that enum's own derived schema, which is
2687/// generated from the variants rather than written beside them. The schema is what
2688/// catches a list left one short — a list checking only the positions it already holds
2689/// would pass while every mapping indexed by the new position panicked.
2690pub const CATEGORIES: [StatusCategory; 8] = [
2691 StatusCategory::Draft,
2692 StatusCategory::Backlog,
2693 StatusCategory::Todo,
2694 StatusCategory::Queued,
2695 StatusCategory::InProgress,
2696 StatusCategory::Done,
2697 StatusCategory::Cancelled,
2698 StatusCategory::Unknown,
2699];
2700
2701/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2702#[must_use]
2703pub const fn category_position(category: StatusCategory) -> usize {
2704 match category {
2705 StatusCategory::Draft => 0,
2706 StatusCategory::Backlog => 1,
2707 StatusCategory::Todo => 2,
2708 StatusCategory::Queued => 3,
2709 StatusCategory::InProgress => 4,
2710 StatusCategory::Done => 5,
2711 StatusCategory::Cancelled => 6,
2712 StatusCategory::Unknown => 7,
2713 }
2714}
2715
2716/// The spelling a status category is configured and reported under.
2717fn category_name(category: StatusCategory) -> &'static str {
2718 match category {
2719 StatusCategory::Draft => "draft",
2720 StatusCategory::Backlog => "backlog",
2721 StatusCategory::Todo => "todo",
2722 StatusCategory::Queued => "queued",
2723 StatusCategory::InProgress => "in-progress",
2724 StatusCategory::Done => "done",
2725 StatusCategory::Cancelled => "cancelled",
2726 StatusCategory::Unknown => "unknown",
2727 }
2728}
2729
2730/// A shipped default's option name.
2731///
2732/// The literals below are this file's own and non-blank, and they are validated by the
2733/// one constructor a configured name goes through rather than beside it.
2734fn shipped_column(name: &'static str) -> ColumnName {
2735 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2736}
2737
2738/// The shipped default for one category this instance's `status_mapping` does not mention,
2739/// for either kind.
2740fn shipped_default(category: StatusCategory) -> StatusTarget {
2741 match category {
2742 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2743 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2744 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2745 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2746 StatusCategory::Done => {
2747 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2748 }
2749 StatusCategory::Cancelled => {
2750 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2751 }
2752 StatusCategory::Draft | StatusCategory::Unknown => {
2753 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2754 }
2755 }
2756}
2757
2758/// The two kinds a status is written and read for, each with its own half of the mapping.
2759const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2760
2761/// This instance's complete category-to-target mapping for each kind, read in both
2762/// directions.
2763///
2764/// One target per category per kind, held at that category's own [`category_position`], so
2765/// a category missing from the mapping, named twice in it, or filed out of order is a state
2766/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2767/// targets are options of the board's one `Status` field.
2768#[derive(Debug, Clone)]
2769struct BoardStatuses {
2770 tasks: [StatusTarget; CATEGORIES.len()],
2771 projects: [StatusTarget; CATEGORIES.len()],
2772}
2773
2774impl BoardStatuses {
2775 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2776 /// would read back from one option.
2777 ///
2778 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2779 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2780 /// omits unmapped rather than defaulted.
2781 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2782 let resolve_kind =
2783 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2784 // `CATEGORIES[position] == category` for every category — the crate's suite
2785 // asserts it — so mapping the list in order fills each category's own slot.
2786 let mut targets = CATEGORIES.map(shipped_default);
2787 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2788 if !configured.mentions(category) {
2789 continue;
2790 }
2791 *slot = match configured.name_for(category, kind) {
2792 Err(why) => StatusTarget::Disabled(why),
2793 Ok(name) => {
2794 let option = ColumnName::try_from(name.as_str().to_owned())
2795 .map_err(|message| SourceError::Config { message })?;
2796 match category {
2797 StatusCategory::Done => {
2798 StatusTarget::Terminal(option, ClosedState::Completed)
2799 }
2800 StatusCategory::Cancelled => {
2801 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2802 }
2803 _ => StatusTarget::Column(option),
2804 }
2805 }
2806 };
2807 }
2808 StatusMapping::distinct(
2809 instance,
2810 kind,
2811 CATEGORIES
2812 .iter()
2813 .zip(&targets)
2814 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2815 )?;
2816 Ok(targets)
2817 };
2818 Ok(Self {
2819 tasks: resolve_kind(ItemKind::Task)?,
2820 projects: resolve_kind(ItemKind::Project)?,
2821 })
2822 }
2823
2824 /// Every category's target for `kind`, in category order.
2825 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2826 match kind {
2827 ItemKind::Task => &self.tasks,
2828 ItemKind::Project => &self.projects,
2829 }
2830 }
2831
2832 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2833 &self.targets(kind)[category_position(category)]
2834 }
2835
2836 /// The category a board option name reports for `kind`, or `None` when nothing of that
2837 /// kind maps to it.
2838 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2839 CATEGORIES.into_iter().find(|category| {
2840 self.target(kind, *category)
2841 .option()
2842 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2843 })
2844 }
2845
2846 /// Every option name either kind maps a category to, each once ignoring case, in
2847 /// category order with a task's name before a project's — what the guarded setup asks
2848 /// the `Status` field to hold.
2849 fn wanted(&self) -> Vec<String> {
2850 let mut wanted: Vec<String> = Vec::new();
2851 for category in CATEGORIES {
2852 for kind in STATUS_KINDS {
2853 if let Some(name) = self.target(kind, category).option()
2854 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2855 {
2856 wanted.push(name.to_owned());
2857 }
2858 }
2859 }
2860 wanted
2861 }
2862
2863 /// The status an item of `kind` reports, from the three things a read of it says: its
2864 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2865 ///
2866 /// The closed state decides the category and the `Status` option decides the name, so
2867 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2868 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2869 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2870 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2871 /// it is read permissively rather than refused — reads are faithful, and refusals
2872 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2873 /// option that mapping does not name reads as `Unknown` under its own name.
2874 ///
2875 /// One function of those three rather than of a response, so a narrow status write can
2876 /// answer what a re-read would report by applying it to the state it has just written.
2877 fn status(
2878 &self,
2879 kind: ItemKind,
2880 option: Option<&str>,
2881 closed: bool,
2882 reason: Option<&str>,
2883 ) -> Status {
2884 if closed {
2885 let category = match reason {
2886 None | Some("COMPLETED") => StatusCategory::Done,
2887 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2888 Some(_) => StatusCategory::Unknown,
2889 };
2890 let fallback = match category {
2891 StatusCategory::Done => "Done",
2892 StatusCategory::Cancelled => "Cancelled",
2893 _ => "Closed",
2894 };
2895 return Status {
2896 category,
2897 name: option.unwrap_or(fallback).to_owned(),
2898 };
2899 }
2900 let name = option.unwrap_or("Open").to_owned();
2901 Status {
2902 category: self
2903 .category_of(kind, &name)
2904 .unwrap_or(StatusCategory::Unknown),
2905 name,
2906 }
2907 }
2908}
2909
2910impl BoardStatuses {
2911 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2912 /// case; a kind lacking none is left out.
2913 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2914 STATUS_KINDS
2915 .into_iter()
2916 .filter_map(|kind| {
2917 let missing: Vec<String> = self
2918 .targets(kind)
2919 .iter()
2920 .filter_map(StatusTarget::option)
2921 .filter(|wanted| {
2922 !existing
2923 .iter()
2924 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2925 })
2926 .map(str::to_owned)
2927 .collect();
2928 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2929 })
2930 .collect()
2931 }
2932}
2933
2934impl StatusTarget {
2935 /// The board option this target selects, or `None` for an unmapped one.
2936 fn option(&self) -> Option<&str> {
2937 match self {
2938 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2939 Self::Disabled(_) => None,
2940 }
2941 }
2942}
2943
2944// 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.
2945/// One repository this source can create an issue in, as `owner/name`.
2946///
2947/// Every `createIssue` this source sends names one of these: the item's own single
2948/// `repositories` entry, else its parent project issue's repository, else the configured
2949/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2950/// that choice and says what it refuses before `createIssue`.
2951// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2952#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2953struct RepositoryTarget {
2954 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2955 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2956}
2957
2958impl RepositoryTarget {
2959 fn parse(value: &str) -> Result<Self, SourceError> {
2960 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2961 message: format!(
2962 "repository must be spelled owner/name; {value:?} names no repository"
2963 ),
2964 })?;
2965 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2966 return Err(SourceError::Config {
2967 message: format!(
2968 "repository must be spelled owner/name with a GitHub login and one \
2969 repository name; {value:?} is not"
2970 ),
2971 });
2972 }
2973 Ok(Self {
2974 owner: owner.to_owned(),
2975 name: name.to_owned(),
2976 })
2977 }
2978
2979 /// The one host whose repositories this source creates issues in, spelled once: it is
2980 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2981 const HOST: &str = "github.com";
2982
2983 fn origin(&self) -> String {
2984 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2985 }
2986
2987 /// The repository a normalized origin names, or why it is none this source can create
2988 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2989 fn from_origin(origin: &Repository) -> Result<Self, String> {
2990 let not_here = || {
2991 format!(
2992 "{} is not a {}/owner/name repository",
2993 origin.as_str(),
2994 Self::HOST
2995 )
2996 };
2997 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2998 if host != Self::HOST {
2999 return Err(not_here());
3000 }
3001 Self::parse(rest).map_err(|_| not_here())
3002 }
3003
3004 fn slug(&self) -> String {
3005 format!("{}/{}", self.owner, self.name)
3006 }
3007}
3008
3009/// A source which reads GitHub afresh for every operation.
3010pub struct GitHubProjectsSource {
3011 /// This source's configured name, used both to tell a far end naming this source
3012 /// from one naming a system it knows nothing about, and to name the instance a
3013 /// status refusal is about.
3014 name: SourceName,
3015 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
3016 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
3017 repository: Option<RepositoryTarget>,
3018 endpoint: Url,
3019 token: SecretString,
3020 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
3021 statuses: BoardStatuses,
3022 /// Where each priority lands on this board, or `None` when this instance holds none.
3023 priorities: Option<PriorityMapping>,
3024 /// The metadata values this instance projects onto board text fields, in configured order.
3025 metadata_fields: Vec<MetadataField>,
3026 client: Client,
3027 asset_client: Client,
3028 /// Every item this source has created in this command, in the order it created them —
3029 /// dropped by [`TaskSource::end_command`].
3030 ///
3031 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
3032 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
3033 /// a copy resolving a dependency on an item it had just created refused it as not
3034 /// found. A board read is completed from this — an item remembered here and absent from
3035 /// the read is added back, because the board really does hold it and only the read is
3036 /// behind.
3037 ///
3038 /// It is not a cache of a user's work: nothing is remembered that this process did not
3039 /// itself just write, it lives and dies with the process, and it is never consulted for
3040 /// an item this source did not create.
3041 created: Mutex<Vec<Resolved>>,
3042 /// Every item that already existed and that this source has written in this command, as
3043 /// it wrote it — dropped by [`TaskSource::end_command`].
3044 ///
3045 /// The other half of [`Self::created`], held on the same terms and for the reason a
3046 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
3047 /// filter is an index behind a write this process made moments ago, so a query matching
3048 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
3049 /// is remembered that this process did not itself just write.
3050 updated: Mutex<Vec<Resolved>>,
3051 /// Every issue this source has added a comment to or edited a comment of in this command
3052 /// — dropped by [`TaskSource::end_command`].
3053 ///
3054 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
3055 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
3056 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
3057 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
3058 /// from before — until the index caught up. Each id here is a candidate of every such
3059 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
3060 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
3061 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
3062 /// process wrote is still found only once the index has it.
3063 commented: Mutex<Vec<NativeId>>,
3064 /// How fast this source writes, and how long it waits out a refusal.
3065 pacing: Pacing,
3066 /// When the last content-creating mutation finished, or the moment the furthest-out
3067 /// reserved slot releases the next one, whichever is later — so the one after it can be
3068 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
3069 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
3070 /// what it is measured from.
3071 last_mutation: Mutex<Option<Duration>>,
3072 clock: SharedClock,
3073 numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
3074 /// The board as this process last read it, for the length of one command — dropped by
3075 /// [`TaskSource::end_command`].
3076 ///
3077 /// A copy of a project used to re-read the whole board, paged, before writing each of
3078 /// its items, which is by far the largest part of a copy's request count and none of
3079 /// its work. Nothing else changes this board while a command runs — this source's own
3080 /// writes are the only writer — so one read answers them all.
3081 ///
3082 /// It is not a store of a user's work and it is not the cache the no-persistence
3083 /// invariant forbids: it lives and dies with the process exactly as `created` does,
3084 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
3085 /// an item this command created and then depends on resolves whether or not GitHub's
3086 /// own eventually-consistent read has caught up. A write to an item already on the
3087 /// board updates the entry here too, so what this holds is the last read plus this
3088 /// process's own writes rather than a snapshot taken before them.
3089 board_cache: Mutex<Option<Board>>,
3090 /// Every issue this board's own search reported, for the length of one command — dropped
3091 /// by [`TaskSource::end_command`].
3092 ///
3093 /// The second half of a board read, and cached for the same reason and on the same
3094 /// terms as the first: it lives and dies with the process, nothing is written down, and
3095 /// a write this process makes updates the entry here exactly as it updates the one in
3096 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
3097 /// that lists this board's projects and its tasks pays for one search rather than two.
3098 search_cache: Mutex<Option<Vec<Resolved>>>,
3099 /// What each narrowed question GitHub was asked answered, keyed by that question, for
3100 /// the length of one command — dropped by [`TaskSource::end_command`].
3101 ///
3102 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
3103 /// and dies with the process, nothing is written down, a write this process makes
3104 /// updates the entry here as it updates the other two, and every answer is completed
3105 /// with this process's own writes each time it is given. A command that asks the same
3106 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
3107 /// write — pays for it once, which is what the whole-board read it replaced gave it.
3108 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
3109 search_next: Mutex<BTreeMap<String, Option<String>>>,
3110 /// Records already resolved in this command, reused by writes and for comment identity.
3111 /// Explicit item reads still reach GitHub. Nothing is persisted, and
3112 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
3113 /// its item as a person has since left it.
3114 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
3115 /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
3116 /// asked for under, with the project that selector named — for the length of one command,
3117 /// dropped by [`TaskSource::end_command`].
3118 ///
3119 /// A caller pages through a project's tasks one engine page at a time, and every page
3120 /// is cut from the whole list of them, so without this each page walked every
3121 /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
3122 /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
3123 /// dies with the process, nothing is written down, a write this process makes updates or
3124 /// removes the entry here as it does there, and every answer is completed with this
3125 /// process's own writes each time it is given.
3126 children_cache: Mutex<ProjectChildren>,
3127 /// The board's own id and field definitions as this process last read them on their
3128 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
3129 ///
3130 /// What a write needs of the board and its item does not say, read once per command
3131 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
3132 /// lives and dies with the process and nothing is written down. It holds no item and so
3133 /// can answer no question about one — see [`Self::board_fields`].
3134 fields_cache: Mutex<Option<BoardFields>>,
3135 /// Each destination repository's node id, resolved once per repository
3136 /// rather than per issue created.
3137 ///
3138 /// A repository's node id does not change, and re-reading it for every issue of a copy
3139 /// spent one request per item on an answer this source already had. It is a map rather
3140 /// than one entry because a copy files each item in the repository its own
3141 /// `repositories` field names, so a plan across five repositories asks GitHub five
3142 /// times and not once per item.
3143 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
3144 /// What every request this source sends is recorded into.
3145 ///
3146 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
3147 /// a request leaves this crate, so nothing has to be switched on for a session to be
3148 /// counted. It is shared rather than owned so a caller accounting for a whole session —
3149 /// its own schema verification, board lookups, residue sweep and cleanup beside this
3150 /// source's reads and writes — adds up one accounting instead of two. See
3151 /// [`accounting`] for what a record carries and what a session's spend is and is not.
3152 ledger: Arc<Accounting>,
3153 /// What [`expected`] holds for a write the store's cache expects: the journal while one is
3154 /// open, the issues primed from the cache, and the comments known on each.
3155 expected: expected::Held,
3156}
3157
3158/// GitHub's closed single-select color vocabulary.
3159#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
3160#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
3161pub enum StatusOptionColor {
3162 /// Gray.
3163 Gray,
3164 /// Blue.
3165 Blue,
3166 /// Green.
3167 Green,
3168 /// Yellow.
3169 Yellow,
3170 /// Purple.
3171 Purple,
3172 /// Red.
3173 Red,
3174 /// Orange.
3175 Orange,
3176 /// Pink.
3177 Pink,
3178}
3179
3180/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
3181/// applies its additions.
3182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3183pub enum SetupMode {
3184 /// Read without mutation.
3185 Plan,
3186 /// Apply and verify.
3187 Apply,
3188}
3189
3190/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
3191/// against it goes on compiling.
3192pub type StatusOptionsMode = SetupMode;
3193
3194/// The explicit result of the requested operation.
3195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3196#[serde(rename_all = "kebab-case")]
3197pub enum StatusOptionsOutcome {
3198 /// A read-only plan.
3199 Planned,
3200 /// Apply found nothing missing.
3201 Unchanged,
3202 /// Additions were applied and verified.
3203 Applied,
3204}
3205
3206/// A GitHub single-select option's opaque GraphQL node identifier.
3207#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3208#[serde(transparent)]
3209pub struct StatusOptionId(#[schemars(length(min = 1))] String);
3210
3211impl TryFrom<String> for StatusOptionId {
3212 type Error = String;
3213
3214 fn try_from(id: String) -> Result<Self, Self::Error> {
3215 if id.trim().is_empty() {
3216 return Err("a GitHub Status option id cannot be blank".to_owned());
3217 }
3218 Ok(Self(id))
3219 }
3220}
3221
3222/// One existing or proposed option in a guarded Status-field update.
3223#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3224pub struct StatusOption {
3225 /// GitHub's stable id.
3226 pub id: StatusOptionId,
3227 /// The visible option name.
3228 pub name: ColumnName,
3229 /// GitHub's single-select color token.
3230 pub color: StatusOptionColor,
3231 /// The option description, including an empty one.
3232 pub description: String,
3233}
3234
3235/// One board item's Status assignment, retained as recovery data.
3236#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3237pub struct StatusAssignment {
3238 /// The project item id whose assignment this is.
3239 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
3240 // carried verbatim as operator recovery data; introducing a semantic type would claim
3241 // validation rules GitHub does not publish and no operation here interprets.
3242 pub item_id: String,
3243 /// The selected option, absent when the item has no status.
3244 #[serde(skip_serializing_if = "Option::is_none")]
3245 pub option: Option<AssignedStatusOption>,
3246}
3247
3248/// The inseparable id and name of an assigned option.
3249#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3250pub struct AssignedStatusOption {
3251 /// GitHub's stable id.
3252 pub id: StatusOptionId,
3253 /// The visible name.
3254 pub name: ColumnName,
3255}
3256
3257/// The plan and verified outcome of reconciling configured Status options.
3258#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3259pub struct StatusOptionsReport {
3260 /// The configured source name.
3261 pub source: SourceName,
3262 /// Configured option names absent before the operation.
3263 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
3264 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
3265 // serialized string here preserves the report's intentionally simple public contract.
3266 pub missing: Vec<String>,
3267 /// What the requested operation did.
3268 pub outcome: StatusOptionsOutcome,
3269 /// The complete option list observed before any mutation.
3270 pub existing: Vec<StatusOption>,
3271}
3272
3273#[derive(Debug, Clone, PartialEq, Eq)]
3274struct StatusSnapshot {
3275 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3276 // passed back as the mutation's project identity; a newtype could enforce no stronger
3277 // invariant because GitHub publishes no grammar for it.
3278 board_id: String,
3279 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3280 // passed back as the mutation's field identity; a newtype could enforce no stronger
3281 // invariant because GitHub publishes no grammar for it.
3282 field_id: String,
3283 options: Vec<StatusOption>,
3284 assignments: Vec<StatusAssignment>,
3285}
3286
3287/// The name of the board field a status is held in.
3288const STATUS_FIELD: &str = "Status";
3289
3290/// Every item's value of each field `report` names, as it stood before the setup wrote
3291/// anything — what a person puts back when the setup is refused part way.
3292fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
3293 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
3294 .fields
3295 .iter()
3296 .map(|field| (field.field.name(), before.assignments(field.field)))
3297 .collect();
3298 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
3299 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
3300 })
3301}
3302
3303/// One board field the guarded setup reads and writes — every one it reads, and the only
3304/// ones it writes.
3305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
3306pub enum BoardField {
3307 /// The single-select `Status` field every instance's `status_mapping` resolves into.
3308 Status,
3309 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
3310 Priority,
3311}
3312
3313impl BoardField {
3314 /// The field's name on the board.
3315 #[must_use]
3316 pub const fn name(self) -> &'static str {
3317 match self {
3318 Self::Status => STATUS_FIELD,
3319 Self::Priority => PRIORITY_FIELD,
3320 }
3321 }
3322
3323 /// The field a board calls `name`, or `None` for one this setup does not own.
3324 fn named(name: &str) -> Option<Self> {
3325 [Self::Status, Self::Priority]
3326 .into_iter()
3327 .find(|field| field.name() == name)
3328 }
3329}
3330
3331/// What the guarded setup did to one field.
3332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3333#[serde(rename_all = "kebab-case")]
3334pub enum FieldOutcome {
3335 /// A read-only plan.
3336 Planned,
3337 /// Apply found the field there with every configured option.
3338 Unchanged,
3339 /// Missing options were added to the field that was there, and verified.
3340 Applied,
3341 /// The field was not there; it was created holding the configured options, and verified.
3342 Created,
3343}
3344
3345/// One field's plan, or its verified outcome.
3346#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3347pub struct FieldReport {
3348 /// Which field.
3349 pub field: BoardField,
3350 /// Whether the board had the field before the operation.
3351 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
3352 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
3353 // "outcome", "existing"}` — so folding one into the other would change a published JSON
3354 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
3355 // one constructor, and it derives `outcome` from `exists` in one match.
3356 pub exists: bool,
3357 /// Configured option names the field lacked before the operation — every one of them,
3358 /// in the order a new field lists them, when the field was not there at all.
3359 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3360 // mapping name and has therefore already passed its nonblank validation; the serialized
3361 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
3362 pub missing: Vec<String>,
3363 /// For the `Status` field, which item kind each missing name is configured for: one
3364 /// entry per kind `status_mapping` names a missing option for, task before project, each
3365 /// listing that kind's missing names in category order. A name both kinds use is in
3366 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
3367 /// `Priority`, which only a task holds.
3368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3369 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3370 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
3371 #[schemars(!skip_serializing_if)]
3372 pub kinds: Vec<KindMissing>,
3373 /// What the requested operation did.
3374 pub outcome: FieldOutcome,
3375 /// The field's complete option list observed before any mutation; empty when the field
3376 /// was not there.
3377 pub existing: Vec<StatusOption>,
3378}
3379
3380/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
3381#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3382pub struct KindMissing {
3383 /// The kind these names are configured for.
3384 pub kind: ItemKind,
3385 /// The names that kind maps a category to and the field lacked, in category order.
3386 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
3387 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
3388 // intentionally simple public contract.
3389 pub missing: Vec<String>,
3390}
3391
3392/// The plan and verified outcome of setting up every field a source's configuration names.
3393#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3394pub struct FieldsReport {
3395 /// The configured source name.
3396 pub source: SourceName,
3397 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
3398 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
3399 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
3400 // per field would change a published JSON shape. The states the list could hold and the
3401 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
3402 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
3403 pub fields: Vec<FieldReport>,
3404 /// Each board text field the source's `metadata_fields` projects a value onto, in
3405 /// configured order. Empty — and left out of the JSON — when it projects none.
3406 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3407 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
3408 // both SDKs model an absent list as an empty one rather than as `null`.
3409 #[schemars(!skip_serializing_if)]
3410 pub metadata_fields: Vec<MetadataFieldReport>,
3411}
3412
3413/// The type of a board field that is not a text field, as GitHub names it — its `dataType`, or
3414/// its GraphQL type when it has none — which is what a projected field's conflict reports.
3415///
3416/// Never empty, never beginning with whitespace, and never `TEXT`: a text field of that name is
3417/// no conflict.
3418// The schema states that rule as `NON_TEXT_FIELD_TYPE_PATTERN`, so a generated SDK model
3419// refuses exactly what `try_from` below does.
3420#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3421#[serde(transparent)]
3422pub struct NonTextFieldType(#[schemars(regex(pattern = NON_TEXT_FIELD_TYPE_PATTERN))] String);
3423
3424/// What a [`NonTextFieldType`] may be, as the emitted schema states it: a first character that
3425/// is not whitespace, and anything but exactly `TEXT` — spelled without a lookahead, which
3426/// neither SDK's validator can be relied on to support.
3427const NON_TEXT_FIELD_TYPE_PATTERN: &str =
3428 r"^(?:[^T\s]|T(?:[^E]|$)|TE(?:[^X]|$)|TEX(?:[^T]|$)|TEXT[\s\S])";
3429
3430impl TryFrom<String> for NonTextFieldType {
3431 type Error = String;
3432
3433 fn try_from(kind: String) -> Result<Self, Self::Error> {
3434 if kind.is_empty() || kind.starts_with(char::is_whitespace) {
3435 return Err("a board field's type cannot be blank or begin with whitespace".to_owned());
3436 }
3437 if kind == "TEXT" {
3438 return Err("a TEXT field is no conflict for a projected text field".to_owned());
3439 }
3440 Ok(Self(kind))
3441 }
3442}
3443
3444impl std::fmt::Display for NonTextFieldType {
3445 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3446 formatter.write_str(&self.0)
3447 }
3448}
3449
3450/// One projected metadata text field's plan, or its verified outcome.
3451///
3452/// The setup creates a missing one as a text field and never alters a field that is there:
3453/// a same-named field of another type is reported in `conflict` and left as it is.
3454#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
3455pub struct MetadataFieldReport {
3456 /// The board field's name.
3457 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.
3458 /// The metadata key the value is read from.
3459 pub key: String, // llmlint: ignore[invalid_states_unrepresentable] As `field`: a validated configuration value reported back as written.
3460 /// The object keys walked inside that key's value; empty for the key's own value.
3461 pub path: Vec<String>,
3462 /// Whether the board had a text field of that name before the operation.
3463 // 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.
3464 pub exists: bool,
3465 /// The type of a field of that name that is not a text field, which the setup leaves as
3466 /// it is; absent — and left out of the JSON — when there is none.
3467 #[serde(default, skip_serializing_if = "Option::is_none")]
3468 pub conflict: Option<NonTextFieldType>,
3469 /// What the requested operation did: `planned`; `created` for a field that was not
3470 /// there; `unchanged` for one that was, and for a conflict, which is never changed.
3471 pub outcome: FieldOutcome,
3472}
3473
3474/// Which options one field is configured with, in the order a new field would list them.
3475struct FieldPlan {
3476 field: BoardField,
3477 wanted: Vec<String>,
3478}
3479
3480/// One single-select field as the guarded setup snapshots it.
3481#[derive(Debug, Clone, PartialEq, Eq)]
3482struct SnapshotField {
3483 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3484 // passed back as the mutation's field identity; a newtype could enforce no stronger
3485 // invariant because GitHub publishes no grammar for it.
3486 field_id: String,
3487 options: Vec<StatusOption>,
3488}
3489
3490/// Every single-select field of a board and every item's value of each.
3491#[derive(Debug, Clone, PartialEq, Eq)]
3492struct BoardSnapshot {
3493 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
3494 // passed back as the mutation's project identity; a newtype could enforce no stronger
3495 // invariant because GitHub publishes no grammar for it.
3496 board_id: String,
3497 fields: BTreeMap<BoardField, SnapshotField>,
3498 /// Each board item's id, and its value of each field this setup owns that it holds one of.
3499 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
3500}
3501
3502impl BoardSnapshot {
3503 /// Every item's value of `field`, in board order — the recovery data a drift refusal
3504 /// carries.
3505 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
3506 self.items
3507 .iter()
3508 .map(|(item_id, values)| StatusAssignment {
3509 item_id: item_id.clone(),
3510 option: values.get(&field).cloned(),
3511 })
3512 .collect()
3513 }
3514}
3515
3516impl GitHubProjectsSource {
3517 /// Report missing configured Status options and, when `apply` is true, add them with
3518 /// a whole-list mutation that preserves every existing id and verifies the result.
3519 ///
3520 /// # Errors
3521 ///
3522 /// Refuses a board without a single-select `Status` field. A post-write difference in
3523 /// any pre-existing option id or item assignment is refused with the complete pre-write
3524 /// assignment snapshot in the diagnostic for recovery.
3525 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
3526 // successful mutation, both drift refusals, source selection, missing Status, casing,
3527 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
3528 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
3529 // responses from entering the defensive malformed-response branches below.
3530 pub async fn status_options(
3531 &self,
3532 mode: StatusOptionsMode,
3533 ) -> Result<StatusOptionsReport, SourceError> {
3534 let before = self.status_snapshot().await?;
3535 // A terminal category's option is as configured as an open one's: a terminal
3536 // write validates it before closing and refuses when the board lacks it. Both
3537 // kinds' names are options of the one field, so both are asked for.
3538 let missing = self
3539 .statuses
3540 .wanted()
3541 .into_iter()
3542 .filter(|wanted| {
3543 !before
3544 .options
3545 .iter()
3546 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3547 })
3548 .collect::<Vec<_>>();
3549 let report = StatusOptionsReport {
3550 source: self.name.clone(),
3551 missing: missing.clone(),
3552 outcome: match (mode, missing.is_empty()) {
3553 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
3554 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
3555 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
3556 },
3557 existing: before.options.clone(),
3558 };
3559 if mode == StatusOptionsMode::Plan || missing.is_empty() {
3560 return Ok(report);
3561 }
3562 let mut options = before
3563 .options
3564 .iter()
3565 .map(|option| {
3566 json!({
3567 "id": option.id, "name": option.name, "color": option.color,
3568 "description": option.description,
3569 })
3570 })
3571 .collect::<Vec<_>>();
3572 options.extend(missing.iter().map(|name| {
3573 json!({
3574 "name": name, "color": "GRAY", "description": ""
3575 })
3576 }));
3577 self.graphql(
3578 graphql::STATUS_OPTIONS_UPDATE,
3579 json!({"input": {
3580 "projectId": before.board_id, "fieldId": before.field_id,
3581 "singleSelectOptions": options,
3582 }}),
3583 )
3584 .await?;
3585 let after = self.status_snapshot().await?;
3586 let options_preserved = before
3587 .options
3588 .iter()
3589 .all(|old| after.options.iter().any(|new| new == old));
3590 let additions_present = missing.iter().all(|wanted| {
3591 after
3592 .options
3593 .iter()
3594 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3595 });
3596 if !options_preserved || !additions_present || after.assignments != before.assignments {
3597 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
3598 SourceError::Malformed {
3599 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
3600 }
3601 })?;
3602 return Err(SourceError::Refused {
3603 message: format!(
3604 "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}"
3605 ),
3606 });
3607 }
3608 Ok(report)
3609 }
3610
3611 /// A fresh snapshot of the Status field and every board item's assignment of it.
3612 ///
3613 /// # Errors
3614 ///
3615 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
3616 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
3617 // Status alone, as this operation has always read it: a `Priority` field is another
3618 // operation's, so nothing about it can refuse this one.
3619 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
3620 let field = board
3621 .fields
3622 .remove(&BoardField::Status)
3623 .ok_or_else(|| self.no_status_field())?;
3624 Ok(StatusSnapshot {
3625 assignments: board.assignments(BoardField::Status),
3626 board_id: board.board_id,
3627 field_id: field.field_id,
3628 options: field.options,
3629 })
3630 }
3631
3632 /// The refusal a board with no `Status` field is answered with by the guarded setup.
3633 fn no_status_field(&self) -> SourceError {
3634 SourceError::Refused {
3635 message: format!("source {} board has no Status field", self.name),
3636 }
3637 }
3638
3639 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
3640 // the real CLI loopback journey, including pagination. The individual malformed guards
3641 // are defensive validation of a schema-pinned third-party response, not separate user
3642 // journeys; drift and missing-field failures cover the operation's recovery behavior.
3643 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
3644 /// every board item's value of each, walked to the end of the board's items. A field not
3645 /// in `owned` is read past whatever it holds.
3646 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
3647 let mut after: Option<String> = None;
3648 let mut snapshot: Option<BoardSnapshot> = None;
3649 loop {
3650 let data = self
3651 .graphql(
3652 graphql::STATUS_OPTIONS_SNAPSHOT,
3653 json!({
3654 "owner": self.owner, "number": self.project_number,
3655 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3656 }),
3657 )
3658 .await?;
3659 let board = data
3660 .pointer("/owner/projectV2")
3661 .filter(|board| board.is_object())
3662 .ok_or_else(|| SourceError::Refused {
3663 message: format!(
3664 "source {} has no accessible GitHub Projects board",
3665 self.name
3666 ),
3667 })?;
3668 if board
3669 .pointer("/fields/pageInfo/hasNextPage")
3670 .and_then(Value::as_bool)
3671 != Some(false)
3672 {
3673 return Err(SourceError::Malformed {
3674 message:
3675 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3676 .into(),
3677 });
3678 }
3679 let mut fields = BTreeMap::new();
3680 // Only the fields this setup owns, by name: a node the single-select fragment did not
3681 // match carries no name, and a person's own single-select field — a `Size`, a
3682 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3683 // `Status` or `Priority` field without its options is malformed, not absent.
3684 // 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.
3685 for (owned, field) in board
3686 .pointer("/fields/nodes")
3687 .and_then(Value::as_array)
3688 .ok_or_else(|| SourceError::Malformed {
3689 message: "GitHub project fields.nodes is not an array".into(),
3690 })?
3691 .iter()
3692 .filter_map(|field| {
3693 let named = BoardField::named(field.get("name")?.as_str()?)?;
3694 owned.contains(&named).then_some((named, field))
3695 })
3696 {
3697 let options = field
3698 .get("options")
3699 .and_then(Value::as_array)
3700 .ok_or_else(|| SourceError::Malformed {
3701 message: "GitHub single-select field options is not an array".into(),
3702 })?
3703 .iter()
3704 .map(|option| {
3705 Ok(StatusOption {
3706 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3707 .map_err(|message| SourceError::Malformed { message })?,
3708 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3709 .map_err(|message| SourceError::Malformed {
3710 message: format!(
3711 "GitHub single-select option name is invalid: {message}"
3712 ),
3713 })?,
3714 color: serde_json::from_value(
3715 option.get("color").cloned().unwrap_or(Value::Null),
3716 )
3717 .map_err(|error| {
3718 SourceError::Malformed {
3719 message: format!(
3720 "GitHub single-select option color is invalid: {error}"
3721 ),
3722 }
3723 })?,
3724 description: optional_str(option, "description")?
3725 .unwrap_or_default()
3726 .to_owned(),
3727 })
3728 })
3729 .collect::<Result<Vec<_>, SourceError>>()?;
3730 let snapshot = SnapshotField {
3731 field_id: required_nonblank_str(field, "id")?.to_owned(),
3732 options,
3733 };
3734 // A board's field names are unique, so a second one is an answer that cannot
3735 // say which field the setup would act on — refused rather than one chosen.
3736 if fields.insert(owned, snapshot).is_some() {
3737 return Err(SourceError::Malformed {
3738 message: format!(
3739 "GitHub answered two {} fields for this board",
3740 owned.name()
3741 ),
3742 });
3743 }
3744 }
3745 let board_id = required_nonblank_str(board, "id")?.to_owned();
3746 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3747 board_id,
3748 fields,
3749 items: Vec::new(),
3750 });
3751 let items = board
3752 .pointer("/items/nodes")
3753 .and_then(Value::as_array)
3754 .ok_or_else(|| SourceError::Malformed {
3755 message: "GitHub project items.nodes is not an array".into(),
3756 })?;
3757 for item in items {
3758 let field_values =
3759 item.get("fieldValues")
3760 .ok_or_else(|| SourceError::Malformed {
3761 message: "GitHub project item is missing fieldValues".into(),
3762 })?;
3763 if field_values
3764 .pointer("/pageInfo/hasNextPage")
3765 .and_then(Value::as_bool)
3766 != Some(false)
3767 {
3768 return Err(SourceError::Malformed {
3769 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3770 });
3771 }
3772 let values = item
3773 .pointer("/fieldValues/nodes")
3774 .and_then(Value::as_array)
3775 .ok_or_else(|| SourceError::Malformed {
3776 message: "GitHub project item fieldValues.nodes is not an array".into(),
3777 })?;
3778 let item_id = required_nonblank_str(item, "id")?;
3779 let mut assigned = BTreeMap::new();
3780 for value in values {
3781 let Some(field) = value
3782 .pointer("/field/name")
3783 .and_then(Value::as_str)
3784 .and_then(BoardField::named)
3785 .filter(|field| owned.contains(field))
3786 else {
3787 continue;
3788 };
3789 let held = assigned.insert(
3790 field,
3791 AssignedStatusOption {
3792 id: StatusOptionId::try_from(
3793 required_str(value, "optionId")?.to_owned(),
3794 )
3795 .map_err(|message| SourceError::Malformed { message })?,
3796 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3797 .map_err(|message| SourceError::Malformed {
3798 message: format!(
3799 "GitHub assigned {} name is invalid: {message}",
3800 field.name()
3801 ),
3802 })?,
3803 },
3804 );
3805 // An item holds one value of a field, so a second one leaves no way to
3806 // tell which it holds — and a verification or recovery built on either
3807 // could restore the wrong one.
3808 if held.is_some() {
3809 return Err(SourceError::Malformed {
3810 message: format!(
3811 "GitHub answered two {} values for board item {item_id}",
3812 field.name()
3813 ),
3814 });
3815 }
3816 }
3817 current.items.push((item_id.to_owned(), assigned));
3818 }
3819 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3820 message: "GitHub project is missing items".into(),
3821 })?;
3822 let has_next = page
3823 .pointer("/pageInfo/hasNextPage")
3824 .and_then(Value::as_bool)
3825 .ok_or_else(|| SourceError::Malformed {
3826 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3827 })?;
3828 if !has_next {
3829 break;
3830 }
3831 let next =
3832 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3833 validate_cursor_progress(after.as_deref(), next)?;
3834 after = Some(next.to_owned());
3835 }
3836 snapshot.ok_or_else(|| SourceError::Malformed {
3837 message: "GitHub returned no board field snapshot".into(),
3838 })
3839 }
3840 // llmlint: ignore-end[changed_behavior_has_e2e]
3841
3842 /// Report every board field this source's configuration names and, with
3843 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3844 /// the `Priority` field when the board has none.
3845 ///
3846 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3847 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3848 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3849 /// color and description: the whole option list goes back with every existing id, because
3850 /// a re-minted id clears every item's value.
3851 ///
3852 /// # Errors
3853 ///
3854 /// Refuses a board without a single-select `Status` field. After an apply the board is
3855 /// read again, and a pre-existing option or any item's value of either field that moved is
3856 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3857 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3858 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3859 // with no Status field and a non-github-projects source through the compiled CLI against
3860 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3861 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3862 let owned: Vec<BoardField> = if self.priorities.is_some() {
3863 vec![BoardField::Status, BoardField::Priority]
3864 } else {
3865 vec![BoardField::Status]
3866 };
3867 let before = self.board_snapshot(&owned).await?;
3868 let mut plans = vec![FieldPlan {
3869 field: BoardField::Status,
3870 wanted: self.statuses.wanted(),
3871 }];
3872 if !before.fields.contains_key(&BoardField::Status) {
3873 return Err(self.no_status_field());
3874 }
3875 if let Some(mapping) = &self.priorities {
3876 plans.push(FieldPlan {
3877 field: BoardField::Priority,
3878 wanted: mapping.names().map(str::to_owned).collect(),
3879 });
3880 }
3881 // The snapshot reads single-select fields alone, so a field it did not find may still
3882 // be on the board under the name, of another type: creating one beside it would fail
3883 // part way, or leave two fields of one name. Asked of the board's own field list, and
3884 // only when a field is missing.
3885 if plans
3886 .iter()
3887 .any(|plan| !before.fields.contains_key(&plan.field))
3888 {
3889 let board = self.board_fields().await?;
3890 for plan in plans
3891 .iter()
3892 .filter(|plan| !before.fields.contains_key(&plan.field))
3893 {
3894 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3895 return Err(SourceError::Refused {
3896 message: format!(
3897 "source {}'s board has a {} field that is not a single-select field \
3898 (it is a {}), so it cannot hold this source's options; next: rename \
3899 or remove that field, then run this again",
3900 self.name,
3901 plan.field.name(),
3902 optional_str(field, "__typename")?.unwrap_or("field of another type")
3903 ),
3904 });
3905 }
3906 }
3907 }
3908 let mut reports = Vec::new();
3909 for plan in &plans {
3910 let held = before.fields.get(&plan.field);
3911 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3912 let mut missing: Vec<String> = Vec::new();
3913 for wanted in &plan.wanted {
3914 let present = existing
3915 .iter()
3916 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3917 || missing
3918 .iter()
3919 .any(|named| named.eq_ignore_ascii_case(wanted));
3920 if !present {
3921 missing.push(wanted.clone());
3922 }
3923 }
3924 let kinds = match plan.field {
3925 BoardField::Status => self.statuses.missing_by_kind(&existing),
3926 BoardField::Priority => Vec::new(),
3927 };
3928 reports.push(FieldReport {
3929 field: plan.field,
3930 exists: held.is_some(),
3931 kinds,
3932 outcome: match (mode, held.is_some(), missing.is_empty()) {
3933 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3934 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3935 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3936 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3937 },
3938 missing,
3939 existing,
3940 });
3941 }
3942 // A projected field is a text field, which the single-select snapshot cannot see, so
3943 // it is asked of the board's own field list — and only when one is configured.
3944 let mut metadata_fields = Vec::new();
3945 if !self.metadata_fields.is_empty() {
3946 let board = self.board_fields().await?;
3947 for projection in &self.metadata_fields {
3948 let conflict = match Board::field(&board.fields, &projection.field)? {
3949 None => None,
3950 Some(field) => {
3951 let typename = optional_str(field, "__typename")?;
3952 let data_type = optional_str(field, "dataType")?;
3953 if typename == Some("ProjectV2Field") && data_type == Some("TEXT") {
3954 None
3955 } else {
3956 let named = data_type
3957 .filter(|kind| *kind != "TEXT")
3958 .or(typename)
3959 .unwrap_or("field of another type");
3960 Some(NonTextFieldType::try_from(named.to_owned()).map_err(
3961 |message| SourceError::Malformed {
3962 message: format!(
3963 "GitHub answered the board's {} field with a type this \
3964 setup cannot read: {message}",
3965 &*projection.field
3966 ),
3967 },
3968 )?)
3969 }
3970 }
3971 };
3972 let exists =
3973 conflict.is_none() && Board::field(&board.fields, &projection.field)?.is_some();
3974 metadata_fields.push(MetadataFieldReport {
3975 field: projection.field.to_string(),
3976 key: projection.key.to_string(),
3977 path: projection.path.clone(),
3978 exists,
3979 outcome: match (mode, exists || conflict.is_some()) {
3980 (SetupMode::Plan, _) => FieldOutcome::Planned,
3981 (SetupMode::Apply, true) => FieldOutcome::Unchanged,
3982 (SetupMode::Apply, false) => FieldOutcome::Created,
3983 },
3984 conflict,
3985 });
3986 }
3987 }
3988 let report = FieldsReport {
3989 source: self.name.clone(),
3990 fields: reports,
3991 metadata_fields,
3992 };
3993 let writes: Vec<&FieldReport> = report
3994 .fields
3995 .iter()
3996 .filter(|field| !field.missing.is_empty() || !field.exists)
3997 .collect();
3998 let creates: Vec<&MetadataFieldReport> = report
3999 .metadata_fields
4000 .iter()
4001 .filter(|field| field.outcome == FieldOutcome::Created)
4002 .collect();
4003 if mode == SetupMode::Plan || (writes.is_empty() && creates.is_empty()) {
4004 return Ok(report);
4005 }
4006 let mut landed: Vec<&str> = Vec::new();
4007 for field in &writes {
4008 let added = field
4009 .missing
4010 .iter()
4011 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
4012 let sent = match before.fields.get(&field.field) {
4013 Some(held) => {
4014 let mut options = held
4015 .options
4016 .iter()
4017 .map(|option| {
4018 json!({
4019 "id": option.id, "name": option.name, "color": option.color,
4020 "description": option.description,
4021 })
4022 })
4023 .collect::<Vec<_>>();
4024 options.extend(added);
4025 self.graphql(
4026 graphql::STATUS_OPTIONS_UPDATE,
4027 json!({"input": {
4028 "projectId": before.board_id, "fieldId": held.field_id,
4029 "singleSelectOptions": options,
4030 }}),
4031 )
4032 .await
4033 }
4034 None => {
4035 self.graphql(
4036 graphql::CREATE_FIELD,
4037 json!({"input": {
4038 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
4039 "name": field.field.name(),
4040 "singleSelectOptions": added.collect::<Vec<_>>(),
4041 }}),
4042 )
4043 .await
4044 }
4045 };
4046 // A mutation that failed does not establish that GitHub left its field as it was,
4047 // so every failure from here on carries the recovery data a drift refusal does.
4048 match sent {
4049 Ok(_) => landed.push(field.field.name()),
4050 Err(error) => {
4051 let changed = if landed.is_empty() {
4052 String::new()
4053 } else {
4054 format!("changed the {} field and then ", landed.join(" and "))
4055 };
4056 return Err(SourceError::Refused {
4057 message: format!(
4058 "the guarded field setup {changed}failed on the {} field, which it may \
4059 have changed part way: {error}; the pre-write item assignments \
4060 are:\n{}",
4061 field.field.name(),
4062 recovery(&report, &before)?
4063 ),
4064 });
4065 }
4066 }
4067 }
4068 for field in &creates {
4069 let sent = self
4070 .graphql(
4071 graphql::CREATE_FIELD,
4072 json!({"input": {
4073 "projectId": before.board_id, "dataType": "TEXT", "name": field.field,
4074 }}),
4075 )
4076 .await;
4077 if let Err(error) = sent {
4078 let changed = if landed.is_empty() {
4079 String::new()
4080 } else {
4081 format!("changed the {} field and then ", landed.join(" and "))
4082 };
4083 return Err(SourceError::Refused {
4084 message: format!(
4085 "the guarded field setup {changed}failed creating the {} text field: \
4086 {error}; run it again to create what is still missing",
4087 field.field
4088 ),
4089 });
4090 }
4091 landed.push(&field.field);
4092 }
4093 // The board has been written, so a verification read that fails leaves it unverified
4094 // rather than unchanged, and says what to put back.
4095 let after = match self.board_snapshot(&owned).await {
4096 Ok(after) => after,
4097 Err(error) => {
4098 return Err(SourceError::Refused {
4099 message: format!(
4100 "the guarded field setup changed the {} field and then could not read the \
4101 board back to verify it: {error}; the pre-write item assignments are:\n{}",
4102 landed.join(" and "),
4103 recovery(&report, &before)?
4104 ),
4105 });
4106 }
4107 };
4108 let mut moved = Vec::new();
4109 for field in &report.fields {
4110 let name = field.field.name();
4111 let now = after
4112 .fields
4113 .get(&field.field)
4114 .map(|held| held.options.as_slice())
4115 .unwrap_or_default();
4116 if !field.existing.iter().all(|old| now.contains(old)) {
4117 moved.push(format!(
4118 "a pre-existing {name} option id, name, color or description"
4119 ));
4120 }
4121 if !field.missing.iter().all(|wanted| {
4122 now.iter()
4123 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
4124 }) {
4125 moved.push(format!("an added {name} option"));
4126 }
4127 if after.assignments(field.field) != before.assignments(field.field) {
4128 moved.push(format!("an item's {name} value"));
4129 }
4130 }
4131 if !creates.is_empty() {
4132 // Read afresh: the view held for this command is the one from before the creates.
4133 *self.fields_cache()? = None;
4134 let board = match self.board_fields().await {
4135 Ok(board) => board,
4136 Err(error) => {
4137 return Err(SourceError::Refused {
4138 message: format!(
4139 "the guarded field setup changed the {} field and then could not \
4140 read the board back to verify it: {error}; run it again to verify \
4141 it, which creates nothing a second time",
4142 landed.join(" and ")
4143 ),
4144 });
4145 }
4146 };
4147 for field in &creates {
4148 let text = Board::field(&board.fields, &field.field)?.is_some_and(|held| {
4149 held.get("__typename").and_then(Value::as_str) == Some("ProjectV2Field")
4150 && held.get("dataType").and_then(Value::as_str) == Some("TEXT")
4151 });
4152 if !text {
4153 moved.push(format!("the created {} text field", field.field));
4154 }
4155 }
4156 }
4157 if !moved.is_empty() {
4158 return Err(SourceError::Refused {
4159 message: format!(
4160 "GitHub changed {} after the guarded field setup; the pre-write item \
4161 assignments are:\n{}",
4162 moved.join(", "),
4163 recovery(&report, &before)?
4164 ),
4165 });
4166 }
4167 Ok(report)
4168 }
4169
4170 /// Validate configuration and capture the named credential without exposing it.
4171 ///
4172 /// # Errors
4173 ///
4174 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
4175 /// [`SourceError::Auth`] when the named credential is missing or empty.
4176 pub fn new(
4177 name: &SourceName,
4178 config: GitHubProjectsConfig,
4179 secrets: &dyn SecretResolver,
4180 ) -> Result<Self, SourceError> {
4181 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
4182 }
4183
4184 /// The same, recording every request it sends into an accounting the caller holds too.
4185 ///
4186 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
4187 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
4188 /// up — passes the one it records those into, so the session total accounts for the
4189 /// whole session rather than for this source's share of it.
4190 ///
4191 /// # Errors
4192 ///
4193 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
4194 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
4195 pub fn recording_into(
4196 name: &SourceName,
4197 config: GitHubProjectsConfig,
4198 secrets: &dyn SecretResolver,
4199 ledger: Arc<Accounting>,
4200 ) -> Result<Self, SourceError> {
4201 if !valid_github_owner(&config.owner) {
4202 return Err(SourceError::Config {
4203 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
4204 });
4205 }
4206 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
4207 return Err(SourceError::Config {
4208 message: format!("project_number must be between 1 and {}", i32::MAX),
4209 });
4210 }
4211 if !valid_environment_name(&config.token_env) {
4212 return Err(SourceError::Config {
4213 message: "token_env must be a valid environment-variable name".into(),
4214 });
4215 }
4216 let repository = config
4217 .repository
4218 .as_deref()
4219 .map(RepositoryTarget::parse)
4220 .transpose()?;
4221 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
4222 message: format!("endpoint is not a valid URL: {e}"),
4223 })?;
4224 if endpoint.scheme() != "https"
4225 && !(endpoint.scheme() == "http"
4226 && endpoint
4227 .host_str()
4228 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
4229 {
4230 return Err(SourceError::Config {
4231 message:
4232 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
4233 .into(),
4234 });
4235 }
4236 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
4237 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),
4238 })?;
4239 Ok(Self {
4240 name: name.clone(),
4241 owner: config.owner,
4242 project_number: config.project_number,
4243 repository,
4244 asset_client: assets::client(&endpoint)?,
4245 endpoint,
4246 token,
4247 credential_name: config.token_env,
4248 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
4249 priorities: config
4250 .priority_mapping
4251 .map(|mapping| PriorityMapping::resolve(mapping, name))
4252 .transpose()?,
4253 metadata_fields: MetadataField::resolve(config.metadata_fields, name)?,
4254 client: Client::builder()
4255 .user_agent("onetaskgraph")
4256 .build()
4257 .map_err(|e| SourceError::Config {
4258 message: format!("cannot build HTTP client: {e}"),
4259 })?,
4260 created: Mutex::new(Vec::new()),
4261 updated: Mutex::new(Vec::new()),
4262 commented: Mutex::new(Vec::new()),
4263 pacing: Pacing::resolve(config.pacing, name)?,
4264 last_mutation: Mutex::new(None),
4265 clock: system_clock(),
4266 numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
4267 board_cache: Mutex::new(None),
4268 search_cache: Mutex::new(None),
4269 narrowed_cache: Mutex::new(BTreeMap::new()),
4270 resolved_cache: Mutex::new(BTreeMap::new()),
4271 children_cache: Mutex::new(BTreeMap::new()),
4272 search_next: Mutex::new(BTreeMap::new()),
4273 fields_cache: Mutex::new(None),
4274 repository_cache: Mutex::new(BTreeMap::new()),
4275 ledger,
4276 expected: expected::Held::default(),
4277 })
4278 }
4279
4280 /// A snapshot of every request this source has sent, and what each cost.
4281 ///
4282 /// A value to hold and compare rather than a borrow of the accounting itself, so two
4283 /// of them can sit side by side. When this source was built with
4284 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
4285 /// point of building it that way.
4286 #[must_use]
4287 pub fn accounting(&self) -> accounting::Session {
4288 self.ledger.snapshot()
4289 }
4290
4291 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
4292 /// rate limit rather than handing it straight back as an error.
4293 ///
4294 /// Retrying is safe for every document here, including the mutations, and the reason
4295 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
4296 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
4297 /// this replays has already taken effect. An outcome this source cannot know — the
4298 /// send failed, or the body could not be read, so the mutation may well have landed —
4299 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
4300 /// attempt. A duplicate write would come from replaying one of those, and none is
4301 /// replayed.
4302 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4303 if !self.expected.journaling() {
4304 return self.graphql_sent(query, variables).await;
4305 }
4306 // While a write the store's cache expects runs, its mutations select the whole issue
4307 // they leave behind, so the response vouches for what the cache keeps — and every
4308 // request is recorded, so the write's parts are reported from their own responses.
4309 let (query, variables) = match graphql::whole_twin(query) {
4310 Some(twin) => (twin, expected::whole_variables(variables)),
4311 None => (query, variables),
4312 };
4313 let sent = self.graphql_sent(query, variables.clone()).await;
4314 self.journal(query, &variables, &sent).await;
4315 sent
4316 }
4317
4318 /// One document sent, retried while GitHub asks for a wait, exactly as asked.
4319 async fn graphql_sent(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
4320 if is_mutation(query)
4321 && ![
4322 graphql::ADD_COMMENT,
4323 graphql::UPDATE_COMMENT,
4324 graphql::DELETE_COMMENT,
4325 graphql::ADD_COMMENT_WHOLE,
4326 graphql::UPDATE_COMMENT_WHOLE,
4327 ]
4328 .contains(&query)
4329 {
4330 let mut cache = self.resolved_cache()?;
4331 for argument in ["input", "second", "third", "clear"] {
4332 if let Some(input) = variables.get(argument) {
4333 cache.retain(|id, item| {
4334 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
4335 input
4336 .get(key)
4337 .and_then(Value::as_str)
4338 .is_some_and(|value| value == id.0 || value == item.item_id)
4339 })
4340 });
4341 }
4342 }
4343 }
4344 let doing = operation_description(query);
4345 let mut waited = Duration::ZERO;
4346 let mut waits = 0_u32;
4347 let mut backoff = self.pacing.retry_backoff;
4348 loop {
4349 if is_mutation(query) {
4350 let spacing = self.reserve_mutation_slot();
4351 if !spacing.is_zero() {
4352 self.clock.sleep(spacing).await;
4353 }
4354 }
4355 let attempt = self.send_once(query, &variables, waits + 1).await;
4356 if is_mutation(query) {
4357 self.finish_mutation();
4358 }
4359 let limited = match attempt {
4360 Ok(data) => return Ok(data),
4361 Err(Attempt::Failed(error)) => return Err(error),
4362 Err(Attempt::Limited(limited)) => limited,
4363 };
4364 // GitHub really does send `retry-after: 0`, and retrying at once is the one
4365 // move that extends a secondary limit, so a hint below the schedule's own next
4366 // wait is raised to it.
4367 let wait = match limited.hint {
4368 Some(hint) => Duration::from_secs(hint).max(backoff),
4369 None => backoff,
4370 };
4371 let remaining = self.pacing.retry_budget.saturating_sub(waited);
4372 // A wait of nothing spends none of the budget, so it is exhaustion rather
4373 // than a retry. `Pacing::resolve` rules out every way of configuring one
4374 // except a budget of zero, where reporting the first refusal is the ask.
4375 if wait.is_zero() || wait > remaining {
4376 return Err(limited.exhausted(
4377 doing,
4378 waits,
4379 waited,
4380 wait,
4381 self.pacing.retry_budget,
4382 ));
4383 }
4384 self.clock.sleep(wait).await;
4385 waited += wait;
4386 waits += 1;
4387 backoff = backoff.saturating_mul(2);
4388 }
4389 }
4390
4391 /// The next moment a content-creating mutation may leave this source, as a wait from
4392 /// now.
4393 ///
4394 /// The slot is reserved under the lock and the waiting happens outside it, so two
4395 /// callers take two slots rather than the same one — and no lock is held across an
4396 /// await.
4397 ///
4398 /// The moment it is spaced from is the previous mutation's *completion*, which
4399 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
4400 /// own is the wrong thing to measure from.
4401 fn reserve_mutation_slot(&self) -> Duration {
4402 if self.pacing.min_mutation_interval.is_zero() {
4403 return Duration::ZERO;
4404 }
4405 // A poisoned lock here costs pacing, not correctness, and refusing the write over
4406 // it would turn an earlier failure into a second one for no gain.
4407 let mut last = self
4408 .last_mutation
4409 .lock()
4410 .unwrap_or_else(std::sync::PoisonError::into_inner);
4411 let now = self.clock.now();
4412 // `checked_add` rather than `+`: adding durations can panic on overflow, and
4413 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
4414 let at = last.map_or(now, |previous| {
4415 previous
4416 .checked_add(self.pacing.min_mutation_interval)
4417 .map_or(now, |earliest| earliest.max(now))
4418 });
4419 *last = Some(at);
4420 at.saturating_sub(now)
4421 }
4422
4423 /// Record that a content-creating mutation has finished, so the next one is spaced
4424 /// from here rather than from the moment this one was released.
4425 ///
4426 /// This source can only choose when a request *departs*; the limiter counts when it
4427 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
4428 /// departure from the last therefore hands the limiter a gap of the interval less that
4429 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
4430 /// exactly how a copy paced well inside a board's threshold was refused by it on a
4431 /// slower machine while passing on a quick one.
4432 ///
4433 /// Spacing from completion removes the subtraction rather than budgeting for it. The
4434 /// previous request had already arrived before its response came back, so its arrival
4435 /// is no later than this moment, and the next mutation is released at least the
4436 /// interval after this moment and arrives no earlier than it is released: the gap the
4437 /// limiter measures is therefore at least the interval, whatever transit costs and on
4438 /// whatever platform. The price is that a mutation's own round trip no longer counts
4439 /// towards its spacing, which makes this source slightly slower than the configured
4440 /// rate rather than slightly faster — the safe side of a limit that punishes being
4441 /// wrong by refusing reads for the next fifty minutes.
4442 ///
4443 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
4444 /// and one that never left costs only a wait nobody needed.
4445 fn finish_mutation(&self) {
4446 if self.pacing.min_mutation_interval.is_zero() {
4447 return;
4448 }
4449 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
4450 let mut last = self
4451 .last_mutation
4452 .lock()
4453 .unwrap_or_else(std::sync::PoisonError::into_inner);
4454 let now = self.clock.now();
4455 // `max` rather than an assignment: a concurrent caller may already have reserved a
4456 // slot further out, and completing this request must never pull that slot back in.
4457 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
4458 }
4459
4460 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
4461 /// failure that waiting cannot help — and recorded, whichever of the three it was.
4462 ///
4463 /// This is the one place a request leaves this crate, which is why the accounting is
4464 /// here rather than at each of the callers: a read path added later is counted without
4465 /// anybody remembering to count it, and
4466 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
4467 /// when one is not.
4468 async fn send_once(
4469 &self,
4470 query: &str,
4471 variables: &Value,
4472 number: u32,
4473 ) -> Result<Value, Attempt> {
4474 let executed = self.ledger.priced_document(query);
4475 let Attempted {
4476 result,
4477 limits,
4478 reported_cost,
4479 response_outcome,
4480 } = self.attempt(executed.as_ref(), variables).await;
4481 // No `otherwise` name: every document this source sends is one of its own, and the
4482 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
4483 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
4484 let outcome = match &result {
4485 Ok(_) => accounting::Outcome::Answered,
4486 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
4487 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
4488 };
4489 self.ledger
4490 .record(sending.finished(outcome, limits).with_execution(
4491 Some(executed.into_owned()),
4492 response_outcome,
4493 number,
4494 ));
4495 result
4496 }
4497
4498 /// The attempt itself, with what its response said about the rate limit alongside.
4499 ///
4500 /// The two are returned together rather than recorded here because every one of the
4501 /// early exits below is a different outcome, and a record written at each of them is a
4502 /// record one of them can be added without.
4503 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
4504 let mut limits = accounting::RateLimit::default();
4505 let mut reported_cost = None;
4506 let mut response_outcome = accounting::ResponseOutcome::TransportError;
4507 let result = self
4508 .attempted(
4509 query,
4510 variables,
4511 &mut limits,
4512 &mut reported_cost,
4513 &mut response_outcome,
4514 )
4515 .await;
4516 Attempted {
4517 result,
4518 limits,
4519 reported_cost,
4520 response_outcome,
4521 }
4522 }
4523
4524 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
4525 async fn attempted(
4526 &self,
4527 query: &str,
4528 variables: &Value,
4529 limits: &mut accounting::RateLimit,
4530 reported_cost: &mut Option<u64>,
4531 response_outcome: &mut accounting::ResponseOutcome,
4532 ) -> Result<Value, Attempt> {
4533 let response = self
4534 .client
4535 .post(self.endpoint.clone())
4536 .bearer_auth(self.token.expose_secret())
4537 .json(&json!({"query": query, "variables": variables}))
4538 .send()
4539 .await
4540 .map_err(|e| {
4541 Attempt::Failed(SourceError::Unavailable {
4542 message: format!("GitHub GraphQL request failed: {e}"),
4543 })
4544 })?;
4545 let status = response.status();
4546 if !status.is_success() {
4547 *response_outcome = accounting::ResponseOutcome::HttpError;
4548 }
4549 let header = |name: &str| whole_seconds(response.headers().get(name));
4550 let served = response
4551 .headers()
4552 .get(reqwest::header::DATE)
4553 .and_then(|value| value.to_str().ok())
4554 .and_then(|value| DateTime::parse_from_rfc2822(value).ok())
4555 .map(|at| at.with_timezone(&Utc));
4556 *limits = accounting::RateLimit::read(|name| {
4557 response
4558 .headers()
4559 .get(name)
4560 .and_then(|value| value.to_str().ok())
4561 .map(str::to_owned)
4562 });
4563 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
4564 // that are not text at all — is "not known to be exhausted". This never makes a
4565 // response a refusal on its own: it says which limiter a refusal is attributed to
4566 // and where its hint comes from, so a value this cannot read costs a hint rather
4567 // than an answer.
4568 let exhausted = response
4569 .headers()
4570 .get("x-ratelimit-remaining")
4571 .and_then(|value| value.to_str().ok())
4572 == Some("0");
4573 // `retry-after` is what GitHub asks for when it asks; when it does not and the
4574 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
4575 // which is the same question answered as an absolute time. Nothing else here is a
4576 // hint, and a schedule is what answers a refusal that carries none.
4577 let hint = header("retry-after").or_else(|| {
4578 exhausted
4579 .then(|| header("x-ratelimit-reset"))
4580 .flatten()
4581 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
4582 });
4583 // Read before it is parsed, because the evidence which tells a secondary rate
4584 // limit from a rejected credential is in the body of a response whose status says
4585 // only "forbidden" — and a non-success response was never parsed at all.
4586 let body = response.text().await.map_err(|e| {
4587 Attempt::Failed(SourceError::Unavailable {
4588 message: format!("GitHub GraphQL response could not be read: {e}"),
4589 })
4590 })?;
4591 *reported_cost = serde_json::from_str::<Value>(&body)
4592 .ok()
4593 .as_ref()
4594 .and_then(|body| body.pointer("/data/rateLimit/cost"))
4595 .and_then(Value::as_u64);
4596 let parsed = serde_json::from_str::<Value>(&body).ok();
4597 *response_outcome = accounting::ResponseOutcome::of_graphql_reply(status, parsed.as_ref());
4598 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
4599 return Err(Attempt::Limited(Limited { limiter, hint }));
4600 }
4601 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
4602 return Err(Attempt::Failed(SourceError::Auth {
4603 message: format!(
4604 "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"
4605 ),
4606 }));
4607 }
4608 if !status.is_success() {
4609 return Err(Attempt::Failed(SourceError::Unavailable {
4610 message: format!("GitHub GraphQL returned HTTP {status}"),
4611 }));
4612 }
4613 let mut data = self.answer(&body).map_err(Attempt::Failed)?;
4614 if let Some(served) = served {
4615 stamp_served(&mut data, &served.to_rfc3339());
4616 }
4617 Ok(data)
4618 }
4619
4620 /// What one successful HTTP response says, once its GraphQL errors are read.
4621 fn answer(&self, body: &str) -> Result<Value, SourceError> {
4622 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
4623 message: format!("GitHub returned invalid JSON: {e}"),
4624 })?;
4625 let errors = body
4626 .get("errors")
4627 .map(|value| {
4628 value.as_array().ok_or_else(|| SourceError::Malformed {
4629 message: "GitHub response errors is not an array".into(),
4630 })
4631 })
4632 .transpose()?;
4633 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
4634 let messages = errors
4635 .iter()
4636 .filter_map(|e| e.get("message").and_then(Value::as_str))
4637 .collect::<Vec<_>>()
4638 .join("; ");
4639 let message = if messages.is_empty() {
4640 "GitHub returned GraphQL errors".into()
4641 } else {
4642 messages
4643 };
4644 let normalized = message.to_ascii_lowercase();
4645 if normalized.contains("resource not accessible") || normalized.contains("scope") {
4646 return Err(SourceError::Auth {
4647 message: format!(
4648 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
4649 self.credential_name
4650 ),
4651 });
4652 }
4653 return Err(SourceError::Refused { message });
4654 }
4655 body.get("data")
4656 .filter(|data| data.is_object())
4657 .cloned()
4658 .ok_or_else(|| SourceError::Malformed {
4659 message: "GitHub response has no data object".into(),
4660 })
4661 }
4662
4663 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
4664 // GraphQL cannot independently page them inside the outer item page. This source page is
4665 // deliberately bounded at that published maximum; the live drift journey exercises it.
4666 async fn board_page(
4667 &self,
4668 items_after: Option<&str>,
4669 items_first: u32,
4670 ) -> Result<Value, SourceError> {
4671 let data = self
4672 .graphql(
4673 graphql::BOARD,
4674 json!({"owner":self.owner,"number":self.project_number,
4675 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
4676 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
4677 )
4678 .await?;
4679 data.pointer("/owner/projectV2")
4680 .filter(|v| !v.is_null())
4681 .cloned()
4682 .ok_or_else(|| SourceError::Refused {
4683 message: format!(
4684 "GitHub project {}/{} was not found or is not visible to the token",
4685 self.owner, self.project_number
4686 ),
4687 })
4688 }
4689
4690 /// The search that finds the issues of this board, narrowed by `also` when it is
4691 /// given.
4692 ///
4693 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
4694 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
4695 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
4696 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
4697 /// from a task by the `parent` field each issue carries rather than by the search.
4698 fn board_search(&self, also: Option<&str>) -> String {
4699 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
4700 match also {
4701 Some(also) => format!("{scope} {also}"),
4702 None => scope,
4703 }
4704 }
4705
4706 /// One issue this source reached directly, as the board item a read of the board would
4707 /// have produced — or `None` when this board does not hold it.
4708 ///
4709 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
4710 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
4711 /// item's own id, that item's field values, and the issue as its content. One resolver
4712 /// for both routes is what makes an issue read through a search, through its own node
4713 /// id, or through its project's sub-issues report the same title, the same status, the
4714 /// same labels and the same qualified id.
4715 ///
4716 /// An issue with no entry for *this* board is not this source's to report, which is
4717 /// what keeps an id naming some other repository's issue from being answered as an item
4718 /// of this board. That answer is given about an **exhausted** connection and never
4719 /// about an unread page: the entry is looked for on the page in hand, and only if that
4720 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
4721 /// rest of it.
4722 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
4723 if optional_str(issue, "__typename")? != Some("Issue") {
4724 return Ok(None);
4725 }
4726 let memberships = issue
4727 .get("projectItems")
4728 .ok_or_else(|| SourceError::Malformed {
4729 message: "GitHub issue is missing projectItems".into(),
4730 })?;
4731 let nodes = memberships
4732 .get("nodes")
4733 .and_then(Value::as_array)
4734 .ok_or_else(|| SourceError::Malformed {
4735 message: "GitHub issue projectItems.nodes is not an array".into(),
4736 })?;
4737 let held = match self.board_entry(nodes) {
4738 Some(held) => held.clone(),
4739 None => {
4740 let info = memberships
4741 .get("pageInfo")
4742 .ok_or_else(|| SourceError::Malformed {
4743 message: "GitHub issue projectItems has no pageInfo".into(),
4744 })?;
4745 // The page held no entry for this board. Whether that means the issue is
4746 // not on it is a question about the rest of the connection, and only a
4747 // connection with no rest answers it here.
4748 if !required_bool(info, "hasNextPage")? {
4749 return Ok(None);
4750 }
4751 let cursor = required_str(info, "endCursor")?;
4752 validate_cursor_progress(None, cursor)?;
4753 let issue_id = required_str(issue, "id")?;
4754 match self.board_membership(issue_id, cursor).await? {
4755 Some(held) => held,
4756 None => return Ok(None),
4757 }
4758 }
4759 };
4760 // The membership's own `updatedAt` rides along: it is the board half of the item's change
4761 // token, which a field change moves where the issue's own time does not.
4762 let item = json!({
4763 "id": required_str(&held, "id")?,
4764 "updatedAt": held.get("updatedAt"),
4765 "project": held.get("project"),
4766 "fieldValues": held.get("fieldValues"),
4767 "content": issue,
4768 });
4769 self.resolve(&item)
4770 }
4771
4772 /// This board's own entry among one page of an issue's `Issue.projectItems`.
4773 ///
4774 /// One spelling of *which membership is this board's*, so the page a read carries and
4775 /// the pages [`Self::board_membership`] walks are searched by the same rule.
4776 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
4777 nodes.iter().find(|node| {
4778 node.pointer("/project/number").and_then(Value::as_u64)
4779 == Some(u64::from(self.project_number))
4780 })
4781 }
4782
4783 /// The rest of one issue's board memberships, from `after`, for this board's entry.
4784 ///
4785 /// The recovery read: a page of memberships that holds no entry for this board says
4786 /// nothing about the memberships past it, so the connection is walked to exhaustion
4787 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
4788 /// positive answer — the whole connection was read and no entry named this board —
4789 /// rather than a failure, and the walk is held to
4790 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
4791 /// with a cursor that does not advance is refused instead of spun on.
4792 async fn board_membership(
4793 &self,
4794 issue: &str,
4795 after: &str,
4796 ) -> Result<Option<Value>, SourceError> {
4797 let mut after = after.to_owned();
4798 loop {
4799 let data = self
4800 .graphql(
4801 graphql::ISSUE_BOARD_ITEMS,
4802 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
4803 "nestedFirst":NESTED_PAGE_SIZE}),
4804 )
4805 .await?;
4806 let Some(connection) = data
4807 .pointer("/node/projectItems")
4808 .filter(|value| !value.is_null())
4809 else {
4810 // The id resolved to nothing, or to something with no memberships to walk —
4811 // which is the same answer as a connection holding no entry for this board.
4812 return Ok(None);
4813 };
4814 let nodes = connection
4815 .get("nodes")
4816 .and_then(Value::as_array)
4817 .ok_or_else(|| SourceError::Malformed {
4818 message: "GitHub issue projectItems.nodes is not an array".into(),
4819 })?;
4820 if let Some(held) = self.board_entry(nodes) {
4821 return Ok(Some(held.clone()));
4822 }
4823 let info = connection
4824 .get("pageInfo")
4825 .ok_or_else(|| SourceError::Malformed {
4826 message: "GitHub issue projectItems has no pageInfo".into(),
4827 })?;
4828 let next = required_bool(info, "hasNextPage")?
4829 .then(|| required_str(info, "endCursor"))
4830 .transpose()?;
4831 match next {
4832 Some(next) => {
4833 validate_cursor_progress(Some(&after), next)?;
4834 after = next.to_owned();
4835 }
4836 None => return Ok(None),
4837 }
4838 }
4839 }
4840
4841 /// One page of a board-scoped issue search, and where the next page resumes.
4842 async fn search_page(
4843 &self,
4844 search: &str,
4845 first: u32,
4846 after: Option<&str>,
4847 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
4848 let data = self
4849 .graphql(
4850 graphql::SEARCH_ISSUES,
4851 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
4852 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
4853 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4854 )
4855 .await?;
4856 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4857 message: "GitHub search response has no search connection".into(),
4858 })?;
4859 let mut found = Vec::new();
4860 for node in connection
4861 .get("nodes")
4862 .and_then(Value::as_array)
4863 .ok_or_else(|| SourceError::Malformed {
4864 message: "GitHub search nodes is not an array".into(),
4865 })?
4866 {
4867 if let Some(resolved) = self.resolve_issue(node).await? {
4868 found.push(resolved);
4869 }
4870 }
4871 let info = connection
4872 .get("pageInfo")
4873 .ok_or_else(|| SourceError::Malformed {
4874 message: "GitHub search connection has no pageInfo".into(),
4875 })?;
4876 let next = required_bool(info, "hasNextPage")?
4877 .then(|| required_str(info, "endCursor"))
4878 .transpose()?
4879 .map(str::to_owned);
4880 if let Some(next) = &next {
4881 validate_cursor_progress(after, next)?;
4882 }
4883 Ok((found, next))
4884 }
4885
4886 /// Every issue this board holds, completed with what this run wrote.
4887 ///
4888 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4889 /// is an index and is eventually consistent, so an issue this run created seconds ago
4890 /// can be absent from it, and a project listed straight after being written would
4891 /// otherwise be missing from its own board. What is added back is only what this
4892 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4893 /// process.
4894 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4895 let found = self.searched_issues().await?;
4896 self.completed_with_written(found, |_| true)
4897 }
4898
4899 /// Every issue this board's own search reports, walked to exhaustion, read once per
4900 /// source.
4901 ///
4902 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4903 /// needs it too and the two would otherwise walk the same search twice in one command.
4904 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4905 /// is.
4906 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4907 let cached = self.search_cache()?.clone();
4908 if let Some(held) = cached {
4909 return Ok(held);
4910 }
4911 let mut after: Option<String> = None;
4912 let mut found = Vec::new();
4913 let search = self.board_search(None);
4914 loop {
4915 let (page, next) = self
4916 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4917 .await?;
4918 found.extend(page);
4919 match next {
4920 Some(next) => after = Some(next),
4921 None => break,
4922 }
4923 }
4924 *self.search_cache()? = Some(found.clone());
4925 Ok(found)
4926 }
4927
4928 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4929 fn search_cache(
4930 &self,
4931 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4932 self.search_cache
4933 .lock()
4934 .map_err(|_| SourceError::Unavailable {
4935 message: "this source's view of the board's issues was left inconsistent by an \
4936 earlier failure; next: run the command again"
4937 .into(),
4938 })
4939 }
4940
4941 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4942 /// report.
4943 ///
4944 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4945 /// at all: the search index is behind, and a node read of an item filed moments ago can
4946 /// be too.
4947 fn completed_with_written(
4948 &self,
4949 mut found: Vec<Resolved>,
4950 keep: impl Fn(&Resolved) -> bool,
4951 ) -> Result<Vec<Resolved>, SourceError> {
4952 for own in self.created()?.iter().filter(|own| keep(own)) {
4953 if !found.iter().any(|item| item.id == own.id) {
4954 found.push(own.clone());
4955 }
4956 }
4957 Ok(found)
4958 }
4959
4960 /// What resolving one node id reached.
4961 ///
4962 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4963 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4964 /// is completed by a read of the draft itself rather than reported as nothing.
4965 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4966 let asked = self
4967 .graphql(
4968 graphql::ISSUE,
4969 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4970 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4971 )
4972 .await;
4973 let data = match asked {
4974 Ok(data) => data,
4975 // A string that is not a node id at all is not a failure to report: it is an id
4976 // this board does not hold, which is what every read of one already answers.
4977 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4978 Err(error) => return Err(error),
4979 };
4980 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4981 return Ok(Reached::Nothing);
4982 };
4983 if optional_str(node, "__typename")? == Some("DraftIssue") {
4984 return Ok(Reached::Draft);
4985 }
4986 Ok(match self.resolve_issue(node).await? {
4987 Some(item) => Reached::Held(Box::new(item)),
4988 None => Reached::Nothing,
4989 })
4990 }
4991
4992 /// One item of this board by its own id, whatever kind it is.
4993 ///
4994 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4995 /// run wrote is read first, because a node read of an item created moments ago can
4996 /// still be behind the board field values written onto it — see [`Self::created`].
4997 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4998 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4999 return Ok(Some(own.clone()));
5000 }
5001 match self.reach(id).await? {
5002 Reached::Held(item) => Ok(Some(*item)),
5003 Reached::Nothing => Ok(None),
5004 Reached::Draft => self.draft_by_id(id).await,
5005 }
5006 }
5007
5008 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
5009 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
5010 /// than one request per id.
5011 ///
5012 /// What this run wrote answers first, as it does there, and only the rest is read. One id
5013 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
5014 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
5015 /// id at a time, so that id is answered as not held and the others as themselves; a draft
5016 /// is completed by a read of the draft, exactly as there.
5017 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
5018 let mut found: Vec<Option<Option<Resolved>>> = {
5019 let created = self.created()?;
5020 ids.iter()
5021 .map(|id| {
5022 created
5023 .iter()
5024 .find(|own| own.id == *id)
5025 .map(|own| Some(own.clone()))
5026 })
5027 .collect()
5028 };
5029 let unread: Vec<NativeId> = ids
5030 .iter()
5031 .zip(&found)
5032 .filter(|(_, found)| found.is_none())
5033 .map(|(id, _)| id.clone())
5034 .collect();
5035 let mut read = Vec::with_capacity(unread.len());
5036 if let [one] = unread.as_slice() {
5037 read.push(self.item_by_id(one).await?);
5038 } else {
5039 for batch in unread.chunks(DETAIL_BATCH) {
5040 let data = match self
5041 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
5042 .await
5043 {
5044 Ok(data) => data,
5045 Err(error) if unresolvable_node(&error) => {
5046 for id in batch {
5047 read.push(self.item_by_id(id).await?);
5048 }
5049 continue;
5050 }
5051 Err(error) => return Err(error),
5052 };
5053 for (slot, id) in batch.iter().enumerate() {
5054 let node =
5055 data.get(format!("i{slot}"))
5056 .ok_or_else(|| SourceError::Malformed {
5057 message: format!(
5058 "GitHub answered a batch read with no item for {}",
5059 id.0
5060 ),
5061 })?;
5062 read.push(if node.is_null() {
5063 None
5064 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
5065 self.draft_by_id(id).await?
5066 } else {
5067 if optional_str(node, "__typename")? == Some("Issue")
5068 && required_str(node, "id")? != id.0
5069 {
5070 return Err(SourceError::Malformed {
5071 message: format!(
5072 "GitHub answered the read of {} with issue {}",
5073 id.0,
5074 required_str(node, "id")?
5075 ),
5076 });
5077 }
5078 self.resolve_issue(node).await?
5079 });
5080 }
5081 }
5082 }
5083 let mut read = read.into_iter();
5084 Ok(found
5085 .iter_mut()
5086 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
5087 .collect())
5088 }
5089
5090 fn resolved_cache(
5091 &self,
5092 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
5093 self.resolved_cache
5094 .lock()
5095 .map_err(|_| SourceError::Unavailable {
5096 message: "resolved item records were left inconsistent; run the command again"
5097 .into(),
5098 })
5099 }
5100
5101 /// Reuse a record this invocation already resolved. The mutation sender invalidates
5102 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
5103 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5104 let cached = self.resolved_cache()?.get(id).cloned();
5105 match cached {
5106 Some(item) => Ok(Some(item)),
5107 None => self.item_by_id(id).await,
5108 }
5109 }
5110
5111 /// One board draft by its own id, with the board item it sits in — or `None` when no
5112 /// item of this board is that draft's.
5113 ///
5114 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
5115 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
5116 /// links a draft to one board item, so the page this read carries is the whole of that
5117 /// connection, and a page that reports more than it holds is refused rather than read
5118 /// as an answer about memberships nobody read.
5119 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
5120 let data = self
5121 .graphql(
5122 graphql::DRAFT,
5123 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
5124 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
5125 )
5126 .await?;
5127 // Gone between the two reads is an answer — the draft is no longer there. Anything
5128 // else than the draft [`Self::reach`] was just told this id is, is not one.
5129 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
5130 return Ok(None);
5131 };
5132 if optional_str(draft, "__typename")? != Some("DraftIssue") {
5133 return Err(SourceError::Malformed {
5134 message: format!(
5135 "GitHub answered {} as a draft and then as something else",
5136 id.0
5137 ),
5138 });
5139 }
5140 if required_str(draft, "id")? != id.0 {
5141 return Err(SourceError::Malformed {
5142 message: format!("GitHub answered a different draft for {}", id.0),
5143 });
5144 }
5145 let memberships = draft
5146 .get("projectV2Items")
5147 .ok_or_else(|| SourceError::Malformed {
5148 message: format!("GitHub draft {} is missing projectV2Items", id.0),
5149 })?;
5150 let nodes = memberships
5151 .get("nodes")
5152 .and_then(Value::as_array)
5153 .ok_or_else(|| SourceError::Malformed {
5154 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
5155 })?;
5156 let info = memberships
5157 .get("pageInfo")
5158 .ok_or_else(|| SourceError::Malformed {
5159 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
5160 })?;
5161 // Read whether or not this board's entry is on the page: a page claiming more than
5162 // the one item GitHub links a draft to is a malformed answer either way.
5163 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
5164 return Err(SourceError::Malformed {
5165 message: format!(
5166 "GitHub draft {} reports more board items than the one GitHub links a draft \
5167 to",
5168 id.0
5169 ),
5170 });
5171 }
5172 if let Some(node) = nodes.first()
5173 && node
5174 .pointer("/project/number")
5175 .and_then(Value::as_u64)
5176 .is_none()
5177 {
5178 return Err(SourceError::Malformed {
5179 message: format!(
5180 "GitHub draft {} board item has no numeric project number",
5181 id.0
5182 ),
5183 });
5184 }
5185 let Some(held) = self.board_entry(nodes) else {
5186 return Ok(None);
5187 };
5188 if required_str(
5189 held.get("project").ok_or_else(|| SourceError::Malformed {
5190 message: format!("GitHub draft {} board item has no project", id.0),
5191 })?,
5192 "id",
5193 )? != self.board_fields().await?.id.as_str()
5194 {
5195 return Ok(None);
5196 }
5197 let item = json!({
5198 "id": required_str(held, "id")?,
5199 "project": held.get("project"),
5200 "fieldValues": held.get("fieldValues"),
5201 "content": draft,
5202 });
5203 self.resolve(&item)
5204 }
5205
5206 /// The board's own id and field definitions, for a write whose item does not carry
5207 /// them — never its items.
5208 ///
5209 /// A board this command has already listed supplies them, since it read them beside its
5210 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
5211 /// is consulted about which items the board holds: see the module documentation for
5212 /// why a question about one known item is answered by reading that item.
5213 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
5214 if let Some(board) = self.board_cache()?.as_ref() {
5215 return Ok(BoardFields {
5216 id: BoardId::parse(&board.id)?,
5217 fields: board.fields.clone(),
5218 });
5219 }
5220 if let Some(held) = self.fields_cache()?.clone() {
5221 return Ok(held);
5222 }
5223 let data = self
5224 .graphql(
5225 graphql::BOARD_FIELDS,
5226 json!({"owner":self.owner,"number":self.project_number,
5227 "nestedFirst":NESTED_PAGE_SIZE}),
5228 )
5229 .await?;
5230 self.fields_read(&data)
5231 }
5232
5233 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
5234 /// the rest of this command.
5235 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
5236 let board = data
5237 .pointer("/boardFields/projectV2")
5238 .filter(|value| !value.is_null())
5239 .ok_or_else(|| SourceError::Refused {
5240 message: format!(
5241 "GitHub project {}/{} was not found or is not visible to the token",
5242 self.owner, self.project_number
5243 ),
5244 })?;
5245 let read = BoardFields {
5246 id: BoardId::parse(required_str(board, "id")?)?,
5247 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5248 };
5249 *self.fields_cache()? = Some(read.clone());
5250 Ok(read)
5251 }
5252
5253 /// Read what creating an issue in `repository` needs and this command has not read yet —
5254 /// the board's fields and the repository's node id — in one request when it needs both.
5255 ///
5256 /// When either is already known this sends nothing, and the other is read by its own
5257 /// document where it is asked for, so no create reads anything twice.
5258 async fn creation_context(
5259 &self,
5260 repository: &RepositoryTarget,
5261 incoming: &Incoming<'_>,
5262 ) -> Result<(), SourceError> {
5263 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
5264 if fields_known || self.repository_cache()?.contains_key(repository) {
5265 return Ok(());
5266 }
5267 let data = self
5268 .graphql(
5269 graphql::CREATION_CONTEXT,
5270 json!({"owner":self.owner,"number":self.project_number,
5271 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
5272 "repositoryName":repository.name}),
5273 )
5274 .await?;
5275 self.fields_read(&data)?;
5276 self.repository_read(&data, repository, incoming)?;
5277 Ok(())
5278 }
5279
5280 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
5281 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
5282 self.fields_cache
5283 .lock()
5284 .map_err(|_| SourceError::Unavailable {
5285 message: "this source's view of the board's fields was left inconsistent by an \
5286 earlier failure; next: run the command again"
5287 .into(),
5288 })
5289 }
5290
5291 /// What a write to `item` needs of the board, read off that item when it says enough and
5292 /// off [`Self::board_fields`] when it does not.
5293 ///
5294 /// A node read of an item names its board and carries the definition of every field it
5295 /// holds a value of — so an item naming its board, holding a value of the origin field,
5296 /// and, when the write carries a status, holding a `Status` value, needs no read of the
5297 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
5298 /// of may still be on the board, and a view reading it as absent would refuse a write the
5299 /// board can take or skip a field write the board needs, so such an item — and a create,
5300 /// which has no item yet — takes the board's fields from their own read instead.
5301 async fn fields_for(
5302 &self,
5303 item: Option<&Resolved>,
5304 writes_status: bool,
5305 selects_priority: bool,
5306 ) -> Result<BoardFields, SourceError> {
5307 if let Some(board) = item.and_then(Resolved::carried_board) {
5308 return Ok(board);
5309 }
5310 if let Some(item) = item
5311 && let Some(board_id) = item.named_board()
5312 && item.defines(ORIGIN_FIELD)
5313 && (!writes_status || item.defines("Status"))
5314 && (!selects_priority || item.defines(PRIORITY_FIELD))
5315 && self
5316 .metadata_fields
5317 .iter()
5318 .all(|projection| item.defines(&projection.field))
5319 {
5320 return Ok(BoardFields {
5321 id: board_id,
5322 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5323 });
5324 }
5325 self.board_fields().await
5326 }
5327
5328 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
5329 /// when that id names nothing here with a sub-issue relationship to walk.
5330 ///
5331 /// `None` and an empty answer are different: `None` is *this is not an issue of this
5332 /// GitHub*, which is what sends a project selector on to be read as a name, and an
5333 /// empty vector is a project that holds nothing.
5334 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
5335 let mut after: Option<String> = None;
5336 let mut children = Vec::new();
5337 loop {
5338 let asked = self
5339 .graphql(
5340 graphql::SUB_ISSUES,
5341 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
5342 "nestedFirst":NESTED_PAGE_SIZE,
5343 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
5344 )
5345 .await;
5346 let data = match asked {
5347 Ok(data) => data,
5348 // A string that is not a node id at all is not a failure to report: it is
5349 // the ordinary answer to a selector naming a project by its name.
5350 Err(error) if unresolvable_node(&error) => return Ok(None),
5351 Err(error) => return Err(error),
5352 };
5353 let Some(connection) = data
5354 .pointer("/node/subIssues")
5355 .filter(|value| !value.is_null())
5356 else {
5357 // No such node, or one with no sub-issue relationship — a board draft is
5358 // the one this board can really hold.
5359 return Ok(None);
5360 };
5361 for node in connection
5362 .get("nodes")
5363 .and_then(Value::as_array)
5364 .ok_or_else(|| SourceError::Malformed {
5365 message: "GitHub subIssues.nodes is not an array".into(),
5366 })?
5367 {
5368 if let Some(resolved) = self.resolve_issue(node).await? {
5369 children.push(resolved);
5370 }
5371 }
5372 let info = connection
5373 .get("pageInfo")
5374 .ok_or_else(|| SourceError::Malformed {
5375 message: "GitHub subIssues connection has no pageInfo".into(),
5376 })?;
5377 let next = required_bool(info, "hasNextPage")?
5378 .then(|| required_str(info, "endCursor"))
5379 .transpose()?;
5380 match next {
5381 Some(next) => {
5382 validate_cursor_progress(after.as_deref(), next)?;
5383 after = Some(next.to_owned());
5384 }
5385 None => return Ok(Some(children)),
5386 }
5387 }
5388 }
5389
5390 /// Which issue of this board a project *name* is, or `None` when none is.
5391 ///
5392 /// One bounded query which filters on that name at the server, rather than a walk of
5393 /// every issue the board holds. The name is compared again here: the qualifier narrows
5394 /// what GitHub sends, and this source decides what it names.
5395 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
5396 let search = self.board_search(Some(&title_qualifier(name)));
5397 let mut after = None;
5398 loop {
5399 let (candidates, next) = self
5400 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5401 .await?;
5402 if let Some(item) = candidates.into_iter().find(|item| {
5403 item.kind == BoardKind::Work(ItemKind::Project)
5404 && item.title.eq_ignore_ascii_case(name)
5405 }) {
5406 return Ok(Some(item.id));
5407 }
5408 match next {
5409 Some(next) => after = Some(next),
5410 None => return Ok(None),
5411 }
5412 }
5413 }
5414
5415 /// Everything filed under one project of this board: the sub-issues of the issue that
5416 /// project is.
5417 ///
5418 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
5419 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
5420 /// gains projects, or as another project gains tasks.
5421 ///
5422 /// A qualified id names the issue and is asked for its sub-issues directly: one
5423 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
5424 /// read as a project *name*, which costs the one bounded search
5425 /// [`Self::project_by_name`] makes.
5426 ///
5427 /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
5428 /// — and each answer is still cut to the items whose parent is this project, so one this
5429 /// process has since filed elsewhere is not reported here, and completed with what this
5430 /// process filed under it.
5431 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
5432 let held = self.children_cache()?.get(selector).cloned();
5433 let (project, children) = match held {
5434 Some(held) => held,
5435 None => {
5436 let answered = match self.sub_issues(selector).await? {
5437 Some(children) => (selector.clone(), children),
5438 None => match self.project_by_name(&selector.0).await? {
5439 Some(project) => {
5440 let children = self.sub_issues(&project).await?.unwrap_or_default();
5441 (project, children)
5442 }
5443 None => return Ok(Vec::new()),
5444 },
5445 };
5446 self.children_cache()?
5447 .insert(selector.clone(), answered.clone());
5448 answered
5449 }
5450 };
5451 let mut children: Vec<Resolved> = children
5452 .into_iter()
5453 .filter(|child| child.parent.as_ref() == Some(&project))
5454 .collect();
5455 for own in self.updated()?.iter() {
5456 if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
5457 children.push(own.clone());
5458 }
5459 }
5460 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
5461 }
5462
5463 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
5464 /// completed with what this run wrote — the candidates a comment-activity read confirms.
5465 ///
5466 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
5467 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
5468 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
5469 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
5470 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
5471 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
5472 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
5473 /// rather than silently narrowing a caller's answer.
5474 ///
5475 /// The instant is written to the second, rounded down, which can only widen what the
5476 /// search returns; confirmation against each candidate's own comments is what makes the
5477 /// answer exact. The search is an index that lags a write by a second or two — the module
5478 /// documentation records it — so a caller that asks again from its last instant should
5479 /// overlap the two by more than that.
5480 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
5481 let found = self.searched(&updated_qualifier(since)).await?;
5482 self.completed_with_written(found, |_| true)
5483 }
5484
5485 /// Every issue of this board GitHub's issue search reports for the board-scoped search
5486 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
5487 ///
5488 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
5489 /// own record is the fresher of the two.
5490 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
5491 let search = self.board_search(Some(also));
5492 let mut after: Option<String> = None;
5493 let mut found = Vec::new();
5494 loop {
5495 let (page, next) = self
5496 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
5497 .await?;
5498 found.extend(page);
5499 match next {
5500 Some(next) => after = Some(next),
5501 None => return Ok(found),
5502 }
5503 }
5504 }
5505
5506 /// A bounded task answer; the versioned cursor carries the connection position, how
5507 /// many rows of the page starting there were already handed out, and the own-write ids
5508 /// already observed, including across a new source instance.
5509 ///
5510 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
5511 /// sliced from the pages it needs; why is the module documentation's paging contract.
5512 async fn search_tasks(
5513 &self,
5514 query: &TaskQuery,
5515 page: &PageRequest,
5516 also: &str,
5517 ) -> Result<Page<Task>, SourceError> {
5518 let mut position = match &page.cursor {
5519 None => SearchPosition::default(),
5520 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
5521 .ok()
5522 .filter(|position| {
5523 position.version == SEARCH_CURSOR_VERSION
5524 && position.connection.valid_resume(position.offset)
5525 })
5526 .ok_or_else(|| SourceError::Config {
5527 message: "page cursor is invalid".into(),
5528 })?,
5529 };
5530 let search = self.board_search(Some(also));
5531 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5532 let own = self.with_own_writes(Vec::new())?;
5533 // An issue this process commented on is a candidate of a comment-activity read
5534 // whether or not the search has caught up with the comment; see `Self::commented`.
5535 let commented = match query.commented_since {
5536 Some(_) => self.commented()?.clone(),
5537 None => Vec::new(),
5538 };
5539 for id in own.iter().map(|item| &item.id).chain(&commented) {
5540 if !position.own.contains(id) {
5541 position.own.push(id.clone());
5542 }
5543 }
5544 let mut tasks = Vec::new();
5545 while !position.connection.exhausted() && tasks.len() < limit {
5546 let first = SEARCH_PAGE_SIZE;
5547 // Page size is part of the key: a short cached answer cannot answer a wider ask.
5548 let key =
5549 serde_json::to_string(&("page", &search, &position.connection.after(), first))
5550 .expect("search page key is serializable");
5551 let cached = if query.commented_since.is_none() {
5552 self.narrowed_cache()?.get(&key).cloned()
5553 } else {
5554 None
5555 };
5556 let (found, next) = match cached {
5557 Some(found) => {
5558 let next = self
5559 .search_next
5560 .lock()
5561 .map_err(|_| SourceError::Unavailable {
5562 message:
5563 "search pagination was left inconsistent; run the command again"
5564 .into(),
5565 })?
5566 .get(&key)
5567 .cloned()
5568 .flatten();
5569 (found, next)
5570 }
5571 None => {
5572 let (found, next) = self
5573 .search_page(&search, first, position.connection.after())
5574 .await?;
5575 if query.commented_since.is_none() {
5576 self.search_next
5577 .lock()
5578 .map_err(|_| SourceError::Unavailable {
5579 message:
5580 "search pagination was left inconsistent; run the command again"
5581 .into(),
5582 })?
5583 .insert(key.clone(), next.clone());
5584 self.narrowed_cache()?.insert(key, found.clone());
5585 }
5586 (found, next)
5587 }
5588 };
5589 let rows = found.len();
5590 for mut item in found.into_iter().skip(position.offset) {
5591 if tasks.len() == limit {
5592 break;
5593 }
5594 position.offset += 1;
5595 if position.own.contains(&item.id) {
5596 if position.seen.contains(&item.id) {
5597 continue;
5598 }
5599 position.seen.push(item.id.clone());
5600 // The search's own copy of an issue this process only commented on is as
5601 // good as a node read of it, since its comments are read either way.
5602 let only_commented = commented.contains(&item.id)
5603 && !own.iter().any(|written| written.id == item.id);
5604 if !only_commented {
5605 let updated_at = item.updated_at;
5606 let Some(written) = self.search_written(&own, &item.id).await? else {
5607 continue;
5608 };
5609 item = written;
5610 item.updated_at = item.updated_at.max(updated_at);
5611 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5612 }
5613 }
5614 if item.kind == BoardKind::Work(ItemKind::Task) {
5615 let task = item.task()?;
5616 if task_matches(&task, query, &query.project)
5617 && self.commented_since(&item, query.commented_since).await?
5618 {
5619 tasks.push(task);
5620 }
5621 }
5622 }
5623 if position.offset < rows {
5624 continue;
5625 }
5626 position.offset = 0;
5627 position.connection = match next {
5628 Some(after) => SearchConnection::Continuing {
5629 after: Cursor(after),
5630 },
5631 None => SearchConnection::Exhausted {},
5632 };
5633 }
5634 if position.connection.exhausted() {
5635 for id in position.own.clone() {
5636 if position.seen.contains(&id) {
5637 continue;
5638 }
5639 if tasks.len() == limit {
5640 break;
5641 }
5642 position.seen.push(id.clone());
5643 let Some(item) = self.search_written(&own, &id).await? else {
5644 continue;
5645 };
5646 if item.kind == BoardKind::Work(ItemKind::Task) {
5647 let task = item.task()?;
5648 if task_matches(&task, query, &query.project)
5649 && self.commented_since(&item, query.commented_since).await?
5650 {
5651 tasks.push(task);
5652 }
5653 }
5654 }
5655 }
5656 let more = !position.connection.exhausted()
5657 || position.own.iter().any(|id| !position.seen.contains(id));
5658 Ok(Page {
5659 items: tasks,
5660 next: more.then(|| {
5661 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
5662 }),
5663 })
5664 }
5665
5666 /// A resumed process has the ids but no write records; resolve only a record the
5667 /// current page needs, by its uncached node read rather than the lagging search index.
5668 async fn search_written(
5669 &self,
5670 own: &[Resolved],
5671 id: &NativeId,
5672 ) -> Result<Option<Resolved>, SourceError> {
5673 match own.iter().find(|item| item.id == *id) {
5674 Some(item) => Ok(Some(item.clone())),
5675 None => self.item_by_id(id).await,
5676 }
5677 }
5678
5679 /// The candidates for a task query carrying a text, metadata or origin predicate, read
5680 /// without enumerating the board — or `None` for a query carrying none of the three, which
5681 /// keeps the reads it always had.
5682 ///
5683 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
5684 /// because it names at most a handful of items. Text and metadata are answered by one
5685 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
5686 /// further by `updated:>=` when the query also asks for comment activity, since both
5687 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
5688 /// process afterwards by the same predicates [`task_matches`] applies to every read.
5689 ///
5690 /// Completed with what this process wrote, its own record winning over the index's copy
5691 /// of the same item: see [`Self::with_own_writes`].
5692 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
5693 let asked = match (&query.origin, narrowing_qualifiers(query)) {
5694 (Some(origin), _) => Narrowing::Origin(origin.clone()),
5695 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
5696 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
5697 None => qualifiers,
5698 }),
5699 (None, None) => return Ok(None),
5700 };
5701 // A question about comment activity is asked afresh every time, as it always was: it
5702 // is the one a caller polls from one source while waiting for the index, and an
5703 // answer held from the first poll would be the answer to every later one.
5704 let key = query.commented_since.is_none().then(|| asked.key());
5705 let cached = match &key {
5706 Some(key) => self.narrowed_cache()?.get(key).cloned(),
5707 None => None,
5708 };
5709 let found = match cached {
5710 Some(found) => found,
5711 None => {
5712 let found = match &asked {
5713 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
5714 Narrowing::Search(also) => self.searched(also).await?,
5715 };
5716 if let Some(key) = key {
5717 self.narrowed_cache()?.insert(key, found.clone());
5718 }
5719 found
5720 }
5721 };
5722 self.with_own_writes(found).map(Some)
5723 }
5724
5725 /// The candidates for a project or unscoped document query carrying a searchable text,
5726 /// read without enumerating the board — or `None` for a query with no text or a blank one,
5727 /// which keeps the read it always had.
5728 ///
5729 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
5730 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
5731 /// costs is the issues that match and never the board. Its answer is held for the command
5732 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
5733 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
5734 /// substring rule, exactly as an item of the wider read was, and is completed with what this
5735 /// process wrote: see [`Self::with_own_writes`].
5736 async fn text_searched(
5737 &self,
5738 text: Option<&TextQuery>,
5739 ) -> Result<Option<Vec<Resolved>>, SourceError> {
5740 let Some(also) = text_qualifiers(text) else {
5741 return Ok(None);
5742 };
5743 let key = Narrowing::Search(also.clone()).key();
5744 let cached = self.narrowed_cache()?.get(&key).cloned();
5745 let found = match cached {
5746 Some(found) => found,
5747 None => {
5748 let found = self.searched(&also).await?;
5749 self.narrowed_cache()?.insert(key, found.clone());
5750 found
5751 }
5752 };
5753 self.with_own_writes(found).map(Some)
5754 }
5755
5756 /// Every item of this board that may carry `origin` — a superset of those that do — found
5757 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
5758 ///
5759 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
5760 /// which reads the field every carrier holds, whichever release wrote it — and the
5761 /// board-scoped issue search for the same id as a phrase in the body, where this source
5762 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
5763 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
5764 /// query's, exactly.
5765 ///
5766 /// Both connections are walked to exhaustion, each from its own cursor. One that has
5767 /// already ended is sent its last cursor again, which answers an empty page, so the one
5768 /// document serves every page of either. What the two leave is stated in the module
5769 /// documentation: a carrier another process added within the last second or two, before
5770 /// either index has it.
5771 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
5772 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
5773 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
5774 let mut items_after: Option<String> = None;
5775 let mut search_after: Option<String> = None;
5776 let mut found: Vec<Resolved> = Vec::new();
5777 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
5778 if !found.iter().any(|held| held.id == resolved.id) {
5779 found.push(resolved);
5780 }
5781 };
5782 loop {
5783 let data = self
5784 .graphql(
5785 graphql::ORIGIN_LOOKUP,
5786 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
5787 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
5788 "itemsAfter":items_after,"searchAfter":search_after,
5789 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
5790 "duplicates":true}),
5791 )
5792 .await?;
5793 let items = data
5794 .pointer("/originItems/projectV2/items")
5795 .filter(|value| !value.is_null())
5796 .ok_or_else(|| SourceError::Refused {
5797 message: format!(
5798 "GitHub project {}/{} was not found or is not visible to the token",
5799 self.owner, self.project_number
5800 ),
5801 })?;
5802 for item in optional_nodes(Some(items), "project items")?
5803 .into_iter()
5804 .flatten()
5805 {
5806 // The board's own items list its drafts too, and a draft is not an issue: no
5807 // narrowed read answers with one, whatever its origin field holds.
5808 if let Some(resolved) = self.resolve(item)?
5809 && resolved.content_kind == ContentKind::Issue
5810 {
5811 keep(resolved, &mut found);
5812 }
5813 }
5814 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
5815 message: "GitHub search response has no search connection".into(),
5816 })?;
5817 for node in optional_nodes(Some(searched), "search")?
5818 .into_iter()
5819 .flatten()
5820 {
5821 if let Some(resolved) = self.resolve_issue(node).await? {
5822 keep(resolved, &mut found);
5823 }
5824 }
5825 let items_next = resumed(items, items_after.as_deref())?;
5826 let search_next = resumed(searched, search_after.as_deref())?;
5827 if !items_next.has_more() && !search_next.has_more() {
5828 return Ok(found);
5829 }
5830 items_after = items_next.cursor();
5831 search_after = search_next.cursor();
5832 }
5833 }
5834
5835 /// `found`, with every item this process created or wrote in its place, and every one of
5836 /// them the read did not report added.
5837 ///
5838 /// This process's own record wins over the read's copy of the same item, because a read
5839 /// of an item written moments ago can still be behind what was written onto it — the
5840 /// origin field included, which is the one a narrowed read is confirmed against — and a
5841 /// read that still names an item under a predicate this process's write moved it out of
5842 /// must not return it. The one thing the read knows that the record cannot is when GitHub
5843 /// last saw the item change, which is what a comment-activity read rules a candidate out
5844 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
5845 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
5846 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
5847 // A board draft is not an issue, so no narrowed read returns one, and this process
5848 // having written one does not make it an answer either.
5849 let own: Vec<Resolved> = self
5850 .created()?
5851 .iter()
5852 .chain(self.updated()?.iter())
5853 .filter(|own| own.content_kind == ContentKind::Issue)
5854 .cloned()
5855 .collect();
5856 for mut own in own {
5857 self.resolved_cache()?.insert(own.id.clone(), own.clone());
5858 match found.iter_mut().find(|read| read.id == own.id) {
5859 Some(read) => {
5860 own.updated_at = own.updated_at.max(read.updated_at);
5861 *read = own;
5862 }
5863 None => found.push(own),
5864 }
5865 }
5866 Ok(found)
5867 }
5868
5869 /// Whether `item` has a comment created or last edited at or after `since` — always, when
5870 /// there is no instant to hold it to.
5871 ///
5872 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
5873 /// or after the instant moved it there: an issue not updated since holds no such comment,
5874 /// and its comments are never asked for — unless this process commented on it in this
5875 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
5876 /// only as far as the first that matches. A board draft is not an issue and has no
5877 /// comments, so it never matches.
5878 async fn commented_since(
5879 &self,
5880 item: &Resolved,
5881 since: Option<DateTime<Utc>>,
5882 ) -> Result<bool, SourceError> {
5883 let Some(since) = since else {
5884 return Ok(true);
5885 };
5886 if item.content_kind == ContentKind::DraftIssue {
5887 return Ok(false);
5888 }
5889 // An `updatedAt` this process's own record or a lagging index holds can predate a
5890 // comment this process wrote since, so only an issue it did not comment on is ruled
5891 // out by one.
5892 if item.updated_at.is_some_and(|updated| updated < since)
5893 && !self.commented()?.contains(&item.id)
5894 {
5895 return Ok(false);
5896 }
5897 let query = TaskQuery {
5898 commented_since: Some(since),
5899 ..TaskQuery::default()
5900 };
5901 let mut after: Option<String> = None;
5902 loop {
5903 let data = self
5904 .graphql(
5905 graphql::ISSUE_COMMENTS,
5906 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5907 )
5908 .await?;
5909 let Some(connection) = data
5910 .get("node")
5911 .filter(|value| !value.is_null())
5912 .and_then(|node| node.get("comments"))
5913 .filter(|value| !value.is_null())
5914 else {
5915 // Removed since the search reported it: no longer an issue with comments.
5916 return Ok(false);
5917 };
5918 let comments = optional_nodes(Some(connection), "issue comments")?
5919 .into_iter()
5920 .flatten()
5921 .map(comment_from)
5922 .collect::<Result<Vec<_>, _>>()?;
5923 if query.comments_match(&comments) {
5924 return Ok(true);
5925 }
5926 match next_cursor(connection)? {
5927 Some(next) => {
5928 validate_cursor_progress(after.as_deref(), &next.0)?;
5929 after = Some(next.0);
5930 }
5931 None => return Ok(false),
5932 }
5933 }
5934 }
5935
5936 /// Every item on the board: the union of both enumerations GitHub offers of one.
5937 ///
5938 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5939 /// board **draft** and reads the board's own fields beside its items, and only the search
5940 /// reports an item that connection is behind on. The module documentation is where the lag and the
5941 /// measurements behind it are written down.
5942 ///
5943 /// A search result is admitted on the same terms as any other issue this source reaches
5944 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5945 /// names *this* board — so an issue the index still believes is here after it was taken
5946 /// off is refused rather than reported.
5947 ///
5948 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5949 /// which is what the cache could otherwise have broken.
5950 async fn board(&self) -> Result<Board, SourceError> {
5951 let cached = self.board_cache()?.clone();
5952 let mut board = match cached {
5953 Some(board) => board,
5954 None => {
5955 let read = self.read_board().await?;
5956 *self.board_cache()? = Some(read.clone());
5957 read
5958 }
5959 };
5960 for held in self.searched_issues().await? {
5961 if !board.items.iter().any(|item| item.id == held.id) {
5962 board.items.push(held);
5963 }
5964 }
5965 for own in self.created()?.iter() {
5966 if !board.items.iter().any(|item| item.id == own.id) {
5967 board.items.push(own.clone());
5968 }
5969 }
5970 Ok(board)
5971 }
5972
5973 /// This process's own view of the board, or the refusal a poisoned lock is.
5974 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5975 self.board_cache
5976 .lock()
5977 .map_err(|_| SourceError::Unavailable {
5978 message: "this source's view of the board was left inconsistent by an earlier \
5979 failure; next: run the command again"
5980 .into(),
5981 })
5982 }
5983
5984 /// Bring this process's own view of the board up to an item it has just written.
5985 ///
5986 /// A created item goes to `created`, which is what completes a board read GitHub's own
5987 /// eventual consistency has left behind. An item that was already there is replaced
5988 /// where it sits, so a second write of it in the same command reads its real parent
5989 /// rather than the one it had before the first write.
5990 ///
5991 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5992 /// that wins: an item this same run created is held in `created` and not in the cached
5993 /// board, and `board` completes the cached board *from* `created`, so replacing only
5994 /// the cached copy of such an item replaces nothing and the read still reports the
5995 /// title it was created with. The search is the third, and it is the one an item the
5996 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5997 /// source is least able to re-read, so leaving it out would put the stale title back on
5998 /// the only items the completion in [`Self::board`] exists for.
5999 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
6000 self.resolved_cache()?.insert(item.id.clone(), item.clone());
6001 if created {
6002 self.created()?.push(item);
6003 return Ok(());
6004 }
6005 {
6006 let mut own = self.created()?;
6007 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
6008 *held = item;
6009 return Ok(());
6010 }
6011 }
6012 {
6013 let mut own = self.updated()?;
6014 match own.iter_mut().find(|held| held.id == item.id) {
6015 Some(held) => *held = item.clone(),
6016 None => own.push(item.clone()),
6017 }
6018 }
6019 if let Some(board) = self.board_cache()?.as_mut()
6020 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
6021 {
6022 *held = item.clone();
6023 }
6024 if let Some(found) = self.search_cache()?.as_mut()
6025 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
6026 {
6027 *held = item.clone();
6028 }
6029 for found in self.narrowed_cache()?.values_mut() {
6030 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6031 *held = item.clone();
6032 }
6033 }
6034 for (_, found) in self.children_cache()?.values_mut() {
6035 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
6036 *held = item.clone();
6037 }
6038 }
6039 Ok(())
6040 }
6041
6042 /// Forget one item this process has just deleted, from every half of its own view.
6043 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
6044 self.resolved_cache()?.remove(id);
6045 self.created()?.retain(|own| own.id != *id);
6046 self.updated()?.retain(|own| own.id != *id);
6047 self.commented()?.retain(|own| own != id);
6048 if let Some(board) = self.board_cache()?.as_mut() {
6049 board.items.retain(|item| item.id != *id);
6050 }
6051 if let Some(found) = self.search_cache()?.as_mut() {
6052 found.retain(|item| item.id != *id);
6053 }
6054 for found in self.narrowed_cache()?.values_mut() {
6055 found.retain(|item| item.id != *id);
6056 }
6057 for (_, found) in self.children_cache()?.values_mut() {
6058 found.retain(|item| item.id != *id);
6059 }
6060 Ok(())
6061 }
6062
6063 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
6064 fn narrowed_cache(
6065 &self,
6066 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
6067 self.narrowed_cache
6068 .lock()
6069 .map_err(|_| SourceError::Unavailable {
6070 message: "this source's view of a narrowed read was left inconsistent by an \
6071 earlier failure; next: run the command again"
6072 .into(),
6073 })
6074 }
6075
6076 /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
6077 /// is.
6078 fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
6079 self.children_cache
6080 .lock()
6081 .map_err(|_| SourceError::Unavailable {
6082 message: "this source's view of a project's tasks was left inconsistent by an \
6083 earlier failure; next: run the command again"
6084 .into(),
6085 })
6086 }
6087
6088 /// Every page of the board, read from GitHub.
6089 async fn read_board(&self) -> Result<Board, SourceError> {
6090 let mut after: Option<String> = None;
6091 let mut items = Vec::new();
6092 let mut board;
6093 loop {
6094 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
6095 for item in page
6096 .pointer("/items/nodes")
6097 .and_then(Value::as_array)
6098 .ok_or_else(|| SourceError::Malformed {
6099 message: "GitHub project items.nodes is not an array".into(),
6100 })?
6101 {
6102 if let Some(resolved) = self.resolve(item)? {
6103 items.push(resolved);
6104 }
6105 }
6106 let info = page
6107 .pointer("/items/pageInfo")
6108 .ok_or_else(|| SourceError::Malformed {
6109 message: "GitHub project items have no pageInfo".into(),
6110 })?;
6111 let has_next = required_bool(info, "hasNextPage")?;
6112 let next = has_next
6113 .then(|| required_str(info, "endCursor"))
6114 .transpose()?;
6115 board = page.clone();
6116 match next {
6117 Some(next) => {
6118 validate_cursor_progress(after.as_deref(), next)?;
6119 after = Some(next.to_owned());
6120 }
6121 None => break,
6122 }
6123 }
6124 Ok(Board {
6125 id: required_str(&board, "id")?.to_owned(),
6126 fields: board.get("fields").cloned().unwrap_or(Value::Null),
6127 items,
6128 })
6129 }
6130
6131 /// The existing items this source has written, for completing a narrowed read that is
6132 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
6133 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6134 self.updated.lock().map_err(|_| SourceError::Unavailable {
6135 message: "this source's record of what it wrote in this run was left inconsistent \
6136 by an earlier failure; next: run the command again"
6137 .into(),
6138 })
6139 }
6140
6141 /// The issues this source has commented on in this command; see
6142 /// [`Self::commented`](GitHubProjectsSource::commented).
6143 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
6144 self.commented.lock().map_err(|_| SourceError::Unavailable {
6145 message: "this source's record of what it commented on in this run was left \
6146 inconsistent by an earlier failure; next: run the command again"
6147 .into(),
6148 })
6149 }
6150
6151 /// Called only once GitHub has answered the comment write, so an issue whose comment
6152 /// failed is never made a candidate a later read would pay a node read for.
6153 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
6154 let mut commented = self.commented()?;
6155 if !commented.contains(issue) {
6156 commented.push(issue.clone());
6157 }
6158 Ok(())
6159 }
6160
6161 /// The items this source has created, for completing a board read that is behind.
6162 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
6163 self.created.lock().map_err(|_| SourceError::Unavailable {
6164 message: "this source's record of what it created in this run was left \
6165 inconsistent by an earlier failure; next: run the command again"
6166 .into(),
6167 })
6168 }
6169
6170 /// One board item as this source reports it, or `None` for content it ignores.
6171 ///
6172 /// A pull request is neither a project nor a task — it is somebody's change, not a
6173 /// unit of plan — and an item whose content the token cannot see has nothing to
6174 /// report at all.
6175 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
6176 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
6177 message: "GitHub project item is missing content".into(),
6178 })?;
6179 if content.is_null() {
6180 return Ok(None);
6181 }
6182 let content_kind = match required_str(content, "__typename")? {
6183 "Issue" => ContentKind::Issue,
6184 "DraftIssue" => ContentKind::DraftIssue,
6185 _ => return Ok(None),
6186 };
6187 let field_values = item
6188 .get("fieldValues")
6189 .ok_or_else(|| SourceError::Malformed {
6190 message: "GitHub project item is missing fieldValues".into(),
6191 })?;
6192 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
6193 let nodes = field_values
6194 .get("nodes")
6195 .and_then(Value::as_array)
6196 .ok_or_else(|| SourceError::Malformed {
6197 message: "GitHub project item fieldValues.nodes is not an array".into(),
6198 })?;
6199 if let Some(labels) = content.get("labels") {
6200 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
6201 }
6202 let raw_body = optional_str(content, "body")?.map(str::to_owned);
6203 let (body, slot) = metadata_body(raw_body.clone())?;
6204 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
6205 .map(|id| NativeId(id.to_owned()));
6206 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
6207 // to read one from; it is a task, and never a project.
6208 let sub_issues = match content_kind {
6209 ContentKind::Issue => sub_issue_total(content)?,
6210 ContentKind::DraftIssue => 0,
6211 };
6212 let content_id = required_str(content, "id")?;
6213 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
6214 message: format!("GitHub issue {content_id}: {message}"),
6215 })?;
6216 let raw_title = required_str(content, "title")?;
6217 // The design prefix is read *first*, before either of the two rules that separate
6218 // a project from a task. A document is not work whatever sub-issues it has and
6219 // whatever marker it carries, and reading the prefix later would make a design
6220 // issue with none of either an empty project.
6221 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
6222 BoardKind::Document
6223 } else if parent.is_some() {
6224 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
6225 // under a project is that project's task even when it has sub-issues of its
6226 // own.
6227 BoardKind::Work(ItemKind::Task)
6228 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
6229 BoardKind::Work(ItemKind::Project)
6230 } else {
6231 BoardKind::Work(ItemKind::Task)
6232 };
6233 // The title a person wrote, which for a document is the one without the prefix —
6234 // the same way `content` above is the body without this source's metadata slot.
6235 let title = match kind {
6236 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
6237 BoardKind::Work(_) => raw_title.to_owned(),
6238 };
6239 let own_repository = content
6240 .pointer("/repository/nameWithOwner")
6241 .and_then(Value::as_str)
6242 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
6243 .transpose()
6244 .map_err(|message| SourceError::Malformed { message })?;
6245 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
6246 Repository::from_metadata(&slot)
6247 .map_err(|message| SourceError::Malformed { message })?
6248 } else {
6249 own_repository.clone().into_iter().collect()
6250 };
6251 let classification = Classification::from_metadata(&slot)
6252 .map_err(|message| SourceError::Malformed { message })?;
6253 let id = NativeId(content_id.to_owned());
6254 // Read only for a task, because only a task has either list: a project or a
6255 // document holding one of these keys holds nothing this source reports, and the
6256 // keys are left out of its caller-visible metadata all the same.
6257 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
6258 let listed = |key: &str| {
6259 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
6260 .map_err(|message| SourceError::Malformed { message })
6261 };
6262 (
6263 listed(TaskRef::DELIVERS_KEY)?,
6264 listed(TaskRef::DELIVERED_BY_KEY)?,
6265 )
6266 } else {
6267 (Vec::new(), Vec::new())
6268 };
6269 let (option, closed, reason) = Self::status_parts(nodes, content)?;
6270 let priority = self.held_priority(nodes)?;
6271 // Present when the item was reached through its own issue, whose board entry
6272 // names the board; a read of the board's own items has the board already. An
6273 // empty id names nothing a field write could address, so it is read as absent and
6274 // the write goes back to reading the board.
6275 let board_id = item
6276 .pointer("/project/id")
6277 .and_then(Value::as_str)
6278 .filter(|id| !id.is_empty());
6279 let resolved = Resolved {
6280 item_id: required_str(item, "id")?.to_owned(),
6281 id,
6282 content_kind,
6283 kind,
6284 title,
6285 body: body.filter(|value| !value.is_empty()),
6286 raw_body,
6287 status: self
6288 .statuses
6289 .status(kind.status_kind(), option, closed, reason),
6290 option: option.map(str::to_owned),
6291 priority,
6292 closed,
6293 delivers,
6294 delivered_by,
6295 labels: labels(content)?,
6296 parent,
6297 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
6298 projected: self.held_projections(nodes)?,
6299 number: match content_kind {
6300 ContentKind::Issue => Some(issue_number(content)?),
6301 // A draft is filed in no repository, so nothing ever numbered it:
6302 // `DraftIssue` declares no `number` at all, exactly as it declares no
6303 // `subIssuesSummary` the branch above reads.
6304 ContentKind::DraftIssue => None,
6305 },
6306 url: optional_str(content, "url")?.map(str::to_owned),
6307 created_at: optional_time(content, "createdAt")?,
6308 updated_at: optional_time(content, "updatedAt")?,
6309 item_updated_at: optional_time(item, "updatedAt")?,
6310 comment_count: content
6311 .pointer("/commentCount/totalCount")
6312 .and_then(Value::as_u64),
6313 relationship_events: content
6314 .pointer("/relationshipEvents/totalCount")
6315 .and_then(Value::as_u64),
6316 // The issue node is stamped wherever it was read; a board item a read of the issue
6317 // put together from its memberships is not.
6318 served_at: match optional_time(content, SERVED_AT)? {
6319 Some(served) => Some(served),
6320 None => optional_time(item, SERVED_AT)?,
6321 },
6322 own_repository,
6323 repositories,
6324 classification,
6325 slot,
6326 board_id: board_id.map(str::to_owned),
6327 fields: field_definitions(nodes),
6328 board_fields: Self::carried_board_fields(content, board_id)?,
6329 blocked_by: carried_blocked_by(content)?,
6330 };
6331 self.resolved_cache()?
6332 .insert(resolved.id.clone(), resolved.clone());
6333 Ok(Some(resolved))
6334 }
6335
6336 /// The field definitions of the board `board_id` names — the project this issue's own
6337 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
6338 /// `None` when the read carried none, carried no entry for that board, or the board item
6339 /// named no board, which a write then answers by reading the board's fields itself.
6340 ///
6341 /// Matched by the board's node id and never by its number alone: a project number is
6342 /// unique only within its owner, so another owner's board numbered alike can sit on the
6343 /// same page, and its field and option ids address nothing on this one.
6344 fn carried_board_fields(
6345 content: &Value,
6346 board_id: Option<&str>,
6347 ) -> Result<Option<Value>, SourceError> {
6348 let (Some(nodes), Some(board_id)) = (
6349 content.pointer("/boards/nodes").and_then(Value::as_array),
6350 board_id,
6351 ) else {
6352 return Ok(None);
6353 };
6354 let Some(board) = nodes.iter().find_map(|node| {
6355 let project = node.get("project")?;
6356 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
6357 }) else {
6358 return Ok(None);
6359 };
6360 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
6361 return Ok(None);
6362 };
6363 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
6364 Ok(Some(fields.clone()))
6365 }
6366
6367 /// What one board item's `Priority` field says, through this instance's mapping.
6368 ///
6369 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
6370 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
6371 /// option the mapping does not name is kept as itself — never read as a level or as
6372 /// `none` — for a read of the task to report by name.
6373 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
6374 let Some(mapping) = &self.priorities else {
6375 return Ok(HeldPriority::Read(Priority::None));
6376 };
6377 // A value of the field that names no option — a text field someone called `Priority` —
6378 // is malformed rather than `none`: reading it as no priority would let the next copy
6379 // clear one a person set.
6380 let Some(option) = field_values
6381 .iter()
6382 .find(|value| {
6383 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
6384 })
6385 .map(|value| required_str(value, "name"))
6386 .transpose()?
6387 else {
6388 return Ok(HeldPriority::Read(Priority::None));
6389 };
6390 Ok(mapping.priority_of(option).map_or_else(
6391 || HeldPriority::Unmapped(option.to_owned()),
6392 HeldPriority::Read,
6393 ))
6394 }
6395
6396 /// What each projected metadata field holds for one board item, by field name — every text
6397 /// value GitHub answers, an empty one included.
6398 fn held_projections(
6399 &self,
6400 field_values: &[Value],
6401 ) -> Result<BTreeMap<String, String>, SourceError> {
6402 let mut held = BTreeMap::new();
6403 for projection in &self.metadata_fields {
6404 if let Some(text) = text_field(field_values, &projection.field)? {
6405 held.insert(projection.field.to_string(), text);
6406 }
6407 }
6408 Ok(held)
6409 }
6410
6411 /// What one board item's status is read from: its `Status` option, whether its issue
6412 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
6413 /// three into the status it reports.
6414 fn status_parts<'a>(
6415 field_values: &'a [Value],
6416 content: &'a Value,
6417 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
6418 let option = field_values
6419 .iter()
6420 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
6421 .map(|value| required_str(value, "name"))
6422 .transpose()?;
6423 let closed = optional_str(content, "state")? == Some("CLOSED");
6424 Ok((option, closed, optional_str(content, "stateReason")?))
6425 }
6426
6427 /// The board Status option this write selects, or the refusal that says why not.
6428 ///
6429 /// The mapped option is required for both open and terminal targets. A terminal write
6430 /// validates it before changing either representation, so it can never fall back to
6431 /// closing an issue whose board cannot display the matching status.
6432 ///
6433 /// Answers the field's id, the option's id, and the option's name as the board spells
6434 /// it — which is the name a read of the item reports once it sits there.
6435 fn column_for(
6436 &self,
6437 fields: &Value,
6438 kind: ItemKind,
6439 category: StatusCategory,
6440 target: &StatusTarget,
6441 ) -> Result<Option<(String, String, String)>, SourceError> {
6442 let Some(wanted) = target.option() else {
6443 return Ok(None);
6444 };
6445 let missing = |detail: &str| SourceError::Refused {
6446 message: format!(
6447 "{} status {} of source {} needs the board Status option {wanted:?}, and \
6448 {detail}; next: add that option to the board, which `onetaskgraph sources \
6449 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
6450 it has",
6451 kind.marker(),
6452 category_name(category),
6453 self.name,
6454 self.name,
6455 category_name(category),
6456 kind.marker()
6457 ),
6458 };
6459 let Some(field) = Board::field(fields, "Status")? else {
6460 return Err(missing("this board has no Status field"));
6461 };
6462 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
6463 return Err(missing(
6464 "this board's Status field is not a single-select field",
6465 ));
6466 }
6467 let option = field
6468 .get("options")
6469 .and_then(Value::as_array)
6470 .and_then(|options| {
6471 options.iter().find(|option| {
6472 option
6473 .get("name")
6474 .and_then(Value::as_str)
6475 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
6476 })
6477 });
6478 match option {
6479 None => Err(missing("this board does not have it")),
6480 Some(option) => Ok(Some((
6481 required_str(field, "id")?.to_owned(),
6482 required_str(option, "id")?.to_owned(),
6483 required_str(option, "name")?.to_owned(),
6484 ))),
6485 }
6486 }
6487
6488 /// The refusal a status that closes an issue is answered with over a board draft.
6489 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
6490 SourceError::Refused {
6491 message: format!(
6492 "status {} of source {} closes the item's issue, and GitHub draft items have \
6493 no open or closed state",
6494 category_name(category),
6495 self.name
6496 ),
6497 }
6498 }
6499
6500 /// What a status write to one item needs of the board: the board's id and the
6501 /// definition of its `Status` field, read off the item when the item says both.
6502 ///
6503 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
6504 /// and its `Status` value carries that field's definition, options and all. An item that
6505 /// does not say — no board id, or no `Status` value to read the field off — takes them
6506 /// from [`Self::board_fields`], which reads no item.
6507 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
6508 if let Some(board) = item.carried_board() {
6509 return Ok(board);
6510 }
6511 if item.defines("Status")
6512 && let Some(board_id) = item.named_board()
6513 {
6514 return Ok(BoardFields {
6515 id: board_id,
6516 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6517 });
6518 }
6519 self.board_fields().await
6520 }
6521
6522 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
6523 async fn set_status(
6524 &self,
6525 id: &NativeId,
6526 category: StatusCategory,
6527 ) -> Result<Option<Status>, SourceError> {
6528 // Refused before anything is read, in the words a write of the same status is.
6529 let target = self.resolved_target(ItemKind::Task, category)?;
6530 let Some(mut item) = self
6531 .bound_item(id)
6532 .await?
6533 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6534 else {
6535 return Ok(None);
6536 };
6537 let board = self.status_board(&item).await?;
6538 let (field, option, name) = self
6539 .column_for(&board.fields, ItemKind::Task, category, &target)?
6540 .ok_or_else(|| SourceError::Malformed {
6541 message: format!(
6542 "status {} of source {} names no board Status option",
6543 category_name(category),
6544 self.name
6545 ),
6546 })?;
6547 if item.status.category == category && item.option.as_deref() == Some(&name) {
6548 return Ok(Some(item.status));
6549 }
6550 match &target {
6551 StatusTarget::Terminal(_, reason) => {
6552 if item.content_kind == ContentKind::DraftIssue {
6553 return Err(self.closes_a_draft(category));
6554 }
6555 self.set_item_field(
6556 board.id.as_str(),
6557 &item.item_id,
6558 &field,
6559 json!({"singleSelectOptionId": option}),
6560 )
6561 .await?;
6562 self.update_content(
6563 ContentKind::Issue,
6564 &item.id,
6565 json!({"stateInput": state_input(Some(&target))}),
6566 )
6567 .await?;
6568 item.closed = true;
6569 item.status =
6570 self.statuses
6571 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
6572 item.option = Some(name);
6573 }
6574 StatusTarget::Column(_) => {
6575 // An option is what an open item's status is, so a closed issue is reopened
6576 // first — sitting closed in the column, it would read back as closed. A draft has
6577 // no state to reopen.
6578 if item.content_kind == ContentKind::Issue && item.closed {
6579 self.update_content(
6580 ContentKind::Issue,
6581 &item.id,
6582 json!({"stateInput": state_input(Some(&target))}),
6583 )
6584 .await?;
6585 item.closed = false;
6586 }
6587 self.set_item_field(
6588 board.id.as_str(),
6589 &item.item_id,
6590 &field,
6591 json!({"singleSelectOptionId": option}),
6592 )
6593 .await?;
6594 item.status = self
6595 .statuses
6596 .status(ItemKind::Task, Some(&name), false, None);
6597 item.option = Some(name);
6598 }
6599 StatusTarget::Disabled(_) => {
6600 unreachable!("resolved_target refused a disabled status")
6601 }
6602 }
6603 let status = item.status.clone();
6604 self.remember_written(item, false)?;
6605 Ok(Some(status))
6606 }
6607
6608 /// Replace one task's `delivered_by` and nothing else; see
6609 /// [`TaskSource::set_delivered_by`].
6610 ///
6611 /// One update of the body, which differs from the body GitHub holds only inside the
6612 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
6613 async fn replace_delivered_by(
6614 &self,
6615 id: &NativeId,
6616 delivered_by: &[TaskRef],
6617 ) -> Result<Option<()>, SourceError> {
6618 let entries = TaskRef::listed(
6619 TaskRef::DELIVERED_BY_KEY,
6620 id,
6621 Some(&self.name),
6622 delivered_by.to_vec(),
6623 )
6624 .map_err(|message| SourceError::Refused { message })?;
6625 let Some(mut item) = self
6626 .bound_item(id)
6627 .await?
6628 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6629 else {
6630 return Ok(None);
6631 };
6632 let mut slot = item.slot.clone();
6633 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
6634 self.write_slot(&mut item, &slot).await?;
6635 item.delivered_by = entries;
6636 self.remember_written(item, false)?;
6637 Ok(Some(()))
6638 }
6639
6640 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
6641 /// see [`TaskSource::set_task_metadata`].
6642 ///
6643 /// `None` when this board holds no item by that id, or holds one of another kind. The
6644 /// answer is the item as this source now reads it, so what a caller is told the key
6645 /// holds is what the slot holds.
6646 ///
6647 /// A key already holding the value is answered without a write, compared as JSON rather
6648 /// than as the body's bytes: a slot a person spelled with other whitespace would
6649 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
6650 async fn set_slot_key(
6651 &self,
6652 id: &NativeId,
6653 kind: BoardKind,
6654 key: &MetadataKey,
6655 value: &Value,
6656 ) -> Result<Option<Resolved>, SourceError> {
6657 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6658 return Ok(None);
6659 };
6660 if item.slot.get(key.as_str()) == Some(value) {
6661 return Ok(Some(item));
6662 }
6663 let mut slot = item.slot.clone();
6664 slot.insert(key.as_str().to_owned(), value.clone());
6665 // A key a board text field projects moves that field first, so the body — the value's
6666 // one authoritative home — is written last, as every write of an existing item is.
6667 if self
6668 .metadata_fields
6669 .iter()
6670 .any(|projection| &*projection.key == key.as_str())
6671 {
6672 let board = self.projection_board(&item).await?;
6673 let (writes, projected) = self.projection_writes(
6674 &board.fields,
6675 Some(&item.projected),
6676 &slot,
6677 Some(&[key.as_str()]),
6678 )?;
6679 let (mut values, mut clears) = (Vec::new(), Vec::new());
6680 for write in &writes {
6681 write.join(&mut values, &mut clears);
6682 }
6683 self.set_item_fields(board.id.as_str(), &item.item_id, &values, &clears)
6684 .await?;
6685 item.projected = projected;
6686 }
6687 self.write_slot(&mut item, &slot).await?;
6688 self.remember_written(item.clone(), false)?;
6689 Ok(Some(item))
6690 }
6691
6692 /// What a projection write to `item` needs of the board, read off the item when it says
6693 /// enough, as [`Self::fields_for`] reads it.
6694 async fn projection_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
6695 if let Some(board) = item.carried_board() {
6696 return Ok(board);
6697 }
6698 if let Some(board_id) = item.named_board()
6699 && self
6700 .metadata_fields
6701 .iter()
6702 .all(|projection| item.defines(&projection.field))
6703 {
6704 return Ok(BoardFields {
6705 id: board_id,
6706 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6707 });
6708 }
6709 self.board_fields().await
6710 }
6711
6712 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
6713 /// `item` up to what that write left.
6714 ///
6715 /// The body sent differs from the body GitHub holds only inside the slot — see
6716 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
6717 /// the mutation the item's content takes, so a board draft's body is written with
6718 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
6719 async fn write_slot(
6720 &self,
6721 item: &mut Resolved,
6722 slot: &BTreeMap<String, Value>,
6723 ) -> Result<(), SourceError> {
6724 let held = item.raw_body.clone().unwrap_or_default();
6725 let body = with_slot(&held, slot)?;
6726 if body != held {
6727 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6728 .await?;
6729 }
6730 let (visible, slot) = metadata_body(Some(body.clone()))?;
6731 item.body = visible.filter(|value| !value.is_empty());
6732 item.raw_body = Some(body);
6733 item.slot = slot;
6734 Ok(())
6735 }
6736
6737 /// This instance's target for a category written to an item of `kind`, refusing one
6738 /// that kind has no option for — before anything is read or written.
6739 ///
6740 /// Nothing here mutates the board's option set to make room for a status. GitHub
6741 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
6742 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
6743 /// field and every item's status.
6744 fn resolved_target(
6745 &self,
6746 kind: ItemKind,
6747 category: StatusCategory,
6748 ) -> Result<StatusTarget, SourceError> {
6749 let target = self.statuses.target(kind, category).clone();
6750 let StatusTarget::Disabled(why) = target else {
6751 return Ok(target);
6752 };
6753 let refusal = why.refusal(&self.name, category, kind);
6754 // Why there is no shipped default, which is the question a person meeting this
6755 // refusal on a source that never mentioned the category asks.
6756 let shipped_none = match category {
6757 StatusCategory::Draft => Some(
6758 "draft has no shipped default because GitHub draft issues cannot have \
6759 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
6760 ),
6761 StatusCategory::Unknown => Some(
6762 "unknown has no shipped default because this board keeps no open-ended status \
6763 word: every word classified unknown is written to the one board Status option \
6764 status_mapping.unknown names",
6765 ),
6766 _ => None,
6767 };
6768 Err(match (refusal, shipped_none, why) {
6769 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
6770 SourceError::Refused {
6771 message: format!("{message}; {note}"),
6772 }
6773 }
6774 (refusal, _, _) => refusal,
6775 })
6776 }
6777
6778 /// What writing `priority` does to one item's `Priority` field on this board, or the
6779 /// refusal naming what the board lacks.
6780 ///
6781 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
6782 /// none already, or of an item not created yet. Every other priority selects the option
6783 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
6784 /// without that option, is refused rather than given one: reads and writes never create
6785 /// a field or an option.
6786 fn priority_write(
6787 &self,
6788 fields: &Value,
6789 existing: Option<&Resolved>,
6790 priority: Priority,
6791 ) -> Result<Option<PriorityWrite>, SourceError> {
6792 let Some(mapping) = &self.priorities else {
6793 return Err(self.holds_no_priority());
6794 };
6795 let Some(wanted) = mapping.option(priority) else {
6796 if !existing.is_some_and(Resolved::holds_priority) {
6797 return Ok(None);
6798 }
6799 let field =
6800 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
6801 message: format!(
6802 "an item holding a {PRIORITY_FIELD} value was read without that field"
6803 ),
6804 })?;
6805 return Ok(Some(PriorityWrite::Clear {
6806 field: required_str(field, "id")?.to_owned(),
6807 }));
6808 };
6809 let missing = |detail: &str| SourceError::Refused {
6810 message: format!(
6811 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
6812 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
6813 it, or point priority_mapping.{priority} of this source at an option the board \
6814 has",
6815 self.name, self.name
6816 ),
6817 };
6818 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
6819 return Err(missing(&format!(
6820 "this board has no {PRIORITY_FIELD} field"
6821 )));
6822 };
6823 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
6824 return Err(missing(&format!(
6825 "this board's {PRIORITY_FIELD} field is not a single-select field"
6826 )));
6827 }
6828 // An options list that is absent or not a list is an answer this source cannot read,
6829 // not a board lacking the option: `sources fields --apply` is no remedy for it.
6830 let option = field
6831 .get("options")
6832 .and_then(Value::as_array)
6833 .ok_or_else(|| SourceError::Malformed {
6834 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
6835 })?
6836 .iter()
6837 .find(|option| {
6838 option
6839 .get("name")
6840 .and_then(Value::as_str)
6841 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
6842 })
6843 .ok_or_else(|| missing("this board does not have it"))?;
6844 Ok(Some(PriorityWrite::Select {
6845 field: required_str(field, "id")?.to_owned(),
6846 option: required_str(option, "id")?.to_owned(),
6847 }))
6848 }
6849
6850 /// What writing `metadata` does to each projected text field of one item, and what those
6851 /// fields hold after it — or the refusal, before anything is sent.
6852 ///
6853 /// `held` is what the item's fields hold now, `None` for an item not created yet. `keys`
6854 /// narrows the work to the entries projecting one of those metadata keys, for a write that
6855 /// touches only them; `None` is every entry. A string the field already holds, and nothing
6856 /// for a field holding nothing, sends nothing. A value no text field can hold is refused,
6857 /// and so is a board without the field, or with a field of that name that is not a text
6858 /// field: reads and writes never create a field.
6859 fn projection_writes(
6860 &self,
6861 fields: &Value,
6862 held: Option<&BTreeMap<String, String>>,
6863 metadata: &BTreeMap<String, Value>,
6864 keys: Option<&[&str]>,
6865 ) -> Result<(Vec<ProjectionWrite>, BTreeMap<String, String>), SourceError> {
6866 let mut projected = held.cloned().unwrap_or_default();
6867 let mut writes = Vec::new();
6868 for projection in self
6869 .metadata_fields
6870 .iter()
6871 .filter(|projection| keys.is_none_or(|keys| keys.contains(&&*projection.key)))
6872 {
6873 let wanted = projection.projected(metadata, &self.name)?;
6874 let field = self.projection_field(fields, projection)?;
6875 let holding = projected.get(&*projection.field);
6876 match wanted {
6877 Projected::Text(text) if holding != Some(&text) => {
6878 writes.push(ProjectionWrite::Set {
6879 field,
6880 text: text.clone(),
6881 });
6882 projected.insert(projection.field.to_string(), text);
6883 }
6884 Projected::Nothing if holding.is_some() => {
6885 writes.push(ProjectionWrite::Clear { field });
6886 projected.remove(&*projection.field);
6887 }
6888 Projected::Text(_) | Projected::Nothing => {}
6889 }
6890 }
6891 Ok((writes, projected))
6892 }
6893
6894 /// The id of the board text field one projection writes, or the refusal naming what the
6895 /// board has instead.
6896 fn projection_field(
6897 &self,
6898 fields: &Value,
6899 projection: &MetadataField,
6900 ) -> Result<String, SourceError> {
6901 let refuse = |detail: String| SourceError::Refused {
6902 message: format!(
6903 "source {} projects metadata key {:?} onto the board text field {:?}, and \
6904 {detail}; next: run `onetaskgraph sources fields {} --apply` to create it as a \
6905 text field, or name another field in metadata_fields",
6906 self.name, &*projection.key, &*projection.field, self.name
6907 ),
6908 };
6909 let Some(field) = Board::field(fields, &projection.field)? else {
6910 return Err(refuse("this board has no field of that name".to_owned()));
6911 };
6912 let typename = optional_str(field, "__typename")?.unwrap_or("field of another type");
6913 let data_type = optional_str(field, "dataType")?;
6914 if typename != "ProjectV2Field" || data_type != Some("TEXT") {
6915 return Err(refuse(format!(
6916 "this board's field of that name is not a text field (it is a {}), so it is \
6917 left as it is; rename or remove that field first",
6918 data_type.unwrap_or(typename)
6919 )));
6920 }
6921 Ok(required_str(field, "id")?.to_owned())
6922 }
6923
6924 /// Apply one priority write to one board item.
6925 async fn write_priority(
6926 &self,
6927 board_id: &str,
6928 item_id: &str,
6929 write: &PriorityWrite,
6930 ) -> Result<(), SourceError> {
6931 match write {
6932 PriorityWrite::Select { field, option } => {
6933 self.set_item_field(
6934 board_id,
6935 item_id,
6936 field,
6937 json!({"singleSelectOptionId": option}),
6938 )
6939 .await
6940 }
6941 PriorityWrite::Clear { field } => {
6942 let data = self
6943 .graphql(
6944 graphql::CLEAR_FIELD,
6945 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
6946 "readPriority":false,"priorityName":PRIORITY_FIELD}),
6947 )
6948 .await?;
6949 let returned = data
6950 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
6951 .ok_or_else(|| SourceError::Malformed {
6952 message: "GitHub field clear returned no project item".into(),
6953 })?;
6954 if required_str(returned, "id")? != item_id {
6955 return Err(SourceError::Malformed {
6956 message: "GitHub field clear returned the wrong project item".into(),
6957 });
6958 }
6959 Ok(())
6960 }
6961 }
6962 }
6963
6964 /// The refusal a priority is answered with by an instance configured with no
6965 /// `priority_mapping`, which holds none.
6966 fn holds_no_priority(&self) -> SourceError {
6967 SourceError::Refused {
6968 message: format!(
6969 "source {} holds no task priority: its configuration sets no priority_mapping; \
6970 next: set priority_mapping on this source, then run `onetaskgraph sources \
6971 fields {} --apply` to set its board up",
6972 self.name, self.name
6973 ),
6974 }
6975 }
6976
6977 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
6978 ///
6979 /// One field write — a select, or a clear for `none` — and no title, body, label, state
6980 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
6981 async fn set_priority(
6982 &self,
6983 id: &NativeId,
6984 priority: Priority,
6985 ) -> Result<Option<Priority>, SourceError> {
6986 if self.priorities.is_none() {
6987 return Err(self.holds_no_priority());
6988 }
6989 let Some(mut item) = self
6990 .bound_item(id)
6991 .await?
6992 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6993 else {
6994 return Ok(None);
6995 };
6996 if priority == Priority::None && !item.holds_priority() {
6997 return Ok(Some(priority));
6998 }
6999 // The item's own read carries the field's definition whenever it holds a value of
7000 // it, which a clear always does; a select onto an item holding none reads the board.
7001 let board = match (item.carried_board(), item.named_board()) {
7002 (Some(board), _) => board,
7003 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
7004 id,
7005 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
7006 },
7007 _ => self.board_fields().await?,
7008 };
7009 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
7010 return Ok(Some(priority));
7011 };
7012 let (document, root, input) = match write {
7013 PriorityWrite::Select { field, option } => (
7014 graphql::UPDATE_FIELD,
7015 "updateProjectV2ItemFieldValue",
7016 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
7017 ),
7018 PriorityWrite::Clear { field } => (
7019 graphql::CLEAR_FIELD,
7020 "clearProjectV2ItemFieldValue",
7021 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
7022 ),
7023 };
7024 let data = self
7025 .graphql(
7026 document,
7027 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
7028 )
7029 .await?;
7030 let returned = data
7031 .get(root)
7032 .and_then(|value| value.get("projectV2Item"))
7033 .ok_or_else(|| SourceError::Malformed {
7034 message: "GitHub priority write returned no project item".into(),
7035 })?;
7036 if required_str(returned, "id")? != item.item_id {
7037 return Err(SourceError::Malformed {
7038 message: "GitHub priority write returned the wrong project item".into(),
7039 });
7040 }
7041 let value = returned
7042 .get("fieldValueByName")
7043 .ok_or_else(|| SourceError::Malformed {
7044 message: "GitHub priority write returned no priority read-back".into(),
7045 })?;
7046 if !value.is_null()
7047 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
7048 {
7049 return Err(SourceError::Malformed {
7050 message: "GitHub priority read-back is not a Priority field value".into(),
7051 });
7052 }
7053 let values = if value.is_null() {
7054 Vec::new()
7055 } else {
7056 vec![value.clone()]
7057 };
7058 item.priority = self.held_priority(&values)?;
7059 let answer = item.task()?.priority;
7060 self.remember_written(item, false)?;
7061 Ok(Some(answer))
7062 }
7063
7064 /// Replace one task's visible body and nothing else; see
7065 /// [`TaskSource::set_task_content`].
7066 ///
7067 /// One update of the body, which differs from the body GitHub holds only outside the
7068 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
7069 /// this source keeps there reads back as it was. A body that would not change is not
7070 /// sent at all.
7071 async fn replace_content(
7072 &self,
7073 id: &NativeId,
7074 content: &str,
7075 ) -> Result<Option<()>, SourceError> {
7076 let Some(mut item) = self
7077 .bound_item(id)
7078 .await?
7079 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7080 else {
7081 return Ok(None);
7082 };
7083 let held = item.raw_body.clone().unwrap_or_default();
7084 let body = with_content(&held, content)?;
7085 // Checked before anything is sent: content ending in what this source reads as its own
7086 // metadata slot would read back as metadata rather than as the content it was.
7087 let (visible, slot) = metadata_body(Some(body.clone()))?;
7088 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
7089 return Err(SourceError::Refused {
7090 message: format!(
7091 "this content ends in what source {} reads as its own metadata slot \
7092 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7093 as content; next: remove that trailing block from the content",
7094 self.name
7095 ),
7096 });
7097 }
7098 if body != held {
7099 self.update_content(item.content_kind, &item.id, json!({"body": body}))
7100 .await?;
7101 }
7102 item.body = visible.filter(|value| !value.is_empty());
7103 item.raw_body = Some(body);
7104 item.slot = slot;
7105 self.remember_written(item, false)?;
7106 Ok(Some(()))
7107 }
7108
7109 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
7110 ///
7111 /// One read of the item — which carries the board's field definitions and the issue's
7112 /// `blockedBy`, so neither is read again — and then only what differs from it: the
7113 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
7114 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
7115 /// the title, the body — visible content and metadata slot together — and a state change.
7116 /// So an update naming any of title, body, metadata, status and priority is one read and
7117 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
7118 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
7119 /// as a whole write does; an open one selects its option and then reopens. The origin
7120 /// field is never written: an update is of an item that already exists, whose origin is
7121 /// what it is.
7122 ///
7123 /// The task answered is the item as those writes left it, built from the read and what was
7124 /// sent rather than read again — the same record a later read in this run answers from.
7125 async fn targeted_update(
7126 &self,
7127 id: &NativeId,
7128 update: &TaskUpdate,
7129 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7130 // Everything this source can refuse without reading the item is refused first, in the
7131 // words a whole write of the same fields is refused with.
7132 update.consistent()?;
7133 if update
7134 .title
7135 .as_deref()
7136 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
7137 {
7138 return Err(SourceError::Refused {
7139 message: format!(
7140 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
7141 spells a document, so it would read back as one rather than as a task; \
7142 retitle it",
7143 self.name
7144 ),
7145 });
7146 }
7147 if let Some(delivers) = &update.delivers {
7148 TaskRef::listed(
7149 TaskRef::DELIVERS_KEY,
7150 id,
7151 Some(&self.name),
7152 delivers.clone(),
7153 )
7154 .map_err(|message| SourceError::Refused { message })?;
7155 }
7156 if self.priorities.is_none()
7157 && update
7158 .priority
7159 .is_some_and(|priority| priority != Priority::None)
7160 {
7161 return Err(self.holds_no_priority());
7162 }
7163 let target = update
7164 .status
7165 .as_ref()
7166 .map(|status| self.resolved_target(ItemKind::Task, status.category))
7167 .transpose()?;
7168 let Some(mut item) = self
7169 .bound_item(id)
7170 .await?
7171 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7172 else {
7173 return Ok(None);
7174 };
7175 let before = item.task()?;
7176
7177 let mut status_move = None;
7178 if let (Some(status), Some(target)) = (&update.status, target) {
7179 let board = self.status_board(&item).await?;
7180 let (field, option, name) = self
7181 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
7182 .ok_or_else(|| SourceError::Malformed {
7183 message: format!(
7184 "status {} of source {} names no board Status option",
7185 category_name(status.category),
7186 self.name
7187 ),
7188 })?;
7189 let terminal = matches!(target, StatusTarget::Terminal(_, _));
7190 if terminal && item.content_kind == ContentKind::DraftIssue {
7191 return Err(self.closes_a_draft(status.category));
7192 }
7193 let landed = match &target {
7194 StatusTarget::Terminal(_, reason) => {
7195 self.statuses
7196 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
7197 }
7198 _ => self
7199 .statuses
7200 .status(ItemKind::Task, Some(&name), false, None),
7201 };
7202 let option_moves = item
7203 .option
7204 .as_deref()
7205 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
7206 let state_moves = item.content_kind == ContentKind::Issue
7207 && (item.closed != terminal || (terminal && item.status != landed));
7208 if let Some(moves) = Moves::of(option_moves, state_moves) {
7209 status_move = Some(StatusMove {
7210 board: board.id,
7211 field,
7212 option,
7213 name,
7214 target,
7215 landed,
7216 moves,
7217 });
7218 }
7219 }
7220
7221 let mut priority_move = None;
7222 if let Some(priority) = update.priority
7223 && self.priorities.is_some()
7224 && item.priority != HeldPriority::Read(priority)
7225 {
7226 let board = match (item.carried_board(), item.named_board()) {
7227 (Some(board), _) => board,
7228 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
7229 id: board,
7230 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
7231 },
7232 _ => self.board_fields().await?,
7233 };
7234 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
7235 priority_move = Some((board.id, write, priority));
7236 }
7237 }
7238
7239 // The metadata keys this update names that a board text field projects, and what
7240 // that does to those fields — refused, as a whole write refuses it, before anything
7241 // is sent.
7242 let touched: Vec<&str> = update
7243 .metadata_set
7244 .keys()
7245 .chain(update.metadata_remove.iter())
7246 .map(MetadataKey::as_str)
7247 .collect();
7248 let mut projection_move = None;
7249 if self
7250 .metadata_fields
7251 .iter()
7252 .any(|projection| touched.contains(&&*projection.key))
7253 {
7254 let mut metadata = item.slot.clone();
7255 for (key, value) in &update.metadata_set {
7256 metadata.insert(key.as_str().to_owned(), value.clone());
7257 }
7258 for key in &update.metadata_remove {
7259 metadata.remove(key.as_str());
7260 }
7261 let board = self.projection_board(&item).await?;
7262 let (writes, projected) = self.projection_writes(
7263 &board.fields,
7264 Some(&item.projected),
7265 &metadata,
7266 Some(&touched),
7267 )?;
7268 projection_move = Some((board.id, writes, projected));
7269 }
7270
7271 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
7272 // recorded in the slot, and the slot travels in the one body update below.
7273 let edges = match &update.depends_on {
7274 Some(edges) => Some(
7275 self.partition_edges(
7276 BoardKind::Work(ItemKind::Task),
7277 item.content_kind,
7278 item.blocked_by.as_deref(),
7279 edges,
7280 )
7281 .await?,
7282 ),
7283 None => None,
7284 };
7285
7286 let mut slot = item.slot.clone();
7287 for (key, value) in &update.metadata_set {
7288 slot.insert(key.as_str().to_owned(), value.clone());
7289 }
7290 for key in &update.metadata_remove {
7291 slot.remove(key.as_str());
7292 }
7293 if let Some(delivers) = &update.delivers {
7294 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
7295 }
7296 if let Some((_, recorded)) = &edges {
7297 record_edges(&mut slot, recorded);
7298 }
7299 let held = item.raw_body.clone().unwrap_or_default();
7300 let content = match &update.content {
7301 Some(content) => with_content(&held, content)?,
7302 None => held.clone(),
7303 };
7304 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
7305 // the body's bytes, as a metadata write compares it: a slot a person spelled with
7306 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
7307 let body = if slot == item.slot {
7308 content
7309 } else {
7310 with_slot(&content, &slot)?
7311 };
7312 // Checked before anything is sent, as a content write checks it: content ending in
7313 // what this source reads as its own slot would read back as metadata.
7314 let (visible, read) = metadata_body(Some(body.clone()))?;
7315 let wanted = update.content.as_deref().or(item.body.as_deref());
7316 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
7317 return Err(SourceError::Refused {
7318 message: format!(
7319 "this content ends in what source {} reads as its own metadata slot \
7320 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7321 as content; next: remove that trailing block from the content",
7322 self.name
7323 ),
7324 });
7325 }
7326 let recorded_moves =
7327 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
7328
7329 // One `updateIssue` carries all three, because every mutation spends the secondary
7330 // limiter and the title, body and state are one mutation's inputs.
7331 let mut fields = serde_json::Map::new();
7332 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
7333 fields.insert("title".to_owned(), json!(title));
7334 }
7335 if body != held {
7336 fields.insert("body".to_owned(), json!(body));
7337 }
7338 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
7339 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
7340 }
7341 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
7342 // GitHub runs no two requests as one, and runs one document's mutation fields in order
7343 // without undoing an earlier field when a later one fails — so a body written before a
7344 // board field the board then refused would be left changed. Written after every other
7345 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
7346 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
7347 // first, together in one request — a terminal option selected before the issue
7348 // closes, as a whole write does — then the `blockedBy` difference, then the body.
7349 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
7350 let mut clears: Vec<(&BoardId, &str)> = Vec::new();
7351 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
7352 board_writes.push((
7353 &moving.board,
7354 (
7355 moving.field.clone(),
7356 json!({"singleSelectOptionId": moving.option}),
7357 ),
7358 ));
7359 }
7360 match &priority_move {
7361 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
7362 board,
7363 (field.clone(), json!({"singleSelectOptionId": option})),
7364 )),
7365 Some((board, PriorityWrite::Clear { field }, _)) => clears.push((board, field)),
7366 None => {}
7367 }
7368 if let Some((board, writes, _)) = &projection_move {
7369 for write in writes {
7370 let (mut values, mut cleared) = (Vec::new(), Vec::new());
7371 write.join(&mut values, &mut cleared);
7372 board_writes.extend(values.into_iter().map(|value| (board, value)));
7373 clears.extend(cleared.into_iter().map(|field| (board, field)));
7374 }
7375 }
7376 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
7377 boards.extend(clears.iter().map(|(board, _)| *board));
7378 boards.dedup_by(|one, other| one.as_str() == other.as_str());
7379 for board in boards {
7380 let writes = board_writes
7381 .iter()
7382 .filter(|(on, _)| on.as_str() == board.as_str())
7383 .map(|(_, write)| write.clone())
7384 .collect::<Vec<_>>();
7385 let cleared = clears
7386 .iter()
7387 .filter(|(on, _)| on.as_str() == board.as_str())
7388 .map(|(_, field)| *field)
7389 .collect::<Vec<_>>();
7390 self.set_item_fields(board.as_str(), &item.item_id, &writes, &cleared)
7391 .await?;
7392 }
7393 let mut blocked_by_moved = false;
7394 if let Some((native, _)) = &edges
7395 && item.content_kind == ContentKind::Issue
7396 {
7397 blocked_by_moved = self
7398 .reconcile_blocked_by(
7399 &item.id,
7400 native,
7401 Issue::Existing(item.blocked_by.as_deref()),
7402 )
7403 .await?;
7404 }
7405 if !fields.is_empty() {
7406 self.update_content(item.content_kind, &item.id, Value::Object(fields))
7407 .await?;
7408 }
7409
7410 // The `blockedBy` this item was read with is what the write just changed, so it is no
7411 // answer any more: a later write in this command would reconcile against it — re-adding
7412 // what it kept and never removing what it dropped — and a forward-edge read would
7413 // answer with it. Left unknown, each asks GitHub instead.
7414 if blocked_by_moved {
7415 item.blocked_by = None;
7416 }
7417 if let Some(title) = &update.title {
7418 item.title.clone_from(title);
7419 }
7420 item.body = visible.filter(|value| !value.is_empty());
7421 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
7422 item.slot = slot;
7423 if let Some(delivers) = &update.delivers {
7424 item.delivers.clone_from(delivers);
7425 }
7426 if let Some(moving) = status_move {
7427 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
7428 && item.content_kind == ContentKind::Issue;
7429 item.status = moving.landed;
7430 item.option = Some(moving.name);
7431 }
7432 if let Some((_, _, priority)) = priority_move {
7433 item.priority = HeldPriority::Read(priority);
7434 }
7435 if let Some((_, _, projected)) = projection_move {
7436 item.projected = projected;
7437 }
7438 let task = item.task()?;
7439 let mut written = update.changed(&before, &task);
7440 if blocked_by_moved || recorded_moves {
7441 written.insert(UpdatedField::DependsOn);
7442 }
7443 self.remember_written(item, false)?;
7444 Ok(Some(TaskUpdateOutcome {
7445 task,
7446 written,
7447 delivers_before: before.delivers,
7448 }))
7449 }
7450
7451 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
7452 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
7453 ///
7454 /// One update of the body: the content outside the slot, and inside it that one entry,
7455 /// every other entry kept as it was. This source keeps no template answers — an issue has
7456 /// no room beside itself that is not its body, and answers written there would duplicate
7457 /// what the content already says and count against GitHub's body limit — so `answers`
7458 /// reaches nothing here. A body that would not change is not sent at all.
7459 async fn replace_rendering(
7460 &self,
7461 id: &NativeId,
7462 kind: BoardKind,
7463 content: &str,
7464 provenance: &Value,
7465 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
7466 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
7467 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
7468 return Ok(None);
7469 };
7470 let held = item.raw_body.clone().unwrap_or_default();
7471 let mut slot = item.slot.clone();
7472 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
7473 let rewritten;
7474 let content = if let Some(assets) = assets {
7475 let uploads = self
7476 .upload_assets(item.own_repository.as_ref(), assets)
7477 .await?;
7478 rewritten =
7479 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
7480 rewritten.as_str()
7481 } else {
7482 content
7483 };
7484 let body = with_slot(&with_content(&held, content)?, &slot)?;
7485 // Checked before anything is sent, as a content write checks it.
7486 let (visible, read) = metadata_body(Some(body.clone()))?;
7487 if visible.as_deref().unwrap_or_default() != content || read != slot {
7488 return Err(SourceError::Refused {
7489 message: format!(
7490 "this content ends in what source {} reads as its own metadata slot \
7491 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
7492 as content; next: remove that trailing block from the template",
7493 self.name
7494 ),
7495 });
7496 }
7497 if body != held {
7498 self.update_content(item.content_kind, &item.id, json!({"body": body}))
7499 .await?;
7500 }
7501 item.body = visible.filter(|value| !value.is_empty());
7502 item.raw_body = Some(body);
7503 item.slot = read;
7504 self.remember_written(item, false)?;
7505 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
7506 id: id.clone(),
7507 content: Some(content.to_owned()),
7508 }))
7509 }
7510
7511 async fn set_item_field(
7512 &self,
7513 board_id: &str,
7514 item_id: &str,
7515 field_id: &str,
7516 value: Value,
7517 ) -> Result<(), SourceError> {
7518 let data = self
7519 .graphql(
7520 graphql::UPDATE_FIELD,
7521 json!({"input":{
7522 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
7523 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
7524 )
7525 .await?;
7526 let returned = data
7527 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
7528 .ok_or_else(|| SourceError::Malformed {
7529 message: "GitHub field update returned no project item".into(),
7530 })?;
7531 if required_str(returned, "id")? != item_id {
7532 return Err(SourceError::Malformed {
7533 message: "GitHub field update returned the wrong project item".into(),
7534 });
7535 }
7536 Ok(())
7537 }
7538
7539 /// GitHub accepts one value per field mutation; aliases combine those mutations in
7540 /// one request — writes in the order given, then clears. A lone write is
7541 /// [`graphql::UPDATE_FIELD`] and a lone clear [`graphql::CLEAR_FIELD`]; more than one
7542 /// request's slots hold go in further requests of the same document, in order. Every
7543 /// returned item id is checked, including optional aliases.
7544 async fn set_item_fields(
7545 &self,
7546 board: &str,
7547 item: &str,
7548 fields: &[(String, Value)],
7549 clears: &[&str],
7550 ) -> Result<(), SourceError> {
7551 match (fields, clears) {
7552 ([], []) => return Ok(()),
7553 ([(field, value)], []) => {
7554 return self.set_item_field(board, item, field, value.clone()).await;
7555 }
7556 ([], [field]) => {
7557 return self
7558 .write_priority(
7559 board,
7560 item,
7561 &PriorityWrite::Clear {
7562 field: (*field).to_owned(),
7563 },
7564 )
7565 .await;
7566 }
7567 _ => {}
7568 }
7569 // Any field of the request stands in for an input a slot leaves out, which GitHub
7570 // still requires to be well formed although it never runs it.
7571 let filler = fields
7572 .first()
7573 .map_or_else(|| clears[0], |(field, _)| field.as_str());
7574 let mut writes = fields.iter();
7575 let mut cleared = clears.iter();
7576 loop {
7577 let batch: Vec<&(String, Value)> =
7578 writes.by_ref().take(FIELD_WRITE_SLOTS.len()).collect();
7579 let batch_clears: Vec<&&str> = cleared.by_ref().take(FIELD_CLEAR_SLOTS.len()).collect();
7580 if batch.is_empty() && batch_clears.is_empty() {
7581 return Ok(());
7582 }
7583 let mut variables = serde_json::Map::new();
7584 for (index, slot) in FIELD_WRITE_SLOTS.iter().enumerate() {
7585 let input = match batch.get(index) {
7586 Some((field, value)) => {
7587 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
7588 }
7589 None => json!({"projectId":board,"itemId":item,"fieldId":filler,
7590 "value":{"text":""}}),
7591 };
7592 variables.insert(slot.variable.to_owned(), input);
7593 variables.insert(slot.include.to_owned(), json!(index < batch.len()));
7594 }
7595 for (index, slot) in FIELD_CLEAR_SLOTS.iter().enumerate() {
7596 let field = batch_clears.get(index).map_or(filler, |field| **field);
7597 variables.insert(
7598 slot.variable.to_owned(),
7599 json!({"projectId":board,"itemId":item,"fieldId":field}),
7600 );
7601 variables.insert(slot.include.to_owned(), json!(index < batch_clears.len()));
7602 }
7603 let data = self
7604 .graphql(graphql::UPDATE_FIELDS, Value::Object(variables))
7605 .await?;
7606 let sent = FIELD_WRITE_SLOTS[..batch.len()]
7607 .iter()
7608 .chain(&FIELD_CLEAR_SLOTS[..batch_clears.len()]);
7609 for slot in sent {
7610 let alias = slot.alias;
7611 let returned = data
7612 .get(alias)
7613 .and_then(|value| value.get("projectV2Item"))
7614 .ok_or_else(|| SourceError::Malformed {
7615 message: format!("GitHub field update {alias} returned no project item"),
7616 })?;
7617 if required_str(returned, "id")? != item {
7618 return Err(SourceError::Malformed {
7619 message: format!(
7620 "GitHub field update {alias} returned the wrong project item"
7621 ),
7622 });
7623 }
7624 }
7625 }
7626 }
7627
7628 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
7629 let mut after: Option<String> = None;
7630 let mut ids = Vec::new();
7631 loop {
7632 let data = self
7633 .graphql(
7634 graphql::ISSUE_DEPENDENCIES,
7635 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
7636 )
7637 .await?;
7638 let connection =
7639 data.pointer("/node/blockedBy")
7640 .ok_or_else(|| SourceError::Malformed {
7641 message: "GitHub dependency response has no blockedBy connection".into(),
7642 })?;
7643 ids.extend(
7644 connection
7645 .get("nodes")
7646 .and_then(Value::as_array)
7647 .ok_or_else(|| SourceError::Malformed {
7648 message: "GitHub dependency response nodes is not an array".into(),
7649 })?
7650 .iter()
7651 .map(|value| required_str(value, "id").map(str::to_owned))
7652 .collect::<Result<Vec<_>, _>>()?,
7653 );
7654 let next = next_cursor(connection)?;
7655 if let Some(next) = &next {
7656 validate_cursor_progress(after.as_deref(), &next.0)?;
7657 }
7658 after = next.map(|cursor| cursor.0);
7659 if after.is_none() {
7660 return Ok(ids);
7661 }
7662 }
7663 }
7664
7665 async fn dependencies(
7666 &self,
7667 id: &NativeId,
7668 near_kind: ItemKind,
7669 direction: Direction,
7670 page: &PageRequest,
7671 ) -> Result<Page<DependencyEdge>, SourceError> {
7672 validate_page(page)?;
7673 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
7674 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
7675 let recorded = recorded_offset(cursor, direction)?;
7676 // What this issue is blocked by, when a read of it by its own id in this command
7677 // already carried the whole connection — a copy reads the item it writes before it
7678 // reads its edges — and the page asked for is the whole of it, or the recorded tail
7679 // after it. Answered from that read, in the shape the dependency read answers in;
7680 // anything else is asked of GitHub.
7681 let carried = match direction {
7682 Direction::DependsOn => self
7683 .resolved_cache()?
7684 .get(id)
7685 .filter(|item| item.content_kind == ContentKind::Issue)
7686 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
7687 Direction::DependedOnBy => None,
7688 }
7689 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
7690 // Asked for even in the recorded phase, whose page reads nothing from the
7691 // connection: `__typename` is what says whether this item has a native
7692 // relationship at all, and that is what decides which far ends the reserved key is
7693 // allowed to hold.
7694 let data = match carried {
7695 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
7696 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
7697 None => {
7698 self.graphql(
7699 graphql::ISSUE_DEPENDENCIES,
7700 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
7701 "after":if recorded.is_some() {None} else {cursor}}),
7702 )
7703 .await?
7704 }
7705 };
7706 let node =
7707 data.get("node")
7708 .filter(|v| !v.is_null())
7709 .ok_or_else(|| SourceError::Refused {
7710 message: format!(
7711 "GitHub item {} was not found or does not support dependencies",
7712 id.0
7713 ),
7714 })?;
7715 let connection_name = match direction {
7716 Direction::DependsOn => "blockedBy",
7717 Direction::DependedOnBy => "blocking",
7718 };
7719 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
7720 // named natively and the reserved key may hold any far end. An issue's connections
7721 // hold issues, and this source reads them at the near item's own level.
7722 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
7723 if let Some(offset) = recorded {
7724 return Ok(recorded_page(
7725 self.recorded_edges(id, near_kind, direction, natively_names, node)
7726 .await?,
7727 offset,
7728 limit,
7729 ));
7730 }
7731 if natively_names.is_none() {
7732 return Ok(recorded_page(
7733 self.recorded_edges(id, near_kind, direction, natively_names, node)
7734 .await?,
7735 0,
7736 limit,
7737 ));
7738 }
7739 let connection = node
7740 .get(connection_name)
7741 .ok_or_else(|| SourceError::Malformed {
7742 message: "GitHub dependency response is missing its connection".into(),
7743 })?;
7744 let nodes = connection
7745 .get("nodes")
7746 .and_then(Value::as_array)
7747 .ok_or_else(|| SourceError::Malformed {
7748 message: "GitHub dependency response nodes is not an array".into(),
7749 })?;
7750 // `from` depends on `to`, always. GitHub spells the same relationship from either
7751 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
7752 // it — so the near item is `from` in one direction and `to` in the other.
7753 let items = nodes
7754 .iter()
7755 .map(|value| {
7756 let related = NativeId(required_str(value, "id")?.into());
7757 let related_kind = related_kind(value)?;
7758 let (from, to) = match direction {
7759 Direction::DependsOn => (
7760 DependencyEndpoint::from_native(id.clone(), near_kind),
7761 DependencyEndpoint::from_native(related, related_kind),
7762 ),
7763 Direction::DependedOnBy => (
7764 DependencyEndpoint::from_native(related, related_kind),
7765 DependencyEndpoint::from_native(id.clone(), near_kind),
7766 ),
7767 };
7768 Ok(DependencyEdge {
7769 from,
7770 to,
7771 kind: DependencyKind::Blocks,
7772 })
7773 })
7774 .collect::<Result<Vec<_>, SourceError>>()?;
7775 let mut next = next_cursor(connection)?;
7776 if let Some(next) = &next {
7777 validate_cursor_progress(cursor, &next.0)?;
7778 }
7779 if next.is_none()
7780 && !self
7781 .recorded_edges(id, near_kind, direction, natively_names, node)
7782 .await?
7783 .is_empty()
7784 {
7785 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
7786 }
7787 Ok(Page { items, next })
7788 }
7789
7790 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
7791 /// a far end in another source has to live: no GitHub issue relationship can name one.
7792 ///
7793 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
7794 /// source never writes one down.
7795 ///
7796 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
7797 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
7798 /// request beyond the read already made, and reading the board for them would be a
7799 /// walk of every item for one field of one. A draft has no body in that answer, because
7800 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
7801 /// listing of the board, which can be behind on the very item asked about.
7802 async fn recorded_edges(
7803 &self,
7804 id: &NativeId,
7805 near_kind: ItemKind,
7806 direction: Direction,
7807 natively_names: Option<ItemKind>,
7808 node: &Value,
7809 ) -> Result<Vec<DependencyEdge>, SourceError> {
7810 if direction != Direction::DependsOn {
7811 return Ok(Vec::new());
7812 }
7813 let slot = match node.get("body") {
7814 Some(body) if natively_names.is_some() => {
7815 metadata_body(body.as_str().map(str::to_owned))?.1
7816 }
7817 _ => {
7818 let Some(item) = self.bound_item(id).await? else {
7819 return Ok(Vec::new());
7820 };
7821 item.slot
7822 }
7823 };
7824 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
7825 .map_err(|message| SourceError::Malformed { message })
7826 }
7827
7828 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
7829 self.repository
7830 .as_ref()
7831 .ok_or_else(|| SourceError::Refused {
7832 message: format!(
7833 "source {} has no repository configured, and a GitHub Projects board has no \
7834 repository of its own to create an issue in; set repository: owner/name on \
7835 this source",
7836 self.name
7837 ),
7838 })
7839 }
7840
7841 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
7842 /// states.
7843 ///
7844 /// The fallback is demanded first, whichever arm answers: a write without a configured
7845 /// repository is refused naming the field exactly as it was before the rule existed,
7846 /// so a source that could not write before cannot write now, rather than writing for
7847 /// the one item whose own field happens to decide it.
7848 ///
7849 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
7850 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
7851 /// entry owned by someone other than the owner of the parent issue's repository —
7852 /// GitHub accepts a sub-issue from another repository of the same owner and from no
7853 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
7854 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
7855 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
7856 /// and is visible to the token is checked where its node id is resolved, still before
7857 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
7858 /// looked up in a listing of the board, which can be minutes behind an issue its own
7859 /// `projectItems` already places on it — and that read answers first from this process's
7860 /// own record, so a project created moments ago in this command answers though GitHub
7861 /// has not caught up.
7862 async fn creation_target(
7863 &self,
7864 incoming: &Incoming<'_>,
7865 ) -> Result<RepositoryTarget, SourceError> {
7866 let fallback = self.configured_repository()?;
7867 let what = |incoming: &Incoming<'_>| {
7868 format!(
7869 "{} {:?}",
7870 incoming.written.kind().describes(),
7871 incoming.title
7872 )
7873 };
7874 let parent = match incoming.parent {
7875 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
7876 SourceError::Refused {
7877 message: format!(
7878 "GitHub project issue {} was not found on the board of source {}, so {} \
7879 cannot be filed under it",
7880 parent.0,
7881 self.name,
7882 what(incoming)
7883 ),
7884 }
7885 })?),
7886 None => None,
7887 };
7888 let parents_repository = parent
7889 .as_ref()
7890 .map(|parent| {
7891 // A draft is on the board and so is found, but it has no repository to
7892 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
7893 // would refuse the task only once `createIssue` had made it.
7894 if parent.content_kind == ContentKind::DraftIssue {
7895 return Err(SourceError::Refused {
7896 message: format!(
7897 "GitHub project item {} on the board of source {} is a draft, \
7898 which cannot have sub-issues, so {} cannot be filed under it",
7899 parent.id.0,
7900 self.name,
7901 what(incoming)
7902 ),
7903 });
7904 }
7905 // An issue's repository is where a sub-issue is placed and whose owner it
7906 // is compared against, so a parent whose repository this source cannot
7907 // spell as `owner/name` — GitHub's login grammar is wider than this
7908 // source's floor — is one nothing can be filed under.
7909 parent
7910 .own_repository
7911 .as_ref()
7912 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
7913 .ok_or_else(|| SourceError::Malformed {
7914 message: format!(
7915 "GitHub project issue {} on the board of source {} is in {}, which \
7916 is not a {}/owner/name repository this source can place {} in",
7917 parent.id.0,
7918 self.name,
7919 parent
7920 .own_repository
7921 .as_ref()
7922 .map_or("no repository", Repository::as_str),
7923 RepositoryTarget::HOST,
7924 what(incoming)
7925 ),
7926 })
7927 })
7928 .transpose()?;
7929 match incoming.repositories {
7930 [named] => {
7931 let target =
7932 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
7933 message: format!(
7934 "{} names repository {}, which is not a {}/owner/name repository \
7935 source {} can create an issue in; name one that is, or name none",
7936 what(incoming),
7937 named.as_str(),
7938 RepositoryTarget::HOST,
7939 self.name
7940 ),
7941 })?;
7942 if let Some(parents) = &parents_repository
7943 && parents.owner != target.owner
7944 {
7945 return Err(SourceError::Refused {
7946 message: format!(
7947 "{} names repository {}, owned by {}, but its project's issue is in \
7948 {}, owned by {}, and GitHub files a sub-issue only in a repository \
7949 of the same owner as its parent issue; name a repository of {}, or \
7950 name none",
7951 what(incoming),
7952 target.slug(),
7953 target.owner,
7954 parents.slug(),
7955 parents.owner,
7956 parents.owner
7957 ),
7958 });
7959 }
7960 Ok(target)
7961 }
7962 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
7963 }
7964 }
7965
7966 /// The node id of the repository `incoming` is being created in, or the refusal naming
7967 /// the item and the repository the token cannot see.
7968 ///
7969 /// Resolved once per command per repository; see [`Self::repository_cache`].
7970 async fn repository_id(
7971 &self,
7972 repository: &RepositoryTarget,
7973 incoming: &Incoming<'_>,
7974 ) -> Result<String, SourceError> {
7975 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
7976 return Ok(id);
7977 }
7978 let data = self
7979 .graphql(
7980 graphql::REPOSITORY,
7981 json!({"owner":repository.owner,"name":repository.name}),
7982 )
7983 .await?;
7984 self.repository_read(&data, repository, incoming)
7985 }
7986
7987 /// The repository's node id out of an answer carrying the `repository` root, held for
7988 /// the rest of this command, or the refusal naming the item that cannot be created in it.
7989 fn repository_read(
7990 &self,
7991 data: &Value,
7992 repository: &RepositoryTarget,
7993 incoming: &Incoming<'_>,
7994 ) -> Result<String, SourceError> {
7995 let node = data
7996 .get("repository")
7997 .filter(|value| !value.is_null())
7998 .ok_or_else(|| SourceError::Refused {
7999 message: format!(
8000 "GitHub repository {} was not found or is not visible to the token, so {} \
8001 {:?} cannot be created in it",
8002 repository.slug(),
8003 incoming.written.kind().describes(),
8004 incoming.title
8005 ),
8006 })?;
8007 let id = required_str(node, "id")?.to_owned();
8008 self.repository_cache()?
8009 .insert(repository.clone(), id.clone());
8010 Ok(id)
8011 }
8012
8013 fn repository_cache(
8014 &self,
8015 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
8016 self.repository_cache
8017 .lock()
8018 .map_err(|_| SourceError::Unavailable {
8019 message: "this source's record of the destination repository was left \
8020 inconsistent by an earlier failure; next: run the command again"
8021 .into(),
8022 })
8023 }
8024
8025 /// Create or update one board item, whichever kind it is.
8026 async fn write_item(
8027 &self,
8028 incoming: &Incoming<'_>,
8029 target: Option<&NativeId>,
8030 depends_on: &[DependencyEdge],
8031 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
8032 // Refused before anything is read or written: a task or a project titled the way
8033 // this board spells a document would land as an issue this same source reads back
8034 // as a document, so the field this destination cannot carry is named rather than
8035 // written and silently reclassified.
8036 if let Written::Work(kind, _) = incoming.written
8037 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
8038 {
8039 return Err(SourceError::Refused {
8040 message: format!(
8041 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
8042 spells a document, so it would read back as one rather than as a {}; \
8043 retitle it, or copy it as a document",
8044 kind.marker(),
8045 self.name,
8046 kind.marker()
8047 ),
8048 });
8049 }
8050 // The destination is read by its own id, and whether this board holds it is decided
8051 // by that read — its own `projectItems` — rather than by whether a listing of the
8052 // board happens to include it yet. See the module documentation.
8053 let existing = match target {
8054 Some(target) => {
8055 Some(
8056 self.bound_item(target)
8057 .await?
8058 .ok_or_else(|| SourceError::Refused {
8059 message: format!("GitHub destination item {} was not found", target.0),
8060 })?,
8061 )
8062 }
8063 None => None,
8064 };
8065 let existing = existing.as_ref();
8066 // An existing issue is never moved; a new one is created where the rule says — and
8067 // knowing where is what lets the board's fields and that repository's id be read
8068 // together, before anything below needs either.
8069 let creation_target = match existing {
8070 Some(_) => None,
8071 None => {
8072 let target = self.creation_target(incoming).await?;
8073 self.creation_context(&target, incoming).await?;
8074 Some(target)
8075 }
8076 };
8077 let board = self
8078 .fields_for(
8079 existing,
8080 incoming.written.status().is_some(),
8081 incoming
8082 .priority
8083 .is_some_and(|priority| priority != Priority::None),
8084 )
8085 .await?;
8086 let status_target = incoming
8087 .written
8088 .work_status()
8089 .map(|(kind, status)| self.resolved_target(kind, status.category))
8090 .transpose()?;
8091 let column = match (incoming.written.work_status(), status_target.as_ref()) {
8092 (Some((kind, status)), Some(target)) => {
8093 self.column_for(&board.fields, kind, status.category, target)?
8094 }
8095 _ => None,
8096 };
8097 // Resolved before anything is created, for the reason the column above is: a
8098 // priority this board has no option for is refused while nothing has been written.
8099 let priority_write = match incoming.priority {
8100 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
8101 None => None,
8102 };
8103 // And for the reason the priority is: a value no projected field can hold, or a board
8104 // that cannot hold the field, is refused while nothing has been written.
8105 let (projections, projected) = self.projection_writes(
8106 &board.fields,
8107 existing.map(|item| &item.projected),
8108 incoming.metadata,
8109 None,
8110 )?;
8111 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
8112 if content_kind == ContentKind::DraftIssue {
8113 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
8114 (status_target.as_ref(), incoming.written.status())
8115 {
8116 return Err(self.closes_a_draft(status.category));
8117 }
8118 if incoming.parent.is_some() {
8119 return Err(SourceError::Refused {
8120 message: "GitHub draft items cannot be a project's sub-issue".into(),
8121 });
8122 }
8123 }
8124 match existing {
8125 Some(item) if content_kind == ContentKind::Issue => {
8126 if item.labels != incoming.labels {
8127 return Err(SourceError::Refused {
8128 message: "GitHub issue labels differ from the labels being written".into(),
8129 });
8130 }
8131 }
8132 _ => {
8133 if !incoming.labels.is_empty() {
8134 return Err(SourceError::Refused {
8135 message: "GitHub items created by this destination carry no labels".into(),
8136 });
8137 }
8138 }
8139 }
8140
8141 // The repository the issue really lives in is what the slot below is written against,
8142 // so a single entry that is where the issue is created travels as no key at all, and
8143 // the read side derives it back from the issue.
8144 let own_repository = match (existing, &creation_target) {
8145 (Some(item), _) => item.own_repository.clone(),
8146 (None, Some(target)) => Some(
8147 Repository::try_from(target.origin())
8148 .map_err(|message| SourceError::Config { message })?,
8149 ),
8150 (None, None) => None,
8151 };
8152 let (native, fallback) = self
8153 .partition_edges(
8154 incoming.written.kind(),
8155 content_kind,
8156 existing.and_then(|item| item.blocked_by.as_deref()),
8157 depends_on,
8158 )
8159 .await?;
8160 let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
8161 let content = match incoming.assets {
8162 Some(assets) => {
8163 let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
8164 let rewritten = onetaskgraph_plugin_api::serve_asset_references(
8165 incoming.content.unwrap_or_default(),
8166 &mut slot,
8167 &uploads,
8168 );
8169 incoming.content.map(|_| rewritten)
8170 }
8171 None => incoming.content.map(str::to_owned),
8172 };
8173 let body = compose_body(content.as_deref(), &slot)?;
8174 // Read before anything is created, for the reason the field below is: a value
8175 // this destination cannot store has to refuse, and refusing after `createIssue`
8176 // would leave an issue behind that nothing asked for. The engine writes a
8177 // qualified id here; a caller handing this key anything else is told so rather
8178 // than having it silently stored as no origin at all.
8179 // 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.
8180 let origin = match incoming.metadata.get(ORIGIN_KEY) {
8181 None => "",
8182 Some(Value::String(origin)) => origin.as_str(),
8183 Some(other) => {
8184 return Err(SourceError::Refused {
8185 message: format!(
8186 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
8187 is {other}"
8188 ),
8189 });
8190 }
8191 };
8192 // Resolved before anything is created: a board that cannot carry the copy origin
8193 // has to refuse the write, and refusing it after `createIssue` would leave an
8194 // issue behind that nothing asked for.
8195 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
8196 Some(field) => {
8197 if required_str(field, "__typename")? != "ProjectV2Field" {
8198 return Err(SourceError::Refused {
8199 message: format!(
8200 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
8201 ),
8202 });
8203 }
8204 Some(required_str(field, "id")?.to_owned())
8205 }
8206 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
8207 return Err(SourceError::Refused {
8208 message: format!(
8209 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
8210 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
8211 the board"
8212 ),
8213 });
8214 }
8215 None => None,
8216 };
8217
8218 let Landed {
8219 content_id,
8220 item_id,
8221 url,
8222 number,
8223 } = match existing {
8224 // Its content is written last, below, once everything else has landed.
8225 Some(item) => Landed {
8226 content_id: item.id.clone(),
8227 item_id: item.item_id.clone(),
8228 url: item.url.clone(),
8229 number: item.number,
8230 },
8231 None => {
8232 let target = creation_target
8233 .as_ref()
8234 .ok_or_else(|| SourceError::Malformed {
8235 message: "a new item was decided without a repository to create it in"
8236 .into(),
8237 })?;
8238 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
8239 .await?
8240 }
8241 };
8242
8243 // A write the store's cache expects sends only what differs from the version it expects:
8244 // a priority the item already holds there is not sent, so a person's edit of it since is
8245 // left alone. Every other write sends it, as its pinned cost says.
8246 let priority_write = priority_write.filter(|_| {
8247 !existing.is_some_and(|item| {
8248 self.expected.is_primed(&item.id)
8249 && incoming.priority.map(HeldPriority::Read) == Some(item.priority.clone())
8250 })
8251 });
8252 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
8253 let column = column
8254 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
8255 .map(|(field, option, _)| (field, option));
8256 // Creating an item here is several calls — `createIssue`, which files it on the
8257 // board, then its board fields, the parent and the dependencies — and GitHub can fail
8258 // at any of them. Everything this source can refuse *before* the first of those is
8259 // already checked above, so what is left is GitHub itself failing part way. When it
8260 // does over an item this call created, the issue is taken back: a write that
8261 // refused must not leave an item behind that nobody asked for, and one that does
8262 // makes the retry create a second.
8263 // Whether the board-field write carrying a moved origin was answered as landing whole.
8264 // When it was refused, GitHub does not say which of its fields ran before the one that
8265 // failed, so the origin may or may not have moved.
8266 let mut origin_landed = false;
8267 let landed = self
8268 .finish_write(
8269 board.id.as_str(),
8270 incoming,
8271 &content_id,
8272 &item_id,
8273 content_kind,
8274 existing,
8275 origin_field.as_deref(),
8276 origin,
8277 column,
8278 status_target.as_ref(),
8279 priority_write.as_ref(),
8280 &projections,
8281 &native,
8282 &mut origin_landed,
8283 )
8284 .await;
8285 // An existing item's title, body and state go last, in one `updateIssue`, once its board
8286 // fields and its relationships have landed: a refusal of any of those then leaves its
8287 // body — and the metadata slot inside it — exactly as it stood.
8288 let landed = match (landed, existing) {
8289 (Ok(()), Some(item)) => {
8290 self.update_existing(item, incoming, &body, status_target.as_ref())
8291 .await
8292 }
8293 (landed, _) => landed,
8294 };
8295 if let Err(error) = landed {
8296 match existing {
8297 // Best effort, and the write's own failure is what the caller is told: a
8298 // refusal naming the tidy-up would hide why the write failed at all.
8299 None => {
8300 let _ = self.delete_issue(&content_id).await;
8301 }
8302 // The origin field is the one piece of an existing item's metadata written
8303 // before its body, so a write refused after it puts it back as it was. When
8304 // that is refused too, the write's own failure is still what the caller is
8305 // told — with what it left behind added, because the item's metadata is then
8306 // not as it stood and a caller retrying has to know which key moved.
8307 Some(item) => {
8308 let before = item.origin.as_deref().unwrap_or("");
8309 if let Some(field) = origin_field.as_deref()
8310 && before != origin
8311 && let Err(restore) = self
8312 .set_item_field(
8313 board.id.as_str(),
8314 &item.item_id,
8315 field,
8316 json!({"text": before}),
8317 )
8318 .await
8319 {
8320 let left = if origin_landed {
8321 format!(
8322 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
8323 not be put back to {before:?} ({restore}), so item {} still \
8324 holds {origin:?} there",
8325 item.id.0
8326 )
8327 } else {
8328 format!(
8329 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
8330 {origin:?}, GitHub does not say whether that part of it ran, \
8331 and putting it back to {before:?} was refused ({restore}), so \
8332 item {} holds {origin:?} or {before:?} there",
8333 item.id.0
8334 )
8335 };
8336 return Err(noting(
8337 error,
8338 &format!(
8339 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
8340 run the write again"
8341 ),
8342 ));
8343 }
8344 }
8345 }
8346 return Err(error);
8347 }
8348
8349 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
8350 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
8351 self.statuses
8352 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
8353 }
8354 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
8355 self.statuses
8356 .status(kind, written_option.as_deref(), false, None)
8357 }
8358 (Some((_, status)), _) => status.clone(),
8359 (None, _) => Status {
8360 category: StatusCategory::Unknown,
8361 name: "Open".to_owned(),
8362 },
8363 };
8364
8365 // So the rest of this command reads what it just did rather than what the board
8366 // said before it. See `remember_written` for which half takes it.
8367 let remembered = Resolved {
8368 item_id,
8369 id: content_id.clone(),
8370 content_kind,
8371 kind: incoming.written.kind(),
8372 title: incoming.title.to_owned(),
8373 // The visible half of the body this write composed, split back off it the
8374 // way a read splits it — so what this record reports is what a read of the
8375 // same issue reports, rather than the person's text with the metadata slot
8376 // still on the end of it.
8377 body: metadata_body(body.clone())?.0,
8378 raw_body: body.clone(),
8379 // A document has no status of its own; what it reads back as is whatever
8380 // the issue's own state says, which is what a re-read reports.
8381 status: written_status,
8382 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
8383 priority: match incoming.priority {
8384 Some(priority) => HeldPriority::Read(priority),
8385 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
8386 item.priority.clone()
8387 }),
8388 },
8389 // What `state_input` asked for: closed for a terminal target, open for any other
8390 // status, and the issue's own state left as it was by a document write.
8391 closed: content_kind == ContentKind::Issue
8392 && match status_target.as_ref() {
8393 Some(StatusTarget::Terminal(_, _)) => true,
8394 Some(_) => false,
8395 None => existing.is_some_and(|item| item.closed),
8396 },
8397 delivers: incoming.delivers.to_vec(),
8398 delivered_by: incoming.delivered_by.to_vec(),
8399 labels: incoming.labels.to_vec(),
8400 parent: incoming.parent.cloned(),
8401 origin: (!origin.is_empty()).then(|| origin.to_owned()),
8402 projected,
8403 number,
8404 // In the update path this is the item's own url, read off `existing` where the
8405 // record above was bound, so one expression serves both halves.
8406 url,
8407 created_at: existing.and_then(|item| item.created_at),
8408 updated_at: existing.and_then(|item| item.updated_at),
8409 // What this write moved is GitHub's to say, so a record of it gives no token until
8410 // it is read again.
8411 item_updated_at: None,
8412 comment_count: None,
8413 relationship_events: None,
8414 served_at: None,
8415 own_repository,
8416 repositories: incoming.repositories.to_vec(),
8417 classification: incoming.classification,
8418 slot,
8419 board_id: Some(board.id.as_str().to_owned()),
8420 fields: board
8421 .fields
8422 .get("nodes")
8423 .and_then(Value::as_array)
8424 .cloned()
8425 .unwrap_or_default(),
8426 board_fields: Some(board.fields.clone()),
8427 // What this write left the relationship holding is known by id alone, and a
8428 // later read of its edges needs each far end's kind, so it reads them again.
8429 blocked_by: None,
8430 };
8431 self.remember_written(remembered, existing.is_none())?;
8432 Ok(onetaskgraph_plugin_api::AssetsWritten {
8433 id: content_id,
8434 content,
8435 })
8436 }
8437
8438 /// Everything a write does after the item exists: its board fields, its parent, and
8439 /// its dependencies.
8440 ///
8441 /// Split out of `write_item` so there is one place a failure past the point of no
8442 /// return is caught, rather than a tidy-up repeated at each `?` above.
8443 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
8444 // so there is one place a failure past the point of no return is caught, and its
8445 // arguments are exactly the values that tail already had in scope. Bundling them into a
8446 // struct would describe no concept — it would be "the arguments of this function" — and
8447 // would put the whole of `write_item`'s locals behind one more indirection.
8448 #[allow(clippy::too_many_arguments)]
8449 async fn finish_write(
8450 &self,
8451 board_id: &str,
8452 incoming: &Incoming<'_>,
8453 content_id: &NativeId,
8454 item_id: &str,
8455 content_kind: ContentKind,
8456 existing: Option<&Resolved>,
8457 origin_field: Option<&str>,
8458 origin: &str,
8459 column: Option<(String, String)>,
8460 status_target: Option<&StatusTarget>,
8461 priority: Option<&PriorityWrite>,
8462 projections: &[ProjectionWrite],
8463 native: &[String],
8464 origin_landed: &mut bool,
8465 ) -> Result<(), SourceError> {
8466 let mut fields = Vec::new();
8467 if let Some(field_id) = origin_field
8468 && existing.map_or(!origin.is_empty(), |item| {
8469 item.origin.as_deref().unwrap_or("") != origin
8470 })
8471 {
8472 fields.push((field_id.to_owned(), json!({"text":origin})));
8473 }
8474 if let Some((field_id, option_id)) = column {
8475 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
8476 }
8477 let mut clears = Vec::new();
8478 match priority {
8479 Some(PriorityWrite::Select { field, option }) => {
8480 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
8481 }
8482 Some(PriorityWrite::Clear { field }) => clears.push(field.as_str()),
8483 None => {}
8484 }
8485 for projection in projections {
8486 projection.join(&mut fields, &mut clears);
8487 }
8488 self.set_item_fields(board_id, item_id, &fields, &clears)
8489 .await?;
8490 *origin_landed = true;
8491
8492 // An existing issue closes in the `updateIssue` its write ends with; one created just
8493 // now closes here, once its option is selected.
8494 if existing.is_none()
8495 && content_kind == ContentKind::Issue
8496 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
8497 {
8498 self.update_content(
8499 ContentKind::Issue,
8500 content_id,
8501 json!({"stateInput":state_input(status_target)}),
8502 )
8503 .await?;
8504 }
8505
8506 if content_kind == ContentKind::Issue {
8507 self.reparent(
8508 existing.and_then(|item| item.parent.clone()),
8509 content_id,
8510 incoming.parent,
8511 )
8512 .await?;
8513 // A document takes part in no dependency graph, so writing one neither reads
8514 // nor changes the issue's own `blockedBy` relationships. Reconciling them
8515 // against the empty list a document write carries would *delete* whatever
8516 // relationships a person had made on that issue, which is a write nobody
8517 // asked for.
8518 if incoming.written.kind() != BoardKind::Document {
8519 let issue = match existing {
8520 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
8521 None => Issue::Created,
8522 };
8523 self.reconcile_blocked_by(content_id, native, issue).await?;
8524 }
8525 }
8526 Ok(())
8527 }
8528
8529 /// Delete one issue, which takes its board item with it.
8530 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
8531 let data = self
8532 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
8533 .await?;
8534 data.pointer("/deleteIssue/repository")
8535 .filter(|value| !value.is_null())
8536 .ok_or_else(|| SourceError::Malformed {
8537 message: "GitHub issue deletion returned no repository".into(),
8538 })?;
8539 self.forget(id)?;
8540 Ok(())
8541 }
8542
8543 /// Remove one item this copy created, so a copy that could not finish leaves the board
8544 /// as it found it.
8545 ///
8546 /// Deleting the issue takes its board item with it, so there is no second mutation to
8547 /// keep in step. An id the board does not hold is not an error: the item is already
8548 /// gone, which is the state this asks for. Which that is, is decided by reading the item
8549 /// by its own id — a listing of the board can still be missing an item it holds, and
8550 /// reading that as *already gone* would leave behind the very item this was asked to
8551 /// take back.
8552 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
8553 let Some(item) = self.bound_item(id).await? else {
8554 return Ok(());
8555 };
8556 if item.content_kind == ContentKind::DraftIssue {
8557 return Err(SourceError::Refused {
8558 message: format!(
8559 "GitHub item {} is a draft, and this source removes an item by deleting \
8560 its issue; next: remove it from the board by hand",
8561 id.0
8562 ),
8563 });
8564 }
8565 let data = self
8566 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
8567 .await?;
8568 data.pointer("/deleteIssue/repository")
8569 .filter(|value| !value.is_null())
8570 .ok_or_else(|| SourceError::Malformed {
8571 message: "GitHub issue deletion returned no repository".into(),
8572 })?;
8573 self.forget(id)?;
8574 Ok(())
8575 }
8576
8577 /// The issue a comment call on `task` is about, or `None` when this board holds no such
8578 /// task.
8579 ///
8580 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
8581 /// read of the task cannot disagree about which ids name one: a project or a document of
8582 /// this board is not a task here either.
8583 ///
8584 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
8585 /// issues and a draft is not one. It is refused rather than answered with an empty page,
8586 /// which would read as a task nobody has commented on yet.
8587 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
8588 let cached = self.resolved_cache()?.get(task).cloned();
8589 let Some(item) = (match cached {
8590 Some(item) => Some(item),
8591 None => self.item_by_id(task).await?,
8592 })
8593 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
8594 return Ok(None);
8595 };
8596 if item.content_kind == ContentKind::DraftIssue {
8597 return Err(self.draft_has_no_comments(task));
8598 }
8599 Ok(Some(item.id))
8600 }
8601
8602 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
8603 /// issues, and a draft is not one.
8604 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
8605 SourceError::Refused {
8606 message: format!(
8607 "task {} of source {} is a draft item on the board, and GitHub keeps \
8608 comments on issues alone, so a draft has none to read or write; next: \
8609 convert the draft to an issue on the board, then comment on the issue it \
8610 becomes",
8611 task.0, self.name
8612 ),
8613 }
8614 }
8615
8616 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
8617 /// request — or `None` when this board holds no task by that id.
8618 ///
8619 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
8620 /// is answered with the draft and the refusal, at the price of the draft's own read.
8621 async fn issue_detail(
8622 &self,
8623 id: &NativeId,
8624 page: &PageRequest,
8625 ) -> Result<Option<TaskDetailRead>, SourceError> {
8626 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
8627 let asked = self
8628 .graphql(
8629 graphql::ISSUE_DETAIL,
8630 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
8631 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
8632 "duplicates":true}),
8633 )
8634 .await;
8635 let data = match asked {
8636 Ok(data) => data,
8637 Err(error) if unresolvable_node(&error) => return Ok(None),
8638 Err(error) => return Err(error),
8639 };
8640 // `node` is null for an id that names nothing, and absent only from an answer this
8641 // source cannot read — never the same thing.
8642 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
8643 message: format!("GitHub answered the read of {} with no node", id.0),
8644 })?;
8645 self.detail_of(id, node, true, after).await
8646 }
8647
8648 /// Several tasks, each with the first page of its comments when `comments` is set, read
8649 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
8650 /// order.
8651 ///
8652 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
8653 /// one item at a time, so that id is answered as missing and the others as themselves; any
8654 /// other refusal is every id of that batch's answer.
8655 async fn issue_details(
8656 &self,
8657 ids: &[NativeId],
8658 comments: Option<&PageRequest>,
8659 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
8660 let mut read = Vec::with_capacity(ids.len());
8661 for batch in ids.chunks(DETAIL_BATCH) {
8662 match self
8663 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
8664 .await
8665 {
8666 Ok(data) => {
8667 for (slot, id) in batch.iter().enumerate() {
8668 // Every alias asked for is answered, null for an id naming nothing;
8669 // one missing is an answer this source cannot read.
8670 let read_one = match data.get(format!("i{slot}")) {
8671 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
8672 None => Err(SourceError::Malformed {
8673 message: format!(
8674 "GitHub answered a batch read with no item for {}",
8675 id.0
8676 ),
8677 }),
8678 };
8679 read.push(read_one);
8680 }
8681 }
8682 Err(error) if unresolvable_node(&error) => {
8683 for id in batch {
8684 read.push(match comments {
8685 Some(page) => self.issue_detail(id, page).await,
8686 None => self.task_read(id).await,
8687 });
8688 }
8689 }
8690 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
8691 }
8692 }
8693 read
8694 }
8695
8696 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
8697 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
8698 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
8699 task,
8700 comments: None,
8701 }))
8702 }
8703
8704 /// What one node a detail read reached says: the task this board holds by `id`, with the
8705 /// page of comments the node carries when `commented` — or `None` for a node that is no
8706 /// task of this board.
8707 ///
8708 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
8709 /// and an item this process created answers from this process's own record, which a node
8710 /// read taken moments after the write can still be behind.
8711 async fn detail_of(
8712 &self,
8713 id: &NativeId,
8714 node: &Value,
8715 commented: bool,
8716 after: Option<&str>,
8717 ) -> Result<Option<TaskDetailRead>, SourceError> {
8718 if node.is_null() {
8719 return Ok(None);
8720 }
8721 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
8722 // An issue answered under one id is that id's, or the answer is not one this source
8723 // can report: reporting another issue's task and comments under the qualified id asked
8724 // for would be the one wrong answer here. A draft's own read checks the same.
8725 if !draft
8726 && optional_str(node, "__typename")? == Some("Issue")
8727 && required_str(node, "id")? != id.0
8728 {
8729 return Err(SourceError::Malformed {
8730 message: format!(
8731 "GitHub answered the read of {} with issue {}",
8732 id.0,
8733 required_str(node, "id")?
8734 ),
8735 });
8736 }
8737 let item = if draft {
8738 self.draft_by_id(id).await?
8739 } else {
8740 self.resolve_issue(node).await?
8741 };
8742 // A read that carried the whole of what the issue is blocked by — a copy's — is kept
8743 // for this command, so the dependency read the copy makes next is answered from it.
8744 if let Some(read) = item.as_ref().filter(|item| item.blocked_by.is_some()) {
8745 self.resolved_cache()?.insert(read.id.clone(), read.clone());
8746 }
8747 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
8748 return Ok(None);
8749 };
8750 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
8751 let task = own.unwrap_or(item).task()?;
8752 let comments = match (commented, draft) {
8753 (false, _) => None,
8754 (true, true) => Some(Err(self.draft_has_no_comments(id))),
8755 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
8756 };
8757 if commented && !draft && after.is_none() {
8758 self.expected.learn_comments(id, node);
8759 }
8760 Ok(Some(TaskDetailRead { task, comments }))
8761 }
8762
8763 /// Whether the comment `comment` is one of `issue`'s own.
8764 ///
8765 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
8766 /// comment's id and nothing else: a comment id given against the wrong task would
8767 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
8768 /// names something that is not an issue comment, is a comment this task does not have —
8769 /// which is what GitHub refusing to resolve it means too.
8770 async fn comment_is_on(
8771 &self,
8772 issue: &NativeId,
8773 comment: &NativeId,
8774 ) -> Result<bool, SourceError> {
8775 Ok(self.comment_on(issue, comment).await?.is_some())
8776 }
8777
8778 /// The body the comment `comment` holds, exactly as GitHub holds it, when it is one of
8779 /// `issue`'s own — the read [`Self::comment_is_on`] makes, answering with what it selected.
8780 async fn comment_on(
8781 &self,
8782 issue: &NativeId,
8783 comment: &NativeId,
8784 ) -> Result<Option<String>, SourceError> {
8785 // A write the store's cache expects knows which comments its issue held, and their
8786 // bodies, at the version it expects: what this read would answer.
8787 if let Some(primed) = self.expected.primed_comment(issue, comment) {
8788 return Ok(primed);
8789 }
8790 let asked = self
8791 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
8792 .await;
8793 let data = match asked {
8794 Ok(data) => data,
8795 Err(error) if unresolvable_node(&error) => return Ok(None),
8796 Err(error) => return Err(error),
8797 };
8798 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
8799 return Ok(None);
8800 };
8801 if optional_str(node, "__typename")? != Some("IssueComment") {
8802 return Ok(None);
8803 }
8804 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
8805 message: format!("GitHub issue comment {} names no issue", comment.0),
8806 })?;
8807 if required_str(on, "id")? != issue.0 {
8808 return Ok(None);
8809 }
8810 Ok(Some(required_str(node, "body")?.to_owned()))
8811 }
8812
8813 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
8814 async fn partition_edges(
8815 &self,
8816 near_kind: BoardKind,
8817 near_content: ContentKind,
8818 carried: Option<&[Value]>,
8819 depends_on: &[DependencyEdge],
8820 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
8821 let mut native = Vec::new();
8822 let mut fallback = Vec::new();
8823 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
8824 .iter()
8825 .map(|edge| {
8826 let same_source = edge
8827 .to
8828 .source()
8829 .is_none_or(|source| source == self.name.as_str());
8830 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
8831 // `DependencyEndpoint::source` both read it that way — and a native id may hold
8832 // colons of its own, so the far end is everything after that one separator.
8833 // Splitting at the last would truncate `work:urn:task:7` to `7`.
8834 let far_id = if edge.to.is_qualified() {
8835 edge.to
8836 .id()
8837 .split_once(':')
8838 .map_or(edge.to.id(), |(_, native)| native)
8839 } else {
8840 edge.to.id()
8841 };
8842 // One that already blocks the near issue was answered by that issue's own
8843 // read, which carried each of its blockers' kinds — an issue every one — so it
8844 // is not read again.
8845 let blocking = carried.and_then(|nodes| {
8846 nodes
8847 .iter()
8848 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
8849 });
8850 (edge, far_id, same_source, blocking)
8851 })
8852 .collect();
8853 // Every other same-source far end is read by its own id, exactly as the item it is a
8854 // far end of is: whether this board holds it is that read's answer, never a listing's.
8855 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
8856 let mut unread: Vec<NativeId> = Vec::new();
8857 for (_, far_id, same_source, blocking) in &far_ends {
8858 let id = NativeId((*far_id).to_owned());
8859 if *same_source && blocking.is_none() && !unread.contains(&id) {
8860 unread.push(id);
8861 }
8862 }
8863 let read: BTreeMap<NativeId, Option<Resolved>> = unread
8864 .iter()
8865 .cloned()
8866 .zip(self.items_by_ids(&unread).await?)
8867 .collect();
8868 for (edge, far_id, same_source, blocking) in far_ends {
8869 let far = match (same_source, blocking) {
8870 (false, _) => None,
8871 (true, Some(node)) => Some(FarEnd {
8872 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
8873 BoardKind::Document
8874 } else {
8875 BoardKind::Work(related_kind(node)?)
8876 },
8877 content_kind: ContentKind::Issue,
8878 }),
8879 (true, None) => {
8880 let read = read
8881 .get(&NativeId(far_id.to_owned()))
8882 .cloned()
8883 .flatten()
8884 .ok_or_else(|| SourceError::Refused {
8885 message: format!("GitHub dependency item {far_id} was not found"),
8886 })?;
8887 Some(FarEnd {
8888 kind: read.kind,
8889 content_kind: read.content_kind,
8890 })
8891 }
8892 };
8893 let far = far.as_ref();
8894 // The caller says which kind the far end is, and this board holds the far end
8895 // itself, so a disagreement is settled here rather than stored: recorded, the
8896 // wrong kind would read back as a cross-level edge that never existed; written
8897 // natively, it would name a relationship of a different level than the caller
8898 // asked for.
8899 //
8900 // A far end this board holds as a *document* fails the same comparison and is
8901 // refused by the same sentence: `ItemKind` has no document variant because
8902 // nothing may point at one, so no caller can name it correctly and the refusal
8903 // is the only honest answer.
8904 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
8905 return Err(SourceError::Refused {
8906 message: format!(
8907 "GitHub dependency item {far_id} is a {} of this board, and this item \
8908 names it as a {}; record the kind it is",
8909 disagreeing.kind.describes(),
8910 edge.to.kind.marker()
8911 ),
8912 });
8913 }
8914 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
8915 // however the far end is spelled — and one classified native here would be
8916 // written nowhere at all, because a draft's native reconciliation never runs.
8917 let native_here = near_content == ContentKind::Issue
8918 && far.is_some_and(|far| {
8919 far.content_kind == ContentKind::Issue
8920 && BoardKind::Work(edge.to.kind) == near_kind
8921 });
8922 if native_here {
8923 native.push(far_id.to_owned());
8924 } else {
8925 fallback.push(edge.clone());
8926 }
8927 }
8928 Ok((native, fallback))
8929 }
8930
8931 async fn update_existing(
8932 &self,
8933 item: &Resolved,
8934 incoming: &Incoming<'_>,
8935 body: &Option<String>,
8936 status_target: Option<&StatusTarget>,
8937 ) -> Result<(), SourceError> {
8938 let title = incoming.written_title();
8939 // A terminal status closes the issue here, in the same mutation as its body: its board
8940 // option was selected before this, so a close never lands on an item whose board cannot
8941 // show it.
8942 let mut fields = match item.content_kind {
8943 ContentKind::DraftIssue => json!({"title":title,"body":body}),
8944 ContentKind::Issue => json!({"title":title,"body":body,
8945 "stateInput":state_input(status_target)}),
8946 };
8947 // A write the store's cache expects sends only the members whose value differs from the
8948 // version it expects, and nothing at all when none does.
8949 if self.expected.is_primed(&item.id)
8950 && let Some(members) = fields.as_object_mut()
8951 {
8952 if members.get("title").and_then(Value::as_str) == Some(item.title.as_str()) {
8953 members.remove("title");
8954 }
8955 if members.get("body").and_then(Value::as_str)
8956 == Some(item.raw_body.as_deref().unwrap_or_default())
8957 {
8958 members.remove("body");
8959 }
8960 let terminal = matches!(status_target, Some(StatusTarget::Terminal(_, _)));
8961 if members.contains_key("stateInput") && terminal == item.closed {
8962 members.remove("stateInput");
8963 }
8964 if members.is_empty() {
8965 return Ok(());
8966 }
8967 }
8968 self.update_content(item.content_kind, &item.id, fields)
8969 .await
8970 }
8971
8972 /// Update one board item's content with exactly `fields` beside its id, through the
8973 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
8974 /// a draft.
8975 ///
8976 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
8977 /// is what lets a narrow write carry the one thing it changes and nothing else.
8978 async fn update_content(
8979 &self,
8980 kind: ContentKind,
8981 id: &NativeId,
8982 fields: Value,
8983 ) -> Result<(), SourceError> {
8984 let (operation, id_key, pointer) = match kind {
8985 ContentKind::DraftIssue => (
8986 graphql::UPDATE_DRAFT,
8987 "draftIssueId",
8988 "/updateProjectV2DraftIssue/draftIssue",
8989 ),
8990 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
8991 };
8992 let mut input = fields;
8993 input[id_key] = json!(id.0);
8994 let data = self.graphql(operation, json!({"input":input})).await?;
8995 let returned = data
8996 .pointer(pointer)
8997 .ok_or_else(|| SourceError::Malformed {
8998 message: "GitHub item update returned no item".into(),
8999 })?;
9000 if required_str(returned, "id")? != id.0 {
9001 return Err(SourceError::Malformed {
9002 message: "GitHub item update returned the wrong item".into(),
9003 });
9004 }
9005 Ok(())
9006 }
9007
9008 /// Creates one issue, files it on the board, and reports what a read of it would say:
9009 /// its content id, its board item id, and the web address GitHub gave it.
9010 ///
9011 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
9012 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
9013 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
9014 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
9015 /// "Content already exists in this project". A terminal status is not written here:
9016 /// `finish_write` selects its option first and closes the issue after, so a close never
9017 /// lands on an item whose board cannot show it.
9018 ///
9019 /// The address and the number come back here because this is the only place either is
9020 /// known before GitHub's own board read catches up — an item this run created answers
9021 /// the reads that follow it out of the record below, and one remembered without them
9022 /// would report no location and no key for the rest of the run.
9023 async fn create_and_file_issue(
9024 &self,
9025 board_id: &str,
9026 repository: &RepositoryTarget,
9027 incoming: &Incoming<'_>,
9028 body: &Option<String>,
9029 ) -> Result<Landed, SourceError> {
9030 let repository_id = self.repository_id(repository, incoming).await?;
9031 let data = self
9032 .graphql(
9033 graphql::CREATE_ISSUE,
9034 json!({"input":{
9035 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
9036 }}),
9037 )
9038 .await?;
9039 let created = data
9040 .pointer("/createIssue/issue")
9041 .filter(|value| !value.is_null())
9042 .ok_or_else(|| SourceError::Malformed {
9043 message: "GitHub issue creation returned no issue".into(),
9044 })?;
9045 let content_id = NativeId(required_str(created, "id")?.to_owned());
9046 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
9047 // a response without it is not worth failing a landed write over — the item simply
9048 // reports no location until the board read catches up, which is what it did before.
9049 let url = optional_str(created, "url")?.map(str::to_owned);
9050 // The issue exists from here on, so an unreadable number and a refused board
9051 // filing below each try, best effort, to take it back: an issue in the repository
9052 // that is on no board is an item nobody asked for and nothing here would find again.
9053 //
9054 // Its number is optional on the same terms its address is — a landed write is not
9055 // worth failing over a member that came back missing, and such an item reports no
9056 // handle until a board read catches up. A number that is *present* and is not an
9057 // unsigned integer is still a response this source cannot read.
9058 let number = match created_issue_number(created) {
9059 Ok(number) => number,
9060 Err(error) => {
9061 let _ = self.delete_issue(&content_id).await;
9062 return Err(error);
9063 }
9064 };
9065 let added = match self
9066 .graphql(
9067 graphql::ADD_TO_BOARD,
9068 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
9069 )
9070 .await
9071 {
9072 Ok(added) => added,
9073 Err(error) => {
9074 let _ = self.delete_issue(&content_id).await;
9075 return Err(error);
9076 }
9077 };
9078 let item = added
9079 .pointer("/addProjectV2ItemById/item")
9080 .filter(|value| !value.is_null())
9081 .ok_or_else(|| SourceError::Malformed {
9082 message: "GitHub board addition returned no project item".into(),
9083 })?;
9084 Ok(Landed {
9085 content_id,
9086 item_id: required_str(item, "id")?.to_owned(),
9087 url,
9088 number,
9089 })
9090 }
9091
9092 /// Move one issue under the project it now belongs to, or out of the one it left.
9093 async fn reparent(
9094 &self,
9095 held: Option<NativeId>,
9096 child: &NativeId,
9097 wanted: Option<&NativeId>,
9098 ) -> Result<(), SourceError> {
9099 if held.as_ref() == wanted {
9100 return Ok(());
9101 }
9102 if let Some(held) = &held {
9103 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
9104 .await?;
9105 }
9106 if let Some(wanted) = wanted {
9107 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
9108 .await?;
9109 }
9110 Ok(())
9111 }
9112
9113 async fn sub_issue(
9114 &self,
9115 operation: &str,
9116 parent: &NativeId,
9117 child: &NativeId,
9118 root: &str,
9119 ) -> Result<(), SourceError> {
9120 let data = self
9121 .graphql(
9122 operation,
9123 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
9124 )
9125 .await?;
9126 let issue =
9127 data.pointer(&format!("/{root}/issue"))
9128 .ok_or_else(|| SourceError::Malformed {
9129 message: "GitHub sub-issue update returned no issue".into(),
9130 })?;
9131 let sub =
9132 data.pointer(&format!("/{root}/subIssue"))
9133 .ok_or_else(|| SourceError::Malformed {
9134 message: "GitHub sub-issue update returned no sub-issue".into(),
9135 })?;
9136 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
9137 return Err(SourceError::Malformed {
9138 message: "GitHub sub-issue update returned the wrong issues".into(),
9139 });
9140 }
9141 Ok(())
9142 }
9143
9144 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
9145 /// whether there was one.
9146 ///
9147 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
9148 /// relationships are not read: there is nothing a read of them could find.
9149 async fn reconcile_blocked_by(
9150 &self,
9151 content_id: &NativeId,
9152 native: &[String],
9153 issue: Issue<'_>,
9154 ) -> Result<bool, SourceError> {
9155 let current = match issue {
9156 Issue::Created => Vec::new(),
9157 Issue::Existing(Some(held)) => held
9158 .iter()
9159 .map(|far| required_str(far, "id").map(str::to_owned))
9160 .collect::<Result<Vec<_>, _>>()?,
9161 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
9162 };
9163 let mut changed = false;
9164 for (operation, far_id) in current
9165 .iter()
9166 .filter(|id| !native.contains(id))
9167 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
9168 .chain(
9169 native
9170 .iter()
9171 .filter(|id| !current.contains(id))
9172 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
9173 )
9174 {
9175 let data = self
9176 .graphql(
9177 operation,
9178 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
9179 )
9180 .await?;
9181 let root = if operation == graphql::ADD_BLOCKED_BY {
9182 "addBlockedBy"
9183 } else {
9184 "removeBlockedBy"
9185 };
9186 let issue =
9187 data.pointer(&format!("/{root}/issue"))
9188 .ok_or_else(|| SourceError::Malformed {
9189 message: "GitHub dependency update returned no issue".into(),
9190 })?;
9191 let blocker = data
9192 .pointer(&format!("/{root}/blockingIssue"))
9193 .ok_or_else(|| SourceError::Malformed {
9194 message: "GitHub dependency update returned no blocking issue".into(),
9195 })?;
9196 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
9197 {
9198 return Err(SourceError::Malformed {
9199 message: "GitHub dependency update returned the wrong issues".into(),
9200 });
9201 }
9202 changed = true;
9203 }
9204 Ok(changed)
9205 }
9206}
9207
9208/// What a write needs to know of one far end it names: which kind of item it is, and whether
9209/// it is an issue a native relationship can name.
9210struct FarEnd {
9211 kind: BoardKind,
9212 content_kind: ContentKind,
9213}
9214
9215/// Whether the issue one write reconciles was created by that write or was already there.
9216#[derive(Clone, Copy, PartialEq, Eq)]
9217enum Issue<'a> {
9218 /// Created by this write, so it holds no relationships yet.
9219 Created,
9220 /// On the board before this write, holding whatever relationships it holds — the far
9221 /// ends of its whole `blockedBy`, when the read that reached it carried them.
9222 Existing(Option<&'a [Value]>),
9223}
9224
9225/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
9226enum Reached {
9227 /// An issue this board holds, resolved into everything this source reports about it.
9228 Held(Box<Resolved>),
9229 /// Nothing this board holds: no such node, or a node on some other board.
9230 Nothing,
9231 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
9232 /// again by [`GitHubProjectsSource::draft_by_id`].
9233 Draft,
9234}
9235
9236/// What GitHub says when a string is not a node id it can resolve.
9237///
9238/// Matched because it is the ordinary answer to a project selector naming a project by its
9239/// *name*, and reporting that as a failure would make naming one impossible. It is read
9240/// off the refusal GitHub sent, never guessed from the shape of the string: this source
9241/// does not define the syntax of a GitHub node id and would be wrong about it.
9242const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
9243
9244/// `error` with `note` added to the end of what it says, its kind and every other member
9245/// unchanged — so a caller still branches on the failure that happened, and reads beside it
9246/// what that failure left behind.
9247fn noting(error: SourceError, note: &str) -> SourceError {
9248 match error {
9249 SourceError::Config { message } => SourceError::Config {
9250 message: message + note,
9251 },
9252 SourceError::Auth { message } => SourceError::Auth {
9253 message: message + note,
9254 },
9255 SourceError::Refused { message } => SourceError::Refused {
9256 message: message + note,
9257 },
9258 SourceError::RateLimited {
9259 retry_after_seconds,
9260 message,
9261 } => SourceError::RateLimited {
9262 retry_after_seconds,
9263 message: Some(message.unwrap_or_default() + note),
9264 },
9265 SourceError::Unavailable { message } => SourceError::Unavailable {
9266 message: message + note,
9267 },
9268 SourceError::Malformed { message } => SourceError::Malformed {
9269 message: message + note,
9270 },
9271 }
9272}
9273
9274/// The variables of one [`graphql::ISSUE_COPY_DETAILS`] request, as [`detail_batch`] binds its
9275/// own: a slot `batch` has no id for is bound to its last id, read again at no added price.
9276fn copy_detail_batch(batch: &[NativeId], comments: &PageRequest) -> Value {
9277 let mut variables = serde_json::Map::new();
9278 for slot in 0..COPY_DETAIL_BATCH {
9279 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9280 variables.insert(format!("id{slot}"), json!(id));
9281 }
9282 variables.insert("first".to_owned(), json!(comments.limit.min(MAX_PAGE_SIZE)));
9283 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9284 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9285 variables.insert("duplicates".to_owned(), json!(true));
9286 Value::Object(variables)
9287}
9288
9289/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
9290/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
9291/// for them.
9292///
9293/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
9294/// is read again at no added price.
9295fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
9296 let mut variables = serde_json::Map::new();
9297 for slot in 0..DETAIL_BATCH {
9298 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
9299 variables.insert(format!("id{slot}"), json!(id));
9300 }
9301 variables.insert(
9302 "first".to_owned(),
9303 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
9304 );
9305 variables.insert("comments".to_owned(), json!(comments.is_some()));
9306 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
9307 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
9308 variables.insert("duplicates".to_owned(), json!(true));
9309 Value::Object(variables)
9310}
9311
9312/// Whether this refusal is GitHub saying the id names no node at all.
9313fn unresolvable_node(error: &SourceError) -> bool {
9314 matches!(error, SourceError::Refused { message }
9315 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
9316}
9317
9318/// One project name, as a search qualifier which filters on it at the server.
9319///
9320/// Quoted so the whole title is one phrase rather than a bag of words, with the two
9321/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
9322/// the way it documents. A title matched here is still compared for equality afterwards:
9323/// the qualifier narrows what the server sends, and this source decides what it names.
9324fn title_qualifier(name: &str) -> String {
9325 format!("in:title {}", quoted(name))
9326}
9327
9328/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
9329/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
9330/// it documents — so a value holding a qualifier's spelling is searched for rather than
9331/// obeyed.
9332fn quoted(value: &str) -> String {
9333 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
9334 format!("\"{escaped}\"")
9335}
9336
9337/// The search qualifier for the issues updated at or after `since`.
9338///
9339/// Written to the second, rounded down, which can only widen what the search returns.
9340fn updated_qualifier(since: DateTime<Utc>) -> String {
9341 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
9342}
9343
9344/// The search terms that narrow a board-scoped issue search to a task query's text and
9345/// metadata predicates, or `None` when it carries neither.
9346///
9347/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
9348/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
9349/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
9350/// matches each in any field the `in:` qualifier names, so a query naming a title search and
9351/// a metadata value searches both fields for both — wider than asked, never narrower, and
9352/// every candidate is confirmed in process afterwards.
9353///
9354/// **This narrows a text search, and that is this source's declared semantics.** GitHub
9355/// matches whole tokens where a substring rule would match inside a word, so an item holding
9356/// the text only inside a longer word is not returned. A text of nothing but whitespace
9357/// matches every item, so it narrows nothing and is not sent.
9358fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
9359 let text = query
9360 .text
9361 .as_ref()
9362 .filter(|text| !text.terms.trim().is_empty());
9363 if text.is_none() && query.metadata.is_empty() {
9364 return None;
9365 }
9366 let (title, body) = match text.map(|text| text.fields) {
9367 None => (false, true),
9368 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
9369 Some(TextFields::Content) => (false, true),
9370 Some(TextFields::TitleOrContent) => (true, true),
9371 };
9372 let fields = match (title, body) {
9373 (true, true) => "in:title,body",
9374 (true, false) => "in:title",
9375 _ => "in:body",
9376 };
9377 let phrases = text
9378 .map(|text| text.terms.clone())
9379 .into_iter()
9380 .chain(
9381 query
9382 .metadata
9383 .iter()
9384 .map(|wanted| as_stored(wanted.value())),
9385 )
9386 .map(|phrase| quoted(&phrase))
9387 .collect::<Vec<_>>();
9388 Some(format!("{fields} {}", phrases.join(" ")))
9389}
9390
9391/// The search terms that narrow a board-scoped issue search to a project or document query's
9392/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
9393/// carrying that text alone is sent as by [`narrowing_qualifiers`].
9394fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
9395 narrowing_qualifiers(&TaskQuery {
9396 text: text.cloned(),
9397 ..TaskQuery::default()
9398 })
9399}
9400
9401/// Refuses a project or document query's text GitHub's issue search cannot find, before
9402/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
9403/// query's.
9404fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
9405 refuse_unsearchable(&TaskQuery {
9406 text: text.cloned(),
9407 ..TaskQuery::default()
9408 })
9409}
9410
9411/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
9412/// before anything is asked of GitHub.
9413///
9414/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
9415/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
9416/// left out, the search is every issue of the board. So this source says it cannot answer
9417/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
9418/// nothing GitHub could search for, and keeps the board read it always had.
9419fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
9420 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
9421 letter or digit with a bounded query";
9422 if let Some(text) = &query.text
9423 && !text.terms.trim().is_empty()
9424 && !has_words(&text.terms)
9425 {
9426 return Err(SourceError::Refused {
9427 message: format!(
9428 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
9429 text.terms
9430 ),
9431 });
9432 }
9433 if let Some(wanted) = query
9434 .metadata
9435 .iter()
9436 .find(|wanted| !has_words(wanted.value()))
9437 {
9438 return Err(SourceError::Refused {
9439 message: format!(
9440 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
9441 wanted.value(),
9442 std::iter::once(wanted.key())
9443 .chain(wanted.path().iter().map(String::as_str))
9444 .collect::<Vec<_>>()
9445 .join("/"),
9446 ),
9447 });
9448 }
9449 Ok(())
9450}
9451
9452/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
9453fn has_words(phrase: &str) -> bool {
9454 phrase.chars().any(char::is_alphanumeric)
9455}
9456
9457/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
9458///
9459/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
9460/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
9461/// which GitHub's word match would read as different words.
9462fn as_stored(value: &str) -> String {
9463 let encoded = Value::String(value.to_owned()).to_string();
9464 encoded[1..encoded.len() - 1].to_owned()
9465}
9466
9467/// The one narrower question a task query carrying a text, metadata or origin predicate is
9468/// sent as.
9469enum Narrowing {
9470 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
9471 Origin(String),
9472 /// The board-scoped issue search narrowed by these qualifiers.
9473 Search(String),
9474}
9475
9476impl Narrowing {
9477 /// What this question is remembered under for the length of one command.
9478 fn key(&self) -> String {
9479 match self {
9480 Self::Origin(origin) => format!("origin {origin}"),
9481 Self::Search(also) => format!("search {also}"),
9482 }
9483 }
9484}
9485
9486/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
9487enum Resumed {
9488 /// It reported another page, which starts after this cursor.
9489 More(String),
9490 /// It has ended. Sending this cursor again — the page's own end when it had one, and
9491 /// otherwise the cursor it was reached from — answers an empty page, so the one document
9492 /// can go on walking the other connection.
9493 Ended(Option<String>),
9494}
9495
9496impl Resumed {
9497 /// Whether the connection has another page.
9498 const fn has_more(&self) -> bool {
9499 matches!(self, Self::More(_))
9500 }
9501
9502 /// The cursor to send this connection next.
9503 fn cursor(self) -> Option<String> {
9504 match self {
9505 Self::More(next) => Some(next),
9506 Self::Ended(last) => last,
9507 }
9508 }
9509}
9510
9511/// Where `connection`, reached from `after`, resumes — refused when it reports another page
9512/// with no cursor to it, or from a cursor that does not advance.
9513fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
9514 let info = connection
9515 .get("pageInfo")
9516 .ok_or_else(|| SourceError::Malformed {
9517 message: "GitHub connection has no pageInfo".into(),
9518 })?;
9519 let end = optional_str(info, "endCursor")?;
9520 if required_bool(info, "hasNextPage")? {
9521 let next = end.ok_or_else(|| SourceError::Malformed {
9522 message: "GitHub connection reports another page and no endCursor".into(),
9523 })?;
9524 validate_cursor_progress(after, next)?;
9525 return Ok(Resumed::More(next.to_owned()));
9526 }
9527 Ok(Resumed::Ended(
9528 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
9529 ))
9530}
9531
9532/// The board, and every item on it this source reports.
9533#[derive(Clone)]
9534struct Board {
9535 id: String,
9536 fields: Value,
9537 items: Vec<Resolved>,
9538}
9539
9540/// What a write needs of the board and nothing more: its node id and its field
9541/// definitions, in the shape a read of the board's own `fields` gives them.
9542///
9543/// Deliberately no items. A write decides which item it writes, which parent it files
9544/// under and which far ends it names by reading each of them by its own id; this is the
9545/// half of the board those reads cannot carry, and holding no item is what keeps it from
9546/// ever being asked whether an item is there.
9547#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9548struct BoardFields {
9549 id: BoardId,
9550 fields: Value,
9551}
9552
9553/// A board's node id: what a field write and `addProjectV2ItemById` address.
9554///
9555/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
9556/// refused where it is read, and one an item names blank is read as not named at all.
9557#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9558#[serde(try_from = "String")]
9559struct BoardId(String);
9560
9561impl TryFrom<String> for BoardId {
9562 type Error = String;
9563
9564 /// The same refusal [`BoardId::parse`] makes, for an id read back from a kept context.
9565 fn try_from(id: String) -> Result<Self, Self::Error> {
9566 Self::parse(&id).map_err(|error| error.to_string())
9567 }
9568}
9569
9570/// Where one write left its item, for the record the rest of the command reads it out of.
9571///
9572/// A named record rather than a tuple because the update arm and the create arm each fill
9573/// all four, and two `Option`s of different meaning side by side in a tuple are two
9574/// positions a reader has to count.
9575struct Landed {
9576 /// The issue's own node id, which is the [`NativeId`] this source reports.
9577 content_id: NativeId,
9578 /// The board item's id, which is what a field write addresses.
9579 // 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.
9580 item_id: String,
9581 /// The web address GitHub gave the issue, when it gave one.
9582 // 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.
9583 url: Option<String>,
9584 /// The issue's number on its repository, when GitHub reported one.
9585 number: Option<u64>,
9586}
9587
9588impl BoardId {
9589 fn parse(id: &str) -> Result<Self, SourceError> {
9590 if id.trim().is_empty() {
9591 return Err(SourceError::Malformed {
9592 message: "GitHub named a board with a blank node id".into(),
9593 });
9594 }
9595 Ok(Self(id.to_owned()))
9596 }
9597
9598 fn as_str(&self) -> &str {
9599 &self.0
9600 }
9601}
9602
9603impl Board {
9604 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
9605 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
9606 let nodes = fields
9607 .get("nodes")
9608 .and_then(Value::as_array)
9609 .ok_or_else(|| SourceError::Malformed {
9610 message: "GitHub project fields.nodes is not an array".into(),
9611 })?;
9612 Ok(nodes
9613 .iter()
9614 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
9615 }
9616}
9617
9618/// One board item, resolved into everything this source reports about it.
9619///
9620/// Serializable because it is what [`TaskSource::write_context`] hands the store's cache and
9621/// [`TaskSource::prime_write`] takes back — see [`expected`].
9622#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
9623struct Resolved {
9624 item_id: String,
9625 id: NativeId,
9626 content_kind: ContentKind,
9627 kind: BoardKind,
9628 title: String,
9629 body: Option<String>,
9630 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
9631 /// that changes the slot alone has to keep byte for byte outside it.
9632 raw_body: Option<String>,
9633 status: Status,
9634 /// The name of the board `Status` option this item sits in, as the board spells it.
9635 option: Option<String>,
9636 /// What its `Priority` field says, read through this instance's mapping.
9637 priority: HeldPriority,
9638 /// Whether this item's issue is closed. A draft has no such state and is never closed.
9639 closed: bool,
9640 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
9641 delivers: Vec<TaskRef>,
9642 /// Every task that delivers this one, read out of its slot. Empty for anything not a
9643 /// task.
9644 delivered_by: Vec<TaskRef>,
9645 labels: Vec<Label>,
9646 parent: Option<NativeId>,
9647 // 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.
9648 origin: Option<String>,
9649 /// What each board text field this instance projects metadata onto holds for this item,
9650 /// by field name — only the fields holding a value, an empty text included. What a write compares the
9651 /// item's metadata against, so a field already holding its value is not written again;
9652 /// never read back as metadata, which the slot alone reports.
9653 projected: BTreeMap<String, String>,
9654 /// The issue's own number on its repository, as GitHub reports it.
9655 ///
9656 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
9657 /// declares none, and a draft is not filed in a repository to be numbered by one — and
9658 /// an issue this run created whose creating mutation answered without one, which is a
9659 /// response GitHub's own schema says cannot happen and which a landed write is not
9660 /// worth failing over. An `Issue` read off the board always has one.
9661 number: Option<u64>,
9662 url: Option<String>,
9663 created_at: Option<DateTime<Utc>>,
9664 updated_at: Option<DateTime<Utc>>,
9665 /// When the board item itself last changed — what a board field write moves — when the
9666 /// read that reached it said.
9667 item_updated_at: Option<DateTime<Utc>>,
9668 /// How many comments the issue holds, when the read that reached it said.
9669 comment_count: Option<u64>,
9670 /// How many relationship events the issue's timeline holds — every dependency, as
9671 /// `blockedBy` or `blocking`, and every project membership, as its parent, added or
9672 /// removed — when the read that reached it said.
9673 relationship_events: Option<u64>,
9674 /// The server's clock on the response that read it — its `Date` header — when the
9675 /// response carried one.
9676 served_at: Option<DateTime<Utc>>,
9677 own_repository: Option<Repository>,
9678 repositories: Vec<Repository>,
9679 classification: Classification,
9680 slot: BTreeMap<String, Value>,
9681 /// The node id of the board this item sits on, when the read that reached it said.
9682 board_id: Option<String>,
9683 /// The definition of every board field this item holds a value of, in the shape a read
9684 /// of the board's own `fields` gives one.
9685 ///
9686 /// Only the fields this item has a value in: a field it holds nothing of is not here,
9687 /// which says nothing about whether the board has it.
9688 fields: Vec<Value>,
9689 /// Every field the board this item sits on defines, as its own read of the board's
9690 /// `fields` gives them — when the read that reached the item carried them, which a read
9691 /// of it by its own id does. What a write of it needs of the board, then, needs no read
9692 /// of the board.
9693 board_fields: Option<Value>,
9694 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
9695 /// selects one — when the read that reached it carried the connection to its end, which a
9696 /// read of it by its own id does for any issue blocked by no more than a page. What a
9697 /// write reconciles that relationship against, and what a read of its forward edges in
9698 /// the same command answers with.
9699 blocked_by: Option<Vec<Value>>,
9700}
9701
9702impl Resolved {
9703 /// The board this item's own read names it on, when that read named one this source can
9704 /// address.
9705 fn named_board(&self) -> Option<BoardId> {
9706 self.board_id
9707 .as_deref()
9708 .and_then(|id| BoardId::parse(id).ok())
9709 }
9710
9711 /// The board's id and every field it defines, when the read that reached this item
9712 /// carried both — which a read of it by its own id does.
9713 fn carried_board(&self) -> Option<BoardFields> {
9714 Some(BoardFields {
9715 id: self.named_board()?,
9716 fields: self.board_fields.clone()?,
9717 })
9718 }
9719
9720 /// Whether this item holds a value of the board field called `name`, and so carries
9721 /// that field's definition. `false` says nothing about whether the board has the field.
9722 fn defines(&self, name: &str) -> bool {
9723 self.fields
9724 .iter()
9725 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
9726 }
9727
9728 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
9729 /// in a field of its own, and none of the five keys that are only an encoding.
9730 ///
9731 /// The two delivery keys are left out for every kind, not only for a task: they are
9732 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
9733 /// document carrying one holds nothing a caller's own metadata could mean by it.
9734 fn metadata(&self) -> BTreeMap<String, Value> {
9735 let mut metadata = self.slot.clone();
9736 metadata.remove(Repository::METADATA_KEY);
9737 metadata.remove(DependencyEdge::RECORDED_KEY);
9738 metadata.remove(ItemKind::METADATA_KEY);
9739 metadata.remove(TaskRef::DELIVERS_KEY);
9740 metadata.remove(TaskRef::DELIVERED_BY_KEY);
9741 metadata.remove(Classification::METADATA_KEY);
9742 // The board field is the origin, and the body's copy of it is only a mirror for the
9743 // issue search to find: an item whose field holds none has none, whatever its body
9744 // says, so no reader ever sees two answers.
9745 metadata.remove(ORIGIN_KEY);
9746 if let Some(origin) = &self.origin {
9747 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
9748 }
9749 metadata
9750 }
9751
9752 /// Where this item is, as a link a reader can open.
9753 ///
9754 /// A board is a hosted place and every issue on it has a web address, so that address
9755 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
9756 /// of place it is, so a reader knows to open it rather than to read a file out. It
9757 /// does not replace or derive from `url`: the field goes on reporting exactly what it
9758 /// reported before, and this says what that address *is*.
9759 ///
9760 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
9761 /// rather than a third variant, which is the contract's "the source did not say". An
9762 /// issue this run created is not one of those: its address comes back from the
9763 /// creating mutation, so it is somewhere a reader can open from the moment it exists
9764 /// rather than from whenever the board read catches up.
9765 fn location(&self) -> Option<Location> {
9766 self.url.clone().map(Location::Url)
9767 }
9768
9769 /// The short handle this board's backend shows people for a task: the issue's number
9770 /// alone, as a decimal string.
9771 ///
9772 /// The number alone rather than `owner/repo#1043`, because that is the contract's
9773 /// value for this backend. A draft has no number and so no handle, which is the
9774 /// contract's *absent* rather than a handle of some other shape — and the native
9775 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
9776 /// derives from.
9777 fn key(&self) -> Option<String> {
9778 self.number.map(|number| number.to_string())
9779 }
9780
9781 /// This item's change token — see [`change_token`] — or `None` when the read that
9782 /// reached it did not carry all five of its parts, which a draft never does, did not say
9783 /// when GitHub served it, or found it changed too recently to have settled.
9784 fn version(&self) -> Option<String> {
9785 match self.content_kind {
9786 ContentKind::Issue => {
9787 let (issue, item) = (self.updated_at?, self.item_updated_at?);
9788 let parts = change_token::Parts {
9789 issue,
9790 item,
9791 comments: self.comment_count?,
9792 relationships: self.relationship_events?,
9793 repository: self.own_repository.clone()?,
9794 };
9795 change_token::settled(issue, item, self.served_at?)
9796 .then(|| change_token::spell(&parts))
9797 }
9798 ContentKind::DraftIssue => None,
9799 }
9800 }
9801
9802 /// Whether its `Priority` field holds a value at all, mapped or not.
9803 fn holds_priority(&self) -> bool {
9804 self.priority != HeldPriority::Read(Priority::None)
9805 }
9806
9807 /// The task this item is.
9808 ///
9809 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
9810 /// reading that as a level would be a guess, and reading it as `none` would let the next
9811 /// copy clear a priority a person set.
9812 fn task(&self) -> Result<Task, SourceError> {
9813 let priority = match &self.priority {
9814 HeldPriority::Read(priority) => *priority,
9815 HeldPriority::Unmapped(option) => {
9816 return Err(SourceError::Malformed {
9817 message: format!(
9818 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
9819 this source's priority_mapping does not name, so its priority cannot be \
9820 read; next: name {option:?} under priority_mapping, or move the item to \
9821 a mapped option",
9822 self.id,
9823 self.number
9824 .map(|number| format!(" (#{number})"))
9825 .unwrap_or_default()
9826 ),
9827 });
9828 }
9829 };
9830 Ok(Task {
9831 version: self.version(),
9832 id: self.id.clone(),
9833 key: self.key(),
9834 title: self.title.clone(),
9835 content: self.body.clone(),
9836 status: self.status.clone(),
9837 priority,
9838 labels: self.labels.clone(),
9839 project: self.parent.clone(),
9840 url: self.url.clone(),
9841 location: self.location(),
9842 created_at: self.created_at,
9843 updated_at: self.updated_at,
9844 metadata: self.metadata(),
9845 repositories: self.repositories.clone(),
9846 delivers: self.delivers.clone(),
9847 delivered_by: self.delivered_by.clone(),
9848 classification: self.classification,
9849 })
9850 }
9851
9852 fn project(&self) -> Project {
9853 Project {
9854 id: self.id.clone(),
9855 title: self.title.clone(),
9856 content: self.body.clone(),
9857 status: self.status.clone(),
9858 labels: self.labels.clone(),
9859 url: self.url.clone(),
9860 location: self.location(),
9861 created_at: self.created_at,
9862 updated_at: self.updated_at,
9863 metadata: self.metadata(),
9864 repositories: self.repositories.clone(),
9865 classification: self.classification,
9866 }
9867 }
9868
9869 /// The same issue as a document: the project it is filed under, and no status and no
9870 /// dependencies, because a document is not work.
9871 fn document(&self) -> Document {
9872 Document {
9873 id: self.id.clone(),
9874 title: self.title.clone(),
9875 content: self.body.clone(),
9876 project: self.parent.clone(),
9877 labels: self.labels.clone(),
9878 url: self.url.clone(),
9879 location: self.location(),
9880 created_at: self.created_at,
9881 updated_at: self.updated_at,
9882 metadata: self.metadata(),
9883 repositories: self.repositories.clone(),
9884 classification: self.classification,
9885 }
9886 }
9887}
9888
9889/// Where one targeted update moves an item's status, and which of its two halves move.
9890struct StatusMove {
9891 /// The board the item's `Status` field is on.
9892 board: BoardId,
9893 /// The `Status` field's id.
9894 field: String,
9895 /// The option's id.
9896 option: String,
9897 /// The option's name, as the board spells it.
9898 name: String,
9899 /// What the status asks of the issue's state.
9900 target: StatusTarget,
9901 /// The status the item reads as once it is there.
9902 landed: Status,
9903 /// Which of the status's two halves differ from what the item holds.
9904 moves: Moves,
9905}
9906
9907/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
9908/// closed state of its issue, or both. A status neither half of which differs is no move at all,
9909/// and is not a value of this type.
9910#[derive(Clone, Copy, PartialEq, Eq)]
9911enum Moves {
9912 /// The option alone.
9913 Option,
9914 /// The issue's state alone: open, closed, or closed with another reason.
9915 State,
9916 /// Both.
9917 Both,
9918}
9919
9920impl Moves {
9921 /// What differs, or `None` when nothing does.
9922 const fn of(option: bool, state: bool) -> Option<Self> {
9923 match (option, state) {
9924 (true, true) => Some(Self::Both),
9925 (true, false) => Some(Self::Option),
9926 (false, true) => Some(Self::State),
9927 (false, false) => None,
9928 }
9929 }
9930
9931 /// Whether the option moves.
9932 const fn option(self) -> bool {
9933 matches!(self, Self::Option | Self::Both)
9934 }
9935
9936 /// Whether the issue's state moves.
9937 const fn state(self) -> bool {
9938 matches!(self, Self::State | Self::Both)
9939 }
9940}
9941
9942/// What one write is, and the status that comes with being it.
9943///
9944/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
9945/// status and a task or a project always has one, so "a document carrying a status" and
9946/// "a task carrying none" are states a write cannot be in rather than states every use
9947/// site below has to defend against.
9948enum Written<'a> {
9949 /// A document, which is not work and so has no status at all.
9950 Document,
9951 /// A task or a project, and the status it is being written with.
9952 Work(ItemKind, &'a Status),
9953}
9954
9955impl Written<'_> {
9956 /// Which of the board's three kinds this write is.
9957 const fn kind(&self) -> BoardKind {
9958 match self {
9959 Self::Document => BoardKind::Document,
9960 Self::Work(kind, _) => BoardKind::Work(*kind),
9961 }
9962 }
9963
9964 /// The status this write carries. A document carries none, so a write of one says
9965 /// nothing about the issue's open or closed state and selects no board `Status`
9966 /// option.
9967 const fn status(&self) -> Option<&Status> {
9968 match self {
9969 Self::Document => None,
9970 Self::Work(_, status) => Some(status),
9971 }
9972 }
9973
9974 /// The status this write carries with the kind whose half of `status_mapping` it is
9975 /// written through.
9976 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
9977 match self {
9978 Self::Document => None,
9979 Self::Work(kind, status) => Some((*kind, status)),
9980 }
9981 }
9982}
9983
9984/// The item being written, in the one shape all three write methods reach.
9985struct Incoming<'a> {
9986 written: Written<'a>,
9987 /// The title a person wrote. A document's goes onto the issue with
9988 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
9989 title: &'a str,
9990 content: Option<&'a str>,
9991 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
9992 labels: &'a [Label],
9993 metadata: &'a BTreeMap<String, Value>,
9994 repositories: &'a [Repository],
9995 /// Recorded in the slot while private, and nowhere while public.
9996 classification: Classification,
9997 parent: Option<&'a NativeId>,
9998 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
9999 /// what keeps either key out of their slot.
10000 delivers: &'a [TaskRef],
10001 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
10002 delivered_by: &'a [TaskRef],
10003 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
10004 /// project, a document, and every write to an instance with no `priority_mapping` —
10005 /// which is what keeps such a write's requests exactly what they were before.
10006 priority: Option<Priority>,
10007}
10008
10009/// What one write does to one projected metadata text field, by the field's id.
10010#[derive(Debug, Clone)]
10011enum ProjectionWrite {
10012 /// Write this text.
10013 Set {
10014 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.
10015 text: String,
10016 },
10017 /// Clear the field's value.
10018 Clear { field: String }, // llmlint: ignore[invalid_states_unrepresentable] As above.
10019}
10020
10021impl ProjectionWrite {
10022 /// Add this write to a field write's list of values and list of clears.
10023 fn join<'a>(&'a self, values: &mut Vec<(String, Value)>, clears: &mut Vec<&'a str>) {
10024 match self {
10025 Self::Set { field, text } => values.push((field.clone(), json!({"text": text}))),
10026 Self::Clear { field } => clears.push(field),
10027 }
10028 }
10029}
10030
10031/// What one write does to an item's `Priority` field.
10032enum PriorityWrite {
10033 /// Select this option of this field.
10034 Select {
10035 /// The `Priority` field's id.
10036 field: String,
10037 /// The mapped option's id.
10038 option: String,
10039 },
10040 /// Clear the field's value, which is what `none` is.
10041 Clear {
10042 /// The `Priority` field's id.
10043 field: String,
10044 },
10045}
10046
10047impl Incoming<'_> {
10048 /// The title this write puts on the issue.
10049 fn written_title(&self) -> String {
10050 match self.written {
10051 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
10052 Written::Work(..) => self.title.to_owned(),
10053 }
10054 }
10055}
10056
10057#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10058enum ContentKind {
10059 DraftIssue,
10060 Issue,
10061}
10062
10063/// What one board issue is: a document, or the work an [`ItemKind`] names.
10064///
10065/// A type of this source's own rather than an `ItemKind` with a third variant, because
10066/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
10067/// document — the contract keeps a document out of that enum deliberately. Holding the
10068/// board's three answers in one value is what makes every place that asks "which is this?"
10069/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
10070/// two thirds of the board.
10071#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10072enum BoardKind {
10073 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
10074 Document,
10075 /// Every other issue, and every draft.
10076 Work(ItemKind),
10077}
10078
10079impl BoardKind {
10080 /// Whose half of `status_mapping` an item of this kind reads its status through. A
10081 /// document has no status of its own, so the task half stands in for whatever the issue
10082 /// holds; nothing reports it.
10083 const fn status_kind(self) -> ItemKind {
10084 match self {
10085 Self::Document => ItemKind::Task,
10086 Self::Work(kind) => kind,
10087 }
10088 }
10089
10090 /// How a refusal names this kind to the person reading it.
10091 const fn describes(self) -> &'static str {
10092 match self {
10093 Self::Document => "document",
10094 Self::Work(kind) => kind.marker(),
10095 }
10096 }
10097}
10098
10099/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
10100///
10101/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
10102/// the shared cross-source journeys assert one answer to one question, so two sources
10103/// that disagree about what "carries the label bug" means fail them.
10104fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
10105 let holds = |name: &String| {
10106 labels
10107 .iter()
10108 .any(|label| label.name.eq_ignore_ascii_case(name))
10109 };
10110 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
10111 && filter.all_of.iter().all(holds)
10112 && !filter.none_of.iter().any(holds)
10113}
10114
10115/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
10116/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
10117fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
10118 statuses.is_empty() || statuses.contains(&category)
10119}
10120
10121/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
10122///
10123/// `content` is the item's own prose — the body with this source's trailing metadata
10124/// comment already taken off — so a search never matches an encoding the author of the
10125/// issue never wrote.
10126fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
10127 let terms = query.terms.to_lowercase();
10128 let in_title = title.to_lowercase().contains(&terms);
10129 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
10130 match query.fields {
10131 TextFields::Title => in_title,
10132 TextFields::Content => in_content,
10133 TextFields::TitleOrContent => in_title || in_content,
10134 }
10135}
10136
10137/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
10138///
10139/// The project predicate is passed separately because a read narrowed to one project has
10140/// already answered it by asking *that project* for its own items — and re-applying it
10141/// there would compare the caller's selector, which may be a project's **name**, against
10142/// the id of the project that name resolved to, and keep nothing. Every other read passes
10143/// `query.project` and applies it here, which is what keeps `projects` a predicate this
10144/// source really does apply.
10145fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
10146 labels_match(&task.labels, &query.labels)
10147 && status_matches(task.status.category, &query.statuses)
10148 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
10149 && match project {
10150 ProjectFilter::Any => true,
10151 ProjectFilter::Orphans => task.project.is_none(),
10152 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
10153 }
10154 && query
10155 .text
10156 .as_ref()
10157 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
10158 // Against the parsed metadata slot, and against the origin field, which is where
10159 // `Resolved::metadata` reads each of them from.
10160 && query.metadata_matches(&task.metadata)
10161 && query.origin_matches(&task.metadata)
10162}
10163
10164fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
10165 labels_match(&project.labels, &query.labels)
10166 && status_matches(project.status.category, &query.statuses)
10167 && query
10168 .text
10169 .as_ref()
10170 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
10171}
10172
10173/// The same three predicates a task query carries, minus the status filter.
10174///
10175/// A document is not work, so it has no status for one to compare against and the query
10176/// type carries none. The project predicate is the same one — a design issue filed under a
10177/// project issue is in that project, and one filed under nothing is in none — so it is
10178/// spelled the same way here rather than answered differently.
10179fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
10180 labels_match(&document.labels, &query.labels)
10181 && match project {
10182 ProjectFilter::Any => true,
10183 ProjectFilter::Orphans => document.project.is_none(),
10184 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
10185 }
10186 && query
10187 .text
10188 .as_ref()
10189 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
10190}
10191
10192#[async_trait::async_trait]
10193impl TaskSource for GitHubProjectsSource {
10194 fn kind(&self) -> &'static str {
10195 KIND
10196 }
10197 fn order_versions(&self, a: &str, b: &str) -> Option<std::cmp::Ordering> {
10198 change_token::order(a, b)
10199 }
10200 /// Every part: §4.13d of `docs/plugin-protocol.md` states, member by member, the change
10201 /// each part of the token moves for, comments included.
10202 fn version_coverage(&self) -> VersionCoverage {
10203 VersionCoverage::whole()
10204 }
10205 fn write_context(&self, id: &NativeId) -> Option<Value> {
10206 self.context_of(id)
10207 }
10208 fn board_context(&self) -> Option<Value> {
10209 self.board_context_held()
10210 }
10211 fn prime_write(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
10212 self.prime(id, context, board)
10213 }
10214 fn unprime(&self, id: &NativeId) {
10215 self.unprime_held(id);
10216 }
10217 fn begin_journal(&self) {
10218 self.expected.begin();
10219 }
10220 fn end_journal(&self) -> Option<WriteJournal> {
10221 Some(self.expected.end())
10222 }
10223 fn capabilities(&self) -> Capabilities {
10224 Capabilities {
10225 projects: Support::Native,
10226 documents: Support::Native,
10227 comments: Support::Native,
10228 assets: Support::Native,
10229 priority: if self.priorities.is_some() {
10230 Support::Native
10231 } else {
10232 Support::Unsupported
10233 },
10234 filter_by_priority: Support::Native,
10235 filter_by_comment_activity: Support::Native,
10236 filter_by_metadata: Support::Native,
10237 filter_by_origin: Support::Native,
10238 orphan_tasks: Support::Native,
10239 filter_by_label: Support::Native,
10240 filter_by_status: Support::Native,
10241 search_title: Support::Native,
10242 search_content: Support::Native,
10243 task_dependencies: DependencySupport::BothDirections,
10244 project_dependencies: DependencySupport::BothDirections,
10245 max_page_size: MAX_PAGE_SIZE,
10246 }
10247 }
10248 async fn visibility(
10249 &self,
10250 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
10251 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
10252 self.write_visibility(target).await
10253 }
10254 async fn health(&self) -> Result<Health, SourceError> {
10255 let board = self.board_page(None, 1).await?;
10256 Ok(Health {
10257 reachable: true,
10258 detail: Some(format!(
10259 "reading GitHub project {}/{} ({})",
10260 self.owner,
10261 self.project_number,
10262 required_str(&board, "title")?
10263 )),
10264 })
10265 }
10266 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
10267 self.item_by_id(id)
10268 .await?
10269 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
10270 .map(|item| item.task())
10271 .transpose()
10272 }
10273 async fn task_assets(
10274 &self,
10275 id: &NativeId,
10276 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
10277 self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
10278 }
10279 async fn task_asset(
10280 &self,
10281 id: &NativeId,
10282 name: &onetaskgraph_plugin_api::AssetName,
10283 ) -> Result<Option<Vec<u8>>, SourceError> {
10284 self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
10285 .await
10286 }
10287 async fn set_task_rendering_with_assets(
10288 &self,
10289 id: &NativeId,
10290 content: &str,
10291 provenance: &Value,
10292 _answers: &BTreeMap<String, Value>,
10293 assets: &onetaskgraph_plugin_api::AssetWrite,
10294 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
10295 self.replace_rendering(
10296 id,
10297 BoardKind::Work(ItemKind::Task),
10298 content,
10299 provenance,
10300 Some(assets),
10301 )
10302 .await
10303 }
10304 async fn document_assets(
10305 &self,
10306 id: &NativeId,
10307 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
10308 self.held_assets(id, BoardKind::Document).await
10309 }
10310 async fn document_asset(
10311 &self,
10312 id: &NativeId,
10313 name: &onetaskgraph_plugin_api::AssetName,
10314 ) -> Result<Option<Vec<u8>>, SourceError> {
10315 self.held_asset(id, BoardKind::Document, name).await
10316 }
10317 async fn set_document_rendering_with_assets(
10318 &self,
10319 id: &NativeId,
10320 content: &str,
10321 provenance: &Value,
10322 _answers: &BTreeMap<String, Value>,
10323 assets: &onetaskgraph_plugin_api::AssetWrite,
10324 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
10325 self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
10326 .await
10327 }
10328 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
10329 Ok(self
10330 .item_by_id(id)
10331 .await?
10332 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
10333 .map(|item| item.project()))
10334 }
10335 async fn query_tasks(
10336 &self,
10337 query: &TaskQuery,
10338 page: &PageRequest,
10339 ) -> Result<Page<Task>, SourceError> {
10340 validate_page(page)?;
10341 refuse_unsearchable(query)?;
10342 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
10343 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
10344 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
10345 (Some(also), None) => Some(also),
10346 (None, Some(since)) => Some(updated_qualifier(since)),
10347 (None, None) => None,
10348 };
10349 if let Some(also) = qualifiers {
10350 return self.search_tasks(query, page, &also).await;
10351 }
10352 }
10353
10354 // A read narrowed to one project asks that project for its own tasks, so nothing
10355 // about it costs what the rest of the board holds. A read carrying a text, metadata
10356 // or origin predicate asks GitHub the narrower question those predicates are, and a
10357 // read narrowed to comment activity alone asks the board's own issue search for the
10358 // issues updated since, which is every issue a comment could have been written or
10359 // edited on since. Every other task read is a question about the whole board and is
10360 // answered by reading it.
10361 let (held, membership) = match (&query.project, query.commented_since) {
10362 (ProjectFilter::Is(project), _) => (
10363 self.project_children(project).await?,
10364 // Answered by where these items came from; see `task_matches`.
10365 &ProjectFilter::Any,
10366 ),
10367 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
10368 match (self.narrowed(query).await?, since) {
10369 (Some(narrowed), _) => (narrowed, &query.project),
10370 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
10371 (None, None) => (self.board().await?.items, &query.project),
10372 }
10373 }
10374 };
10375 // Filtered before paged: a page of a filtered result is a page of the survivors,
10376 // never the survivors of a page.
10377 let mut tasks = Vec::new();
10378 for item in held
10379 .iter()
10380 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
10381 {
10382 let task = item.task()?;
10383 if task_matches(&task, query, membership)
10384 && self.commented_since(item, query.commented_since).await?
10385 {
10386 tasks.push(task);
10387 }
10388 }
10389 Ok(offset_page(
10390 tasks,
10391 numeric_cursor(page.cursor.as_ref())?,
10392 page.limit.min(MAX_PAGE_SIZE) as usize,
10393 ))
10394 }
10395 async fn query_projects(
10396 &self,
10397 query: &ProjectQuery,
10398 page: &PageRequest,
10399 ) -> Result<Page<Project>, SourceError> {
10400 validate_page(page)?;
10401 refuse_unsearchable_text(query.text.as_ref())?;
10402 // The projects a board holds are found by an issue search scoped to that board,
10403 // never by walking the board's own item connection: what tells a project from a
10404 // task is the `parent` each issue carries, which costs nothing to read. A query
10405 // carrying a text asks that search for the text too, so it reads the issues that
10406 // hold it rather than every issue of the board.
10407 let held = match self.text_searched(query.text.as_ref()).await? {
10408 Some(searched) => searched,
10409 None => self.board_issues().await?,
10410 };
10411 let projects = held
10412 .iter()
10413 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
10414 .map(Resolved::project)
10415 .filter(|project| project_matches(project, query))
10416 .collect();
10417 Ok(offset_page(
10418 projects,
10419 numeric_cursor(page.cursor.as_ref())?,
10420 page.limit.min(MAX_PAGE_SIZE) as usize,
10421 ))
10422 }
10423 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
10424 Ok(self
10425 .item_by_id(id)
10426 .await?
10427 .filter(|item| item.kind == BoardKind::Document)
10428 .map(|item| item.document()))
10429 }
10430 async fn query_documents(
10431 &self,
10432 query: &DocumentQuery,
10433 page: &PageRequest,
10434 ) -> Result<Page<Document>, SourceError> {
10435 validate_page(page)?;
10436 // Narrowed to one project, this is the same sub-issue read a task list scoped to
10437 // that project makes — a document filed under a project is a sub-issue of it too,
10438 // and which of them come back is the kind this caller asked for. Unscoped, a query
10439 // carrying a text asks the board-scoped issue search for it, as a task query does,
10440 // and only one carrying none reads the board.
10441 let (held, membership) = match &query.project {
10442 ProjectFilter::Is(project) => (
10443 self.project_children(project).await?,
10444 // Answered by where these items came from; see `task_matches`.
10445 &ProjectFilter::Any,
10446 ),
10447 ProjectFilter::Any | ProjectFilter::Orphans => {
10448 refuse_unsearchable_text(query.text.as_ref())?;
10449 match self.text_searched(query.text.as_ref()).await? {
10450 Some(searched) => (searched, &query.project),
10451 None => (self.board().await?.items, &query.project),
10452 }
10453 }
10454 };
10455 // Filtered before paged, exactly as a task read is: a page of a filtered result is
10456 // a page of the survivors, never the survivors of a page.
10457 let documents = held
10458 .iter()
10459 .filter(|item| item.kind == BoardKind::Document)
10460 .map(Resolved::document)
10461 .filter(|document| document_matches(document, query, membership))
10462 .collect();
10463 Ok(offset_page(
10464 documents,
10465 numeric_cursor(page.cursor.as_ref())?,
10466 page.limit.min(MAX_PAGE_SIZE) as usize,
10467 ))
10468 }
10469 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
10470 validate_page(page)?;
10471 let offset = numeric_cursor(page.cursor.as_ref())?;
10472 let mut labels = self
10473 .board()
10474 .await?
10475 .items
10476 .into_iter()
10477 .flat_map(|item| item.labels)
10478 .fold(Vec::new(), |mut all, label| {
10479 if !all.iter().any(|x: &Label| x.id == label.id) {
10480 all.push(label);
10481 }
10482 all
10483 });
10484 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
10485 Ok(offset_page(
10486 labels,
10487 offset,
10488 page.limit.min(MAX_PAGE_SIZE) as usize,
10489 ))
10490 }
10491 async fn task_dependencies(
10492 &self,
10493 id: &NativeId,
10494 direction: Direction,
10495 page: &PageRequest,
10496 ) -> Result<Page<DependencyEdge>, SourceError> {
10497 self.dependencies(id, ItemKind::Task, direction, page).await
10498 }
10499 async fn project_dependencies(
10500 &self,
10501 id: &NativeId,
10502 direction: Direction,
10503 page: &PageRequest,
10504 ) -> Result<Page<DependencyEdge>, SourceError> {
10505 self.dependencies(id, ItemKind::Project, direction, page)
10506 .await
10507 }
10508
10509 fn writes(&self) -> WriteSupport {
10510 WriteSupport::Supported
10511 }
10512
10513 /// Create or update one task.
10514 ///
10515 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
10516 /// neither may name the task itself or name one task twice — and land in the body's
10517 /// metadata slot under their reserved keys, in place of any caller metadata of those
10518 /// names.
10519 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
10520 self.write_task_assets(write, None)
10521 .await
10522 .map(|written| written.id)
10523 }
10524
10525 async fn write_task_with_assets(
10526 &self,
10527 write: &ItemWrite<Task>,
10528 _answers: Option<&BTreeMap<String, Value>>,
10529 assets: &onetaskgraph_plugin_api::AssetWrite,
10530 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10531 self.write_task_assets(write, Some(assets)).await
10532 }
10533
10534 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
10535 self.write_item(
10536 &Incoming {
10537 written: Written::Work(ItemKind::Project, &write.item.status),
10538 title: &write.item.title,
10539 content: write.item.content.as_deref(),
10540 assets: None,
10541 labels: &write.item.labels,
10542 metadata: &write.item.metadata,
10543 repositories: &write.item.repositories,
10544 classification: write.item.classification,
10545 parent: None,
10546 delivers: &[],
10547 delivered_by: &[],
10548 priority: None,
10549 },
10550 write.target.as_ref(),
10551 &write.depends_on,
10552 )
10553 .await
10554 .map(|written| written.id)
10555 }
10556
10557 /// Create or update one document, which is one issue titled the way this board spells
10558 /// a document.
10559 ///
10560 /// Everything else is exactly a task write: caller metadata goes to the same canonical
10561 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
10562 /// or a field this board cannot carry is refused by name rather than dropped, a target
10563 /// naming an issue this board does not hold is refused rather than created, and an
10564 /// issue this call created is taken back when the rest of the write fails.
10565 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
10566 self.write_document_assets(write, None)
10567 .await
10568 .map(|written| written.id)
10569 }
10570
10571 async fn write_document_with_assets(
10572 &self,
10573 write: &ItemWrite<Document>,
10574 _answers: Option<&BTreeMap<String, Value>>,
10575 assets: &onetaskgraph_plugin_api::AssetWrite,
10576 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10577 self.write_document_assets(write, Some(assets)).await
10578 }
10579
10580 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
10581 /// which reads nothing; then the board's `Status` option. Over an existing item that is
10582 /// read off the item, as the write reads it, and the item is held among this command's
10583 /// resolved records so the write that follows reuses that read rather than repeating it;
10584 /// an item that does not carry the field takes the board's fields, which are held once
10585 /// read. A create is checked against the board's fields only when this command already
10586 /// holds them, because a create reads them together with its repository, in one request,
10587 /// and refuses a missing option before it writes anything.
10588 async fn check_status_write(
10589 &self,
10590 kind: ItemKind,
10591 category: StatusCategory,
10592 target: Option<&NativeId>,
10593 ) -> Result<(), SourceError> {
10594 let status = self.resolved_target(kind, category)?;
10595 if status.option().is_none() {
10596 return Ok(());
10597 }
10598 let fields = match target {
10599 Some(target) => {
10600 // A target this board does not hold is the write's own refusal to make.
10601 let Some(item) = self.bound_item(target).await? else {
10602 return Ok(());
10603 };
10604 self.resolved_cache()?.insert(target.clone(), item.clone());
10605 self.fields_for(Some(&item), true, false).await?.fields
10606 }
10607 None => {
10608 let held = self
10609 .board_cache()?
10610 .as_ref()
10611 .map(|board| board.fields.clone());
10612 match held.or_else(|| {
10613 self.fields_cache()
10614 .ok()
10615 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
10616 }) {
10617 Some(fields) => fields,
10618 None => return Ok(()),
10619 }
10620 }
10621 };
10622 self.column_for(&fields, kind, category, &status)
10623 .map(|_| ())
10624 }
10625
10626 /// Set one task's status alone.
10627 ///
10628 /// An open target reopens a closed issue with an `updateIssue` carrying only its
10629 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
10630 /// terminal target selects its mapped option, then closes with its fixed reason. No
10631 /// request carries a title, a body or a label. The status
10632 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
10633 /// what a re-read reports.
10634 async fn set_task_status(
10635 &self,
10636 id: &NativeId,
10637 category: StatusCategory,
10638 ) -> Result<Option<Status>, SourceError> {
10639 self.set_status(id, category).await
10640 }
10641
10642 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
10643 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
10644 /// for `none`. Refused by an instance with no `priority_mapping`.
10645 async fn set_task_priority(
10646 &self,
10647 id: &NativeId,
10648 priority: Priority,
10649 ) -> Result<Option<Priority>, SourceError> {
10650 self.set_priority(id, priority).await
10651 }
10652
10653 /// Replace one task's content with a single body update that keeps the metadata slot
10654 /// byte for byte.
10655 async fn set_task_content(
10656 &self,
10657 id: &NativeId,
10658 content: &str,
10659 ) -> Result<Option<()>, SourceError> {
10660 self.replace_content(id, content).await
10661 }
10662
10663 /// Replace one task issue's content and its provenance slot entry with a single body
10664 /// update. The answers are not kept: see `replace_rendering`.
10665 async fn set_task_rendering(
10666 &self,
10667 id: &NativeId,
10668 content: &str,
10669 provenance: &Value,
10670 _answers: &BTreeMap<String, Value>,
10671 ) -> Result<Option<()>, SourceError> {
10672 self.replace_rendering(
10673 id,
10674 BoardKind::Work(ItemKind::Task),
10675 content,
10676 provenance,
10677 None,
10678 )
10679 .await
10680 .map(|written| written.map(|_| ()))
10681 }
10682
10683 /// Replace one design-document issue's content and its provenance slot entry, on exactly
10684 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
10685 async fn set_document_rendering(
10686 &self,
10687 id: &NativeId,
10688 content: &str,
10689 provenance: &Value,
10690 _answers: &BTreeMap<String, Value>,
10691 ) -> Result<Option<()>, SourceError> {
10692 self.replace_rendering(id, BoardKind::Document, content, provenance, None)
10693 .await
10694 .map(|written| written.map(|_| ()))
10695 }
10696
10697 /// Replace one project issue's content and its provenance slot entry, on exactly the
10698 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
10699 async fn set_project_rendering(
10700 &self,
10701 id: &NativeId,
10702 content: &str,
10703 provenance: &Value,
10704 _answers: &BTreeMap<String, Value>,
10705 ) -> Result<Option<()>, SourceError> {
10706 self.replace_rendering(
10707 id,
10708 BoardKind::Work(ItemKind::Project),
10709 content,
10710 provenance,
10711 None,
10712 )
10713 .await
10714 .map(|written| written.map(|_| ()))
10715 }
10716
10717 /// Apply a targeted update with one read of the item and a write only for what differs:
10718 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
10719 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
10720 async fn update_task(
10721 &self,
10722 id: &NativeId,
10723 update: &TaskUpdate,
10724 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
10725 self.targeted_update(id, update).await
10726 }
10727
10728 /// Replace one task's `delivered_by` with a single body update that changes the
10729 /// metadata slot and nothing outside it.
10730 async fn set_delivered_by(
10731 &self,
10732 id: &NativeId,
10733 delivered_by: &[TaskRef],
10734 ) -> Result<Option<()>, SourceError> {
10735 self.replace_delivered_by(id, delivered_by).await
10736 }
10737
10738 /// Set one key of one task issue's metadata with a single body update that changes the
10739 /// metadata slot and nothing outside it — no title, label, state or board field request —
10740 /// and sends nothing when the task already holds that value under the key.
10741 async fn set_task_metadata(
10742 &self,
10743 id: &NativeId,
10744 key: &MetadataKey,
10745 value: &Value,
10746 ) -> Result<Option<Task>, SourceError> {
10747 Ok(self
10748 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
10749 .await?
10750 .map(|item| item.task())
10751 .transpose()?)
10752 }
10753
10754 /// Set one key of one project issue's metadata, on exactly the terms of
10755 /// [`set_task_metadata`](TaskSource::set_task_metadata).
10756 async fn set_project_metadata(
10757 &self,
10758 id: &NativeId,
10759 key: &MetadataKey,
10760 value: &Value,
10761 ) -> Result<Option<Project>, SourceError> {
10762 Ok(self
10763 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
10764 .await?
10765 .map(|item| item.project()))
10766 }
10767
10768 /// Set one key of one design-document issue's metadata, on exactly the terms of
10769 /// [`set_task_metadata`](TaskSource::set_task_metadata).
10770 async fn set_document_metadata(
10771 &self,
10772 id: &NativeId,
10773 key: &MetadataKey,
10774 value: &Value,
10775 ) -> Result<Option<Document>, SourceError> {
10776 Ok(self
10777 .set_slot_key(id, BoardKind::Document, key, value)
10778 .await?
10779 .map(|item| item.document()))
10780 }
10781
10782 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
10783 self.delete_item(id).await
10784 }
10785
10786 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
10787 self.delete_item(id).await
10788 }
10789
10790 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
10791 self.delete_item(id).await
10792 }
10793
10794 /// One page of the task issue's own comments, walked by GitHub's own cursor.
10795 ///
10796 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
10797 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
10798 ///
10799 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
10800 /// board is the read of its comments. A draft this process already resolved is refused
10801 /// without one.
10802 async fn task_comments(
10803 &self,
10804 task: &NativeId,
10805 page: &PageRequest,
10806 ) -> Result<Option<Page<Comment>>, SourceError> {
10807 validate_page(page)?;
10808 let cached = self.resolved_cache()?.get(task).cloned();
10809 if let Some(item) = cached {
10810 if item.kind != BoardKind::Work(ItemKind::Task) {
10811 return Ok(None);
10812 }
10813 if item.content_kind == ContentKind::DraftIssue {
10814 return Err(self.draft_has_no_comments(task));
10815 }
10816 }
10817 match self.issue_detail(task, page).await? {
10818 Some(TaskDetailRead {
10819 comments: Some(comments),
10820 ..
10821 }) => comments,
10822 _ => Ok(None),
10823 }
10824 }
10825
10826 /// Every id's task, with the first page of its comments when `comments` names it:
10827 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
10828 /// comments in one [`graphql::ISSUE_DETAIL`] request.
10829 async fn get_task_details(
10830 &self,
10831 ids: &[NativeId],
10832 comments: Option<&PageRequest>,
10833 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
10834 if let Some(page) = comments
10835 && let Err(error) = validate_page(page)
10836 {
10837 return ids.iter().map(|_| Err(error.clone())).collect();
10838 }
10839 match (ids, comments) {
10840 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
10841 ([id], None) => vec![self.task_read(id).await],
10842 _ => self.issue_details(ids, comments).await,
10843 }
10844 }
10845
10846 /// Every id's task with the first page of its comments and its `blockedBy`, as a copy reads
10847 /// the tasks it carries: [`COPY_DETAIL_BATCH`] items per [`graphql::ISSUE_COPY_DETAILS`]
10848 /// request, so the dependency read a copy makes of each next is answered from this one.
10849 async fn get_task_details_for_copy(
10850 &self,
10851 ids: &[NativeId],
10852 comments: &PageRequest,
10853 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
10854 // A page past the first is a page of one task's comments, which the detail read of it
10855 // alone answers.
10856 if comments.cursor.is_some() {
10857 return self.get_task_details(ids, Some(comments)).await;
10858 }
10859 if let Err(error) = validate_page(comments) {
10860 return ids.iter().map(|_| Err(error.clone())).collect();
10861 }
10862 let mut read = Vec::with_capacity(ids.len());
10863 for batch in ids.chunks(COPY_DETAIL_BATCH) {
10864 match self
10865 .graphql(
10866 graphql::ISSUE_COPY_DETAILS,
10867 copy_detail_batch(batch, comments),
10868 )
10869 .await
10870 {
10871 Ok(data) => {
10872 for (slot, id) in batch.iter().enumerate() {
10873 let read_one = match data.get(format!("i{slot}")) {
10874 Some(node) => self.detail_of(id, node, true, None).await,
10875 None => Err(SourceError::Malformed {
10876 message: format!(
10877 "GitHub answered a batch read with no item for {}",
10878 id.0
10879 ),
10880 }),
10881 };
10882 read.push(read_one);
10883 }
10884 }
10885 Err(error) if unresolvable_node(&error) => {
10886 for id in batch {
10887 read.push(self.issue_detail(id, comments).await);
10888 }
10889 }
10890 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
10891 }
10892 }
10893 read
10894 }
10895
10896 /// Add one comment to the task's issue, as the account the token belongs to.
10897 ///
10898 /// The author is refused before anything is sent — not even the task is read — because
10899 /// no answer GitHub could give would make posting under another name than the one asked
10900 /// for the right outcome.
10901 async fn add_comment(
10902 &self,
10903 task: &NativeId,
10904 comment: &NewComment,
10905 ) -> Result<Option<Comment>, SourceError> {
10906 if let Some(author) = &comment.author {
10907 return Err(SourceError::Refused {
10908 message: format!(
10909 "source {} cannot post a comment as {author:?}: GitHub records the account \
10910 the token signs in as the author of every comment; next: leave --author \
10911 out, and the comment is posted as that account",
10912 self.name
10913 ),
10914 });
10915 }
10916 let Some(issue) = self.commented_issue(task).await? else {
10917 return Ok(None);
10918 };
10919 let data = self
10920 .graphql(
10921 graphql::ADD_COMMENT,
10922 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
10923 )
10924 .await?;
10925 let subject = data
10926 .pointer("/addComment/subject")
10927 .filter(|value| !value.is_null())
10928 .ok_or_else(|| SourceError::Malformed {
10929 message: "GitHub comment addition returned no subject".into(),
10930 })?;
10931 if required_str(subject, "id")? != issue.0 {
10932 return Err(SourceError::Malformed {
10933 message: "GitHub comment addition answered about another issue".into(),
10934 });
10935 }
10936 let added = data
10937 .pointer("/addComment/commentEdge/node")
10938 .filter(|value| !value.is_null())
10939 .ok_or_else(|| SourceError::Malformed {
10940 message: "GitHub comment addition returned no comment".into(),
10941 })?;
10942 let added = comment_from(added)?;
10943 self.remember_commented(&issue)?;
10944 Ok(Some(added))
10945 }
10946
10947 async fn edit_comment(
10948 &self,
10949 task: &NativeId,
10950 comment: &NativeId,
10951 body: &CommentBody,
10952 ) -> Result<Option<Comment>, SourceError> {
10953 let Some(issue) = self.commented_issue(task).await? else {
10954 return Ok(None);
10955 };
10956 let Some(held) = self.comment_on(&issue, comment).await? else {
10957 return Ok(None);
10958 };
10959 // A comment a copy wrote keeps its origin through an edit: its marker is carried over.
10960 let body = CommentOrigin::kept(&held, body)?;
10961 let data = self
10962 .graphql(
10963 graphql::UPDATE_COMMENT,
10964 json!({"input":{"id":comment.0,"body":body.as_str()}}),
10965 )
10966 .await?;
10967 let edited = data
10968 .pointer("/updateIssueComment/issueComment")
10969 .filter(|value| !value.is_null())
10970 .ok_or_else(|| SourceError::Malformed {
10971 message: "GitHub comment update returned no comment".into(),
10972 })?;
10973 let edited = comment_from(edited)?;
10974 if edited.id != *comment {
10975 return Err(SourceError::Malformed {
10976 message: "GitHub comment update returned the wrong comment".into(),
10977 });
10978 }
10979 self.remember_commented(&issue)?;
10980 Ok(Some(edited))
10981 }
10982
10983 async fn delete_comment(
10984 &self,
10985 task: &NativeId,
10986 comment: &NativeId,
10987 ) -> Result<Option<NativeId>, SourceError> {
10988 let Some(issue) = self.commented_issue(task).await? else {
10989 return Ok(None);
10990 };
10991 if !self.comment_is_on(&issue, comment).await? {
10992 return Ok(None);
10993 }
10994 let data = self
10995 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
10996 .await?;
10997 // The payload says nothing about the comment it removed, so what is checked is that
10998 // GitHub answered the mutation at all rather than leaving it unanswered.
10999 data.get("deleteIssueComment")
11000 .filter(|value| !value.is_null())
11001 .ok_or_else(|| SourceError::Malformed {
11002 message: "GitHub comment deletion returned no payload".into(),
11003 })?;
11004 Ok(Some(comment.clone()))
11005 }
11006
11007 /// Every request this source has recorded, and what each of GitHub's two budgets was
11008 /// attributed — read off the same accounting the session report is rendered from, so
11009 /// the two cannot count one request two ways.
11010 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
11011 Ok(Some(self.ledger.snapshot().metering()))
11012 }
11013
11014 /// Drop every item, search answer and board read this source holds, so the next command
11015 /// reads the board as a person has since left it.
11016 ///
11017 /// Every one of those is held on the assumption that nothing but this source writes the
11018 /// board while a command runs, which stops being true the moment the command is over: a
11019 /// body a person edited would be overwritten from the record held here, and a card they
11020 /// moved would be read as still where this source left it. The board's own field
11021 /// definitions go too, because a person can add or delete a `Status` option and a write
11022 /// resolved against the held list would not re-read on a miss. What stays is what stays
11023 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
11024 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
11025 /// accounting [`metering`](TaskSource::metering) answers from.
11026 ///
11027 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
11028 /// refused, because clearing it is what puts it right.
11029 async fn end_command(&self) -> Result<(), SourceError> {
11030 fn clear<T: Default>(held: &Mutex<T>) {
11031 *held
11032 .lock()
11033 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
11034 held.clear_poison();
11035 }
11036 clear(&self.created);
11037 clear(&self.updated);
11038 clear(&self.commented);
11039 clear(&self.board_cache);
11040 clear(&self.search_cache);
11041 clear(&self.narrowed_cache);
11042 clear(&self.search_next);
11043 clear(&self.resolved_cache);
11044 clear(&self.children_cache);
11045 clear(&self.fields_cache);
11046 self.expected.end_command();
11047 Ok(())
11048 }
11049}
11050
11051/// Each project's sub-issues as one command read them, keyed by the selector they were asked
11052/// for under, beside the project that selector named; see `children_cache`.
11053type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
11054
11055/// One issue comment as the contract carries it.
11056///
11057/// `author` is absent both when GitHub answers `null` for an account that no longer exists
11058/// and when it answers an actor with no login, because either way the source did not say who
11059/// wrote it — which is what an absent author means, rather than an author called nothing.
11060///
11061/// A comment a copy wrote here carries its origin as the hidden marker at the end of its body
11062/// — GitHub sets every comment's author and times itself, so there is nowhere else to keep it —
11063/// and is read back with the marker taken out and reported as its origin.
11064fn comment_from(value: &Value) -> Result<Comment, SourceError> {
11065 Ok(Comment {
11066 origin: None,
11067 id: NativeId(required_str(value, "id")?.to_owned()),
11068 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
11069 .map(str::to_owned),
11070 created_at: optional_time(value, "createdAt")?,
11071 updated_at: optional_time(value, "updatedAt")?,
11072 body: required_str(value, "body")?.to_owned(),
11073 url: optional_str(value, "url")?.map(str::to_owned),
11074 }
11075 .unmarked())
11076}
11077
11078/// The page of comments one issue node carries, resumed from `after`.
11079fn comment_page(
11080 node: &Value,
11081 issue: &str,
11082 after: Option<&str>,
11083) -> Result<Page<Comment>, SourceError> {
11084 let connection = node
11085 .get("comments")
11086 .filter(|value| !value.is_null())
11087 .ok_or_else(|| SourceError::Malformed {
11088 message: format!("GitHub issue {issue} answered with no comments connection"),
11089 })?;
11090 let items = optional_nodes(Some(connection), "issue comments")?
11091 .into_iter()
11092 .flatten()
11093 .map(comment_from)
11094 .collect::<Result<Vec<_>, _>>()?;
11095 let next = next_cursor(connection)?;
11096 if let Some(next) = &next {
11097 validate_cursor_progress(after, &next.0)?;
11098 }
11099 Ok(Page { items, next })
11100}
11101
11102/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
11103/// end — `None` when it carried none, or a page with more past it.
11104fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
11105 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
11106 return Ok(None);
11107 };
11108 if next_cursor(connection)?.is_some() {
11109 return Ok(None);
11110 }
11111 Ok(Some(
11112 optional_nodes(Some(connection), "blocked-by issues")?
11113 .into_iter()
11114 .flatten()
11115 .cloned()
11116 .collect(),
11117 ))
11118}
11119
11120/// Where the recorded tail of a dependency walk resumes; see
11121/// [`GitHubProjectsSource::recorded_edges`].
11122const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
11123
11124/// The board text field this source keeps a copy's origin in.
11125///
11126/// Named after the key it holds, and held to that name by the guard below rather than by
11127/// a reader noticing.
11128const ORIGIN_FIELD: &str = "onetaskgraph.origin";
11129
11130/// The metadata key that field holds.
11131///
11132/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
11133/// constructs or interprets the qualified id it carries. This source names it only to
11134/// route it — a short, typed value belongs in a typed field rather than in the body slot
11135/// a caller's own prose shares.
11136///
11137/// Restated rather than imported, because no plugin crate may depend on the engine. What
11138/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
11139/// target in `check`: it reads the engine's own literal and fails naming the file and the
11140/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
11141/// that creates a second item every run instead of finding the one it wrote — and that is
11142/// too late to learn it.
11143const ORIGIN_KEY: &str = "onetaskgraph.origin";
11144
11145/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
11146///
11147/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
11148/// is derived from the far end, never written down on the near item — so only a forward
11149/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
11150/// it did not come from, and it is told so rather than answered with an empty page that
11151/// reads as a walk which ended.
11152fn recorded_offset(
11153 cursor: Option<&str>,
11154 direction: Direction,
11155) -> Result<Option<usize>, SourceError> {
11156 cursor
11157 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
11158 .map(|offset| {
11159 if direction != Direction::DependsOn {
11160 return Err(SourceError::Config {
11161 message: format!(
11162 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
11163 reverse dependency read never issues; resume it in the direction \
11164 that reported it"
11165 ),
11166 });
11167 }
11168 offset.parse().map_err(|_| SourceError::Config {
11169 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
11170 })
11171 })
11172 .transpose()
11173}
11174
11175fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
11176 let mut page = offset_page(edges, offset, limit.max(1));
11177 page.next = page
11178 .next
11179 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
11180 page
11181}
11182
11183/// The kind of one issue reached through a dependency connection.
11184///
11185/// The same questions the board scan asks, over the fields the dependency document
11186/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
11187/// then anything with sub-issues or the marker is a project.
11188///
11189/// # Errors
11190///
11191/// A far end this board holds as a document is refused rather than reported. The two
11192/// answers that are not refusals would both be wrong: reporting it as a task names an id
11193/// no task read of this source can find, and reporting it as a project names one no
11194/// project read can. There is no third value to return — `ItemKind` has no document
11195/// variant, because nothing may point at a document — so the relationship itself is what
11196/// the person is told about.
11197fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
11198 let id = required_str(value, "id")?;
11199 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
11200 return Err(SourceError::Refused {
11201 message: format!(
11202 "GitHub issue {id} is a document of this board — its title begins \
11203 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
11204 on by one; next: remove that issue's blocking relationship on this board"
11205 ),
11206 });
11207 }
11208 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
11209 if parent.is_some() {
11210 return Ok(ItemKind::Task);
11211 }
11212 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
11213 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
11214 message: format!("GitHub issue {id}: {message}"),
11215 })?;
11216 let sub_issues = sub_issue_total(value)?;
11217 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
11218 ItemKind::Project
11219 } else {
11220 ItemKind::Task
11221 })
11222}
11223
11224/// The `IssueStateUpdateInput` one status target asks for.
11225///
11226/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
11227/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
11228/// a currently-closed issue: without that the item would read back `Unknown` and a copy
11229/// would report a change forever. A document has no status at all, and asks for neither.
11230fn state_input(target: Option<&StatusTarget>) -> Value {
11231 match target {
11232 Some(StatusTarget::Terminal(_, reason)) => {
11233 json!({"value":"CLOSED","stateReason":reason.reason()})
11234 }
11235 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
11236 // A document has no status, so a write of one says nothing about the issue's open
11237 // or closed state rather than forcing it open: `stateInput` is what carries that
11238 // instruction, and an explicit null asks for no change to it.
11239 None => Value::Null,
11240 }
11241}
11242
11243/// The metadata one write stores in the item's body slot.
11244///
11245/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
11246/// rather than carried: the kind marker so an empty project stays readable, the
11247/// repository list only when it is not exactly the issue's own repository, and the far
11248/// ends no relationship here can name.
11249///
11250/// The copy origin is the one typed field that is also mirrored here, and only as a
11251/// mirror: it lands in the board's origin field as well, which stays the one every reader
11252/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
11253/// and catches up with a write in seconds rather than minutes — can find the item by it.
11254/// A reader of the release before this one drops the slot's copy and reads the field, so an
11255/// item written here still reads with exactly one origin there.
11256fn slot_metadata(
11257 incoming: &Incoming<'_>,
11258 own_repository: Option<&Repository>,
11259 fallback: &[DependencyEdge],
11260) -> BTreeMap<String, Value> {
11261 let mut metadata = incoming.metadata.clone();
11262 match metadata.remove(ORIGIN_KEY) {
11263 Some(Value::String(origin)) if !origin.is_empty() => {
11264 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
11265 }
11266 _ => {}
11267 }
11268 match incoming.written.kind() {
11269 BoardKind::Work(kind) => metadata.insert(
11270 ItemKind::METADATA_KEY.to_owned(),
11271 Value::String(kind.marker().to_owned()),
11272 ),
11273 // A document is told by its title, so it carries no kind marker: that key names
11274 // what a dependency endpoint points at, and nothing may point at a document.
11275 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
11276 };
11277 incoming.classification.record(&mut metadata);
11278 let derivable = own_repository
11279 .map(|own| incoming.repositories == [own.clone()])
11280 .unwrap_or(incoming.repositories.is_empty());
11281 if derivable {
11282 metadata.remove(Repository::METADATA_KEY);
11283 } else {
11284 metadata.insert(
11285 Repository::METADATA_KEY.to_owned(),
11286 Value::Array(
11287 incoming
11288 .repositories
11289 .iter()
11290 .map(|repository| Value::String(repository.as_str().to_owned()))
11291 .collect(),
11292 ),
11293 );
11294 }
11295 // The typed lists are what land, whatever the caller's own metadata held under their
11296 // keys: a key of either name travelling beside the field would otherwise be a second
11297 // answer to the same question, and the field is the one the contract names.
11298 for (key, entries) in [
11299 (TaskRef::DELIVERS_KEY, incoming.delivers),
11300 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
11301 ] {
11302 set_task_list(&mut metadata, key, entries);
11303 }
11304 record_edges(&mut metadata, fallback);
11305 metadata
11306}
11307
11308/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
11309/// one slot's metadata, or no such key when there are none.
11310fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
11311 if fallback.is_empty() {
11312 metadata.remove(DependencyEdge::RECORDED_KEY);
11313 } else {
11314 metadata.insert(
11315 DependencyEdge::RECORDED_KEY.to_owned(),
11316 Value::Array(
11317 fallback
11318 .iter()
11319 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
11320 .collect(),
11321 ),
11322 );
11323 }
11324}
11325
11326/// Every label one item carries, from its content's own connection and nowhere else.
11327///
11328/// There is no second place to read one from: no document this source sends selects the
11329/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
11330/// cannot carry one at all. The module documentation records the three schema facts that
11331/// settle it.
11332fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
11333 optional_nodes(content.get("labels"), "content labels")?
11334 .into_iter()
11335 .flatten()
11336 .map(|v| {
11337 Ok(Label {
11338 id: NativeId(required_str(v, "id")?.to_owned()),
11339 name: required_str(v, "name")?.to_owned(),
11340 color: optional_str(v, "color")?.map(str::to_owned),
11341 })
11342 })
11343 .collect()
11344}
11345
11346/// The definition of each board field one item's values are values of, in the shape a read
11347/// of the board's own `fields` gives one.
11348///
11349/// A value names its field through a fragment on that field's own type, so the type is
11350/// known from which kind of value it is: a single-select value's field is a
11351/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
11352/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
11353fn field_definitions(field_values: &[Value]) -> Vec<Value> {
11354 field_values
11355 .iter()
11356 .filter_map(|value| {
11357 let field = value.get("field")?.as_object()?;
11358 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
11359 let typename = if value.get("text").is_some() {
11360 "ProjectV2Field"
11361 } else if value.get("name").is_some() {
11362 "ProjectV2SingleSelectField"
11363 } else {
11364 return None;
11365 };
11366 let mut defined = field.clone();
11367 defined.insert("__typename".to_owned(), json!(typename));
11368 // Only a text field holds a text value, so the value says the field's type too.
11369 if typename == "ProjectV2Field" {
11370 defined.insert("dataType".to_owned(), json!("TEXT"));
11371 }
11372 Some(Value::Object(defined))
11373 })
11374 .collect()
11375}
11376
11377fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
11378 let Some(node) = field_values
11379 .iter()
11380 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
11381 else {
11382 return Ok(None);
11383 };
11384 Ok(optional_str(node, "text")?.map(str::to_owned))
11385}
11386
11387fn valid_github_owner(owner: &str) -> bool {
11388 !owner.is_empty()
11389 && owner.len() <= 39
11390 && !owner.starts_with('-')
11391 && !owner.ends_with('-')
11392 && !owner.contains("--")
11393 && owner
11394 .bytes()
11395 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
11396}
11397
11398/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
11399/// neither of the two names a path segment already means.
11400fn valid_github_repository_name(name: &str) -> bool {
11401 !name.is_empty()
11402 && name.len() <= 100
11403 && name != "."
11404 && name != ".."
11405 && name
11406 .bytes()
11407 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
11408}
11409
11410fn valid_environment_name(name: &str) -> bool {
11411 let mut bytes = name.bytes();
11412 bytes
11413 .next()
11414 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
11415 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
11416}
11417
11418/// How many sub-issues one issue has.
11419///
11420/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
11421/// absent or non-integer one is a response this source cannot read — and reading it as
11422/// zero would classify a project as a task, which is exactly the mistake the marker
11423/// exists to keep from happening quietly.
11424fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
11425 let summary = issue
11426 .get("subIssuesSummary")
11427 .ok_or_else(|| SourceError::Malformed {
11428 message: "GitHub issue is missing subIssuesSummary".into(),
11429 })?;
11430 summary
11431 .get("total")
11432 .and_then(Value::as_u64)
11433 .ok_or_else(|| SourceError::Malformed {
11434 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
11435 })
11436}
11437
11438/// One issue's own `number`.
11439///
11440/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
11441/// an issue in this module asks for it. So a read of one that comes back without it, or
11442/// with something that is not an unsigned integer, is a response this source cannot read —
11443/// absence here is **not** "this issue has no number". A draft is the content that has
11444/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
11445/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
11446fn issue_number(issue: &Value) -> Result<u64, SourceError> {
11447 issue
11448 .get("number")
11449 .and_then(Value::as_u64)
11450 .ok_or_else(|| SourceError::Malformed {
11451 message: "GitHub issue number is missing or is not an unsigned integer".into(),
11452 })
11453}
11454
11455/// The `number` a creating mutation answered with, and `None` when it answered without one;
11456/// why a missing one is tolerated is at the call in `create_and_file_issue`.
11457fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
11458 match created.get("number") {
11459 None | Some(Value::Null) => Ok(None),
11460 Some(value) => value
11461 .as_u64()
11462 .map(Some)
11463 .ok_or_else(|| SourceError::Malformed {
11464 message: "GitHub created issue number is not an unsigned integer".into(),
11465 }),
11466 }
11467}
11468
11469fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
11470 value
11471 .get(field)
11472 .and_then(Value::as_str)
11473 .ok_or_else(|| SourceError::Malformed {
11474 message: format!("GitHub response is missing string field {field}"),
11475 })
11476}
11477
11478fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
11479 let found = required_str(value, field)?;
11480 if found.trim().is_empty() {
11481 return Err(SourceError::Malformed {
11482 message: format!("GitHub response has blank string field {field}"),
11483 });
11484 }
11485 Ok(found)
11486}
11487
11488/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
11489/// needs one — Linear spells them too, in its own description field.
11490///
11491/// Restated rather than shared, because a plugin crate depends on the contract crate and
11492/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
11493/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
11494/// source round-trips its own writes perfectly well under its own spelling.
11495const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
11496const METADATA_CLOSE: &str = "\n-->";
11497
11498/// What the composer puts between a non-empty visible body and the slot, and the one thing
11499/// the parser takes off the visible body when it takes the slot off — exactly once, so every
11500/// other trailing byte of the body comes back as it was written.
11501// 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.
11502const METADATA_SEPARATOR: &str = "\n\n";
11503
11504/// The visible body and the metadata slot at the end of it.
11505///
11506/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
11507/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
11508/// own content and is left alone. The visible body is everything before the slot less the
11509/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
11510fn metadata_body(
11511 body: Option<String>,
11512) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
11513 let Some(body) = body else {
11514 return Ok((None, BTreeMap::new()));
11515 };
11516 let Some(slot) = slot_span(&body)? else {
11517 return Ok((Some(body), BTreeMap::new()));
11518 };
11519 let metadata =
11520 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
11521 SourceError::Malformed {
11522 message: format!(
11523 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
11524 ),
11525 }
11526 })?;
11527 let before = &body[..slot.start];
11528 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
11529 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
11530}
11531
11532/// Where the metadata slot sits in one body, as byte offsets into it.
11533struct SlotSpan {
11534 /// Where [`METADATA_OPEN`] begins.
11535 start: usize,
11536 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
11537 encoded_start: usize,
11538 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
11539 encoded_end: usize,
11540 /// Just past [`METADATA_CLOSE`].
11541 end: usize,
11542}
11543
11544/// The slot at the very end of `body`, or `None` when it has none.
11545///
11546/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
11547/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
11548/// slot.
11549fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
11550 let Some(start) = body.rfind(METADATA_OPEN) else {
11551 return Ok(None);
11552 };
11553 let encoded_start = start + METADATA_OPEN.len();
11554 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
11555 return Err(SourceError::Malformed {
11556 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
11557 });
11558 };
11559 let encoded_end = encoded_start + relative_end;
11560 let end = encoded_end + METADATA_CLOSE.len();
11561 if !body[end..].trim().is_empty() {
11562 return Ok(None);
11563 }
11564 Ok(Some(SlotSpan {
11565 start,
11566 encoded_start,
11567 encoded_end,
11568 end,
11569 }))
11570}
11571
11572/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
11573/// slot as it was.
11574///
11575/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
11576/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
11577/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
11578/// or alone in an empty body — and a body with no slot that is given no metadata is
11579/// returned as it is.
11580fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
11581 let encoded = if metadata.is_empty() {
11582 None
11583 } else {
11584 Some(
11585 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
11586 message: error.to_string(),
11587 })?,
11588 )
11589 };
11590 Ok(match (slot_span(body)?, encoded) {
11591 (Some(slot), Some(encoded)) => format!(
11592 "{}{encoded}{}",
11593 &body[..slot.encoded_start],
11594 &body[slot.encoded_end..]
11595 ),
11596 (Some(slot), None) => {
11597 let before = &body[..slot.start];
11598 format!(
11599 "{}{}",
11600 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
11601 &body[slot.end..]
11602 )
11603 }
11604 (None, None) => body.to_owned(),
11605 (None, Some(encoded)) if body.is_empty() => {
11606 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
11607 }
11608 (None, Some(encoded)) => {
11609 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
11610 }
11611 })
11612}
11613
11614/// `body` with everything before its metadata slot replaced by `content`, and the slot
11615/// itself kept byte for byte.
11616///
11617/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
11618/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
11619/// `content` is empty — so a read of the result reports `content` as the visible body and
11620/// the slot's metadata exactly as it was.
11621fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
11622 let Some(slot) = slot_span(body)? else {
11623 return Ok(content.to_owned());
11624 };
11625 let kept = &body[slot.start..];
11626 Ok(if content.is_empty() {
11627 kept.to_owned()
11628 } else {
11629 format!("{content}{METADATA_SEPARATOR}{kept}")
11630 })
11631}
11632
11633/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
11634fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
11635 if entries.is_empty() {
11636 metadata.remove(key);
11637 } else {
11638 metadata.insert(
11639 key.to_owned(),
11640 Value::Array(
11641 entries
11642 .iter()
11643 .map(|entry| Value::String(entry.as_str().to_owned()))
11644 .collect(),
11645 ),
11646 );
11647 }
11648}
11649
11650fn compose_body(
11651 content: Option<&str>,
11652 metadata: &BTreeMap<String, Value>,
11653) -> Result<Option<String>, SourceError> {
11654 let visible = content.unwrap_or_default();
11655 if metadata.is_empty() {
11656 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
11657 }
11658 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
11659 message: error.to_string(),
11660 })?;
11661 Ok(Some(if visible.is_empty() {
11662 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
11663 } else {
11664 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
11665 }))
11666}
11667
11668fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
11669 value
11670 .get(field)
11671 .and_then(Value::as_bool)
11672 .ok_or_else(|| SourceError::Malformed {
11673 message: format!("GitHub response is missing boolean field {field}"),
11674 })
11675}
11676fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
11677 match value.get(field) {
11678 None | Some(Value::Null) => Ok(None),
11679 Some(value) => value
11680 .as_str()
11681 .map(Some)
11682 .ok_or_else(|| SourceError::Malformed {
11683 message: format!("GitHub response field {field} is not a string or null"),
11684 }),
11685 }
11686}
11687fn optional_nodes<'a>(
11688 connection: Option<&'a Value>,
11689 name: &str,
11690) -> Result<Option<&'a Vec<Value>>, SourceError> {
11691 match connection {
11692 None | Some(Value::Null) => Ok(None),
11693 Some(value) => value
11694 .get("nodes")
11695 .and_then(Value::as_array)
11696 .map(Some)
11697 .ok_or_else(|| SourceError::Malformed {
11698 message: format!("GitHub {name}.nodes is not an array"),
11699 }),
11700 }
11701}
11702fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
11703 let page_info = connection
11704 .get("pageInfo")
11705 .ok_or_else(|| SourceError::Malformed {
11706 message: format!("GitHub {name} has no pageInfo"),
11707 })?;
11708 if required_bool(page_info, "hasNextPage")? {
11709 return Err(SourceError::Malformed {
11710 message: format!(
11711 "GitHub {name} exceeds the supported nested connection size of {size}"
11712 ),
11713 });
11714 }
11715 Ok(())
11716}
11717fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
11718 optional_str(value, field)?
11719 .map(|timestamp| {
11720 timestamp.parse().map_err(|error| SourceError::Malformed {
11721 message: format!("GitHub response field {field} is not a timestamp: {error}"),
11722 })
11723 })
11724 .transpose()
11725}
11726fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
11727 if page.limit == 0 {
11728 Err(SourceError::Config {
11729 message: "page limit must be at least 1".into(),
11730 })
11731 } else {
11732 Ok(())
11733 }
11734}
11735fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
11736 let page = connection
11737 .get("pageInfo")
11738 .filter(|value| value.is_object())
11739 .ok_or_else(|| SourceError::Malformed {
11740 message: "GitHub connection is missing pageInfo".into(),
11741 })?;
11742 if required_bool(page, "hasNextPage")? {
11743 let cursor = required_str(page, "endCursor")?;
11744 validate_cursor_progress(None, cursor)?;
11745 Ok(Some(Cursor(cursor.into())))
11746 } else {
11747 Ok(None)
11748 }
11749}
11750fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
11751 if next.is_empty() || previous == Some(next) {
11752 Err(SourceError::Malformed {
11753 message: "GitHub pagination cursor is empty or did not advance".into(),
11754 })
11755 } else {
11756 Ok(())
11757 }
11758}
11759/// The version of this plugin's opaque narrowing-search cursor.
11760pub const SEARCH_CURSOR_VERSION: u32 = 4;
11761
11762#[derive(Serialize, Deserialize)]
11763#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
11764enum SearchConnection {
11765 Initial {},
11766 Continuing { after: Cursor },
11767 Exhausted {},
11768}
11769impl SearchConnection {
11770 fn after(&self) -> Option<&str> {
11771 match self {
11772 Self::Continuing { after } => Some(&after.0),
11773 _ => None,
11774 }
11775 }
11776 fn exhausted(&self) -> bool {
11777 matches!(self, Self::Exhausted { .. })
11778 }
11779 /// Whether a cursor naming this position, `offset` rows into its page, is one this
11780 /// plugin could have handed out: a page is resumed only part of the way through it — an
11781 /// offset of a whole page or more would skip rows nobody was given — an initial page
11782 /// only once some of it was handed out, and an exhausted connection has no page to be
11783 /// part of the way through.
11784 fn valid_resume(&self, offset: usize) -> bool {
11785 let within = offset < SEARCH_PAGE_SIZE as usize;
11786 match self {
11787 Self::Initial { .. } => offset > 0 && within,
11788 Self::Continuing { after } => !after.0.is_empty() && within,
11789 Self::Exhausted { .. } => offset == 0,
11790 }
11791 }
11792}
11793
11794/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
11795#[derive(Serialize, Deserialize)]
11796#[serde(deny_unknown_fields)]
11797struct SearchPosition {
11798 version: u32,
11799 connection: SearchConnection,
11800 /// How many rows of the page `connection` starts were already handed out.
11801 #[serde(default, skip_serializing_if = "is_zero")]
11802 offset: usize,
11803 #[serde(default, skip_serializing_if = "Vec::is_empty")]
11804 seen: Vec<NativeId>,
11805 #[serde(default, skip_serializing_if = "Vec::is_empty")]
11806 own: Vec<NativeId>,
11807}
11808impl Default for SearchPosition {
11809 fn default() -> Self {
11810 Self {
11811 version: SEARCH_CURSOR_VERSION,
11812 connection: SearchConnection::Initial {},
11813 offset: 0,
11814 seen: Vec::new(),
11815 own: Vec::new(),
11816 }
11817 }
11818}
11819
11820fn is_zero(offset: &usize) -> bool {
11821 *offset == 0
11822}
11823
11824fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
11825 cursor.map_or(Ok(0), |c| {
11826 c.0.parse().map_err(|_| SourceError::Config {
11827 message: "page cursor is invalid".into(),
11828 })
11829 })
11830}
11831fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
11832 if offset > items.len() {
11833 return Page::last(vec![]);
11834 }
11835 let tail = items.split_off(offset);
11836 let mut selected = tail;
11837 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
11838 selected.truncate(limit);
11839 Page {
11840 items: selected,
11841 next,
11842 }
11843}
11844
11845impl GitHubProjectsSource {
11846 async fn write_task_assets(
11847 &self,
11848 write: &ItemWrite<Task>,
11849 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
11850 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11851 let near = write.target.as_ref().unwrap_or(&write.item.id);
11852 for (key, entries) in [
11853 (TaskRef::DELIVERS_KEY, &write.item.delivers),
11854 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
11855 ] {
11856 TaskRef::listed(key, near, Some(&self.name), entries.clone())
11857 .map_err(|message| SourceError::Refused { message })?;
11858 }
11859 if self.priorities.is_none() && write.item.priority != Priority::None {
11860 return Err(self.holds_no_priority());
11861 }
11862 self.write_item(
11863 &Incoming {
11864 written: Written::Work(ItemKind::Task, &write.item.status),
11865 title: &write.item.title,
11866 content: write.item.content.as_deref(),
11867 assets,
11868 labels: &write.item.labels,
11869 metadata: &write.item.metadata,
11870 repositories: &write.item.repositories,
11871 classification: write.item.classification,
11872 parent: write.item.project.as_ref(),
11873 delivers: &write.item.delivers,
11874 delivered_by: &write.item.delivered_by,
11875 priority: self.priorities.as_ref().map(|_| write.item.priority),
11876 },
11877 write.target.as_ref(),
11878 &write.depends_on,
11879 )
11880 .await
11881 }
11882 async fn write_document_assets(
11883 &self,
11884 write: &ItemWrite<Document>,
11885 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
11886 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
11887 // A document takes part in no dependency graph, so there is no far end to write
11888 // natively and none to record: a caller naming one is told so rather than having it
11889 // stored under the reserved key, where a later read would report an edge the
11890 // contract says cannot exist.
11891 if !write.depends_on.is_empty() {
11892 return Err(SourceError::Refused {
11893 message: format!(
11894 "this write names {} dependencies for a document, and a document takes \
11895 part in no dependency graph; next: put the dependency on the task or \
11896 project the document is about",
11897 write.depends_on.len()
11898 ),
11899 });
11900 }
11901 self.write_item(
11902 &Incoming {
11903 written: Written::Document,
11904 title: &write.item.title,
11905 content: write.item.content.as_deref(),
11906 assets,
11907 labels: &write.item.labels,
11908 metadata: &write.item.metadata,
11909 repositories: &write.item.repositories,
11910 classification: write.item.classification,
11911 parent: write.item.project.as_ref(),
11912 delivers: &[],
11913 delivered_by: &[],
11914 priority: None,
11915 },
11916 write.target.as_ref(),
11917 &[],
11918 )
11919 .await
11920 }
11921}
11922
11923/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
11924/// itself, for the two things no journey can observe.
11925///
11926/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
11927/// with and without the call, that a settlement, a board listing and a metadata search each
11928/// read afresh after it — the resolved records, the written-item overlay, the board and its
11929/// search, and the narrowed searches. What they cannot reach is the held field definitions,
11930/// because a status write naming an option a person deleted is refused the same whether or
11931/// not the list is held, and a poisoned lock, because nothing outside the source can panic
11932/// while one of its locks is held. So these assert those directly, and every other holder
11933/// beside them so a holder added later without a clear in the call fails here.
11934#[cfg(test)]
11935mod end_command_tests {
11936 use super::*;
11937
11938 struct Token;
11939
11940 impl SecretResolver for Token {
11941 fn get(&self, var: &str) -> Option<SecretString> {
11942 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
11943 }
11944 }
11945
11946 fn source() -> GitHubProjectsSource {
11947 let config = serde_json::from_value(json!({
11948 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
11949 // Nothing here is sent: the source is only built and its state inspected.
11950 "endpoint": "http://127.0.0.1:9/graphql",
11951 }))
11952 .expect("a usable configuration");
11953 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
11954 .expect("the source builds")
11955 }
11956
11957 /// One issue as a board read answers it.
11958 fn resolved(source: &GitHubProjectsSource) -> Resolved {
11959 source
11960 .resolve(&json!({
11961 "id": "ITEM-1",
11962 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
11963 "body": "what a person may since have edited", "state": "OPEN",
11964 "stateReason": null, "url": null, "number": 1,
11965 "subIssuesSummary": {"total": 0},
11966 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
11967 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
11968 }))
11969 .expect("the item reads")
11970 .expect("an issue")
11971 }
11972
11973 /// Hold something in every holder the call clears, and the repository id it keeps.
11974 fn fill(source: &GitHubProjectsSource) {
11975 let item = resolved(source);
11976 source.created.lock().unwrap().push(item.clone());
11977 source.updated.lock().unwrap().push(item.clone());
11978 *source.board_cache.lock().unwrap() = Some(Board {
11979 id: "PVT-board".into(),
11980 fields: json!({"nodes": []}),
11981 items: vec![item.clone()],
11982 });
11983 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
11984 source
11985 .narrowed_cache
11986 .lock()
11987 .unwrap()
11988 .insert("status:todo".into(), vec![item.clone()]);
11989 source.children_cache.lock().unwrap().insert(
11990 NativeId("P-1".into()),
11991 (NativeId("P-1".into()), vec![item.clone()]),
11992 );
11993 source
11994 .search_next
11995 .lock()
11996 .unwrap()
11997 .insert("status:todo".into(), Some("cursor".into()));
11998 source
11999 .resolved_cache
12000 .lock()
12001 .unwrap()
12002 .insert(item.id.clone(), item);
12003 *source.fields_cache.lock().unwrap() = Some(BoardFields {
12004 id: BoardId::parse("PVT-board").unwrap(),
12005 fields: json!({"nodes": []}),
12006 });
12007 source
12008 .repository_cache
12009 .lock()
12010 .unwrap()
12011 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
12012 }
12013
12014 fn assert_dropped(source: &GitHubProjectsSource) {
12015 assert!(source.created().unwrap().is_empty(), "created");
12016 assert!(source.updated().unwrap().is_empty(), "updated");
12017 assert!(source.board_cache().unwrap().is_none(), "board");
12018 assert!(source.search_cache.lock().unwrap().is_none(), "search");
12019 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
12020 assert!(
12021 source.children_cache.lock().unwrap().is_empty(),
12022 "project children"
12023 );
12024 assert!(
12025 source.search_next.lock().unwrap().is_empty(),
12026 "search paging"
12027 );
12028 assert!(
12029 source.resolved_cache().unwrap().is_empty(),
12030 "resolved records"
12031 );
12032 assert!(source.fields_cache().unwrap().is_none(), "board fields");
12033 assert_eq!(
12034 source.repository_cache().unwrap().len(),
12035 1,
12036 "a repository's node id stays valid and is kept"
12037 );
12038 }
12039
12040 fn end(source: &GitHubProjectsSource) {
12041 tokio::runtime::Builder::new_current_thread()
12042 .build()
12043 .unwrap()
12044 .block_on(source.end_command())
12045 .expect("the command ends");
12046 }
12047
12048 #[test]
12049 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
12050 let source = source();
12051 fill(&source);
12052 end(&source);
12053 assert_dropped(&source);
12054 }
12055
12056 #[test]
12057 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
12058 fn poison<T: Send>(held: &Mutex<T>) {
12059 std::thread::scope(|scope| {
12060 let _ = scope
12061 .spawn(|| {
12062 let _guard = held.lock().unwrap();
12063 panic!("a failure while the lock is held");
12064 })
12065 .join();
12066 });
12067 assert!(held.is_poisoned());
12068 }
12069 let source = source();
12070 fill(&source);
12071 poison(&source.created);
12072 poison(&source.updated);
12073 poison(&source.board_cache);
12074 poison(&source.search_cache);
12075 poison(&source.narrowed_cache);
12076 poison(&source.search_next);
12077 poison(&source.resolved_cache);
12078 poison(&source.fields_cache);
12079 assert!(
12080 source.resolved_cache().is_err(),
12081 "a poisoned lock is refused before the call"
12082 );
12083 end(&source);
12084 assert_dropped(&source);
12085 }
12086}
12087
12088#[cfg(test)]
12089mod unprojectable_fields_tests {
12090 use super::*;
12091
12092 /// The field names `docs/metadata.md` says a `metadata_fields` entry may not name.
12093 fn documented() -> std::collections::BTreeSet<String> {
12094 let document = std::fs::read_to_string(
12095 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/metadata.md"),
12096 )
12097 .expect("docs/metadata.md is readable");
12098 let document = document.split_whitespace().collect::<Vec<_>>().join(" ");
12099 let lead = "a `field` equal, ignoring case, to ";
12100 let start = document.find(lead).expect("the refused-field sentence") + lead.len();
12101 let sentence = &document[start..];
12102 let sentence = &sentence[..sentence
12103 .find("; and two entries naming one field")
12104 .expect("the refused-field sentence ends at the duplicate rule")];
12105 let (own, github) = sentence
12106 .split_once(" or a field GitHub owns on every board (")
12107 .expect("the sentence names GitHub's own fields");
12108 own.split('`')
12109 .skip(1)
12110 .step_by(2)
12111 .chain(
12112 github
12113 .strip_suffix(')')
12114 .expect("GitHub's own fields close the sentence")
12115 .split(", "),
12116 )
12117 .map(str::to_owned)
12118 .collect()
12119 }
12120
12121 #[test]
12122 fn the_documented_refused_fields_are_exactly_the_ones_the_configuration_refuses() {
12123 let refused: std::collections::BTreeSet<String> = UNPROJECTABLE_FIELDS
12124 .iter()
12125 .map(|&name| name.to_owned())
12126 .collect();
12127 assert_eq!(documented(), refused, "the fields docs/metadata.md lists");
12128 let instance = SourceName::new("work").unwrap();
12129 for name in UNPROJECTABLE_FIELDS {
12130 for spelled in [name.to_owned(), name.to_uppercase()] {
12131 let entry = MetadataFieldConfig {
12132 field: spelled.clone(),
12133 key: "team.machine".to_owned(),
12134 path: Vec::new(),
12135 };
12136 let refused = MetadataField::resolve(vec![entry], &instance)
12137 .expect_err("an unprojectable field is refused");
12138 assert!(
12139 refused.to_string().contains(&format!("{name:?}")),
12140 "{spelled}: {refused}"
12141 );
12142 }
12143 }
12144 }
12145}