onetaskgraph_github_projects/lib.rs
1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so a write without [`GitHubProjectsConfig::repository`] is refused
11//! naming the field — but that repository is the *fallback*, not the home of every item.
12//!
13//! <!-- llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] The rule's one
14//! executable source is `GitHubProjectsSource::creation_target`; this is where a reader of
15//! the module meets it, and `tests/plugin.rs` drives every arm below against the loopback
16//! board and asserts on `createIssue`'s own `repositoryId`, so the prose cannot outlive a
17//! change to the rule. -->
18//! **Which repository an issue is created in is decided by the item's own `repositories`
19//! field, under one rule.** Exactly one entry names the repository the issue is created in:
20//! a task issue is where a person finds the work from the repository it changes, and one
21//! filed in a board's nominated repository is invisible from every other. Zero entries, or
22//! two or more, name none, so a task's or a document's issue is created in the repository
23//! its parent project's issue lives in — read from the board, or from this process's own
24//! record of a project it created earlier in the same command — and a project's issue, or
25//! a task or document written with no parent, is created in the configured `repository:`.
26//! What that rule refuses, it refuses before `createIssue`, so no issue is half-created. An
27//! existing issue is never moved: the update path leaves the issue where it is and records
28//! the list in the metadata slot when it differs, so the read side's derivation and the
29//! creation rule agree by construction.
30//! <!-- llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate] -->
31//!
32//! **A document is an ordinary issue whose title begins [`DESIGN_TITLE_PREFIX`].** A
33//! board has no document type and nothing but issues to hold one in, so the title is the
34//! discriminator and it is the whole of it. The title this source *reports* is the one a
35//! person wrote, with the prefix taken off — the same way the metadata slot is taken off
36//! the body so `content` is what the person wrote — and writing a document puts the prefix
37//! back, so a round trip returns the title that went in.
38//!
39//! **Telling a document from a project from a task.** The design prefix is read **first**:
40//! a document is never a project and never a task, whatever sub-issues it has or does not
41//! have. Only then does the rest apply — a board issue is a project when *either* it has
42//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
43//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
44//! necessary: it is what makes an *empty* project — the state a project copy passes
45//! through between creating the project and filing its first task — readable as a
46//! project, while the sub-issue arm lets a person author a project on the board by hand
47//! with no knowledge of this product's metadata at all. Reading the prefix later than the
48//! sub-issue rule would make a design issue with no sub-issues an empty project, which is
49//! exactly the state that rule exists to catch. Pull requests are neither a project nor a
50//! task nor a document and are ignored.
51//!
52//! **A task's comments are its issue's comments.** They are read off `Issue.comments` and
53//! written with `addComment`, `updateIssueComment` and `deleteIssueComment`, and a comment's
54//! id is GitHub's own node id for the `IssueComment`. Two things GitHub decides are refused
55//! rather than papered over: a board **draft** is not an issue and has no comments at all, so
56//! a comment call on one is refused rather than answered with an empty page; and GitHub signs
57//! every comment as the account the token belongs to, so a comment handed an author of its
58//! own is refused rather than posted under another name. GitHub's comment mutations take the
59//! comment's id and nothing else, so an edit or a delete first reads which issue that comment
60//! is on, and a comment on some other issue is one this task does not have.
61//!
62//! **Where an entity is, is a link.** Every project, task and document this source reports
63//! carries a [`Location::Url`] naming the issue's own web address — the same address the
64//! `url` field already reports, in the shape that says a reader can open it. That is the
65//! contrast the location contract exists for: a reader holding an entity from this source
66//! is handed something to link to and one holding an entity from a folder of Markdown is
67//! handed a path, and neither has to know which plugin answered. It does not replace or
68//! derive from `url`; that field goes on reporting what it always reported.
69//!
70//! **Where metadata lives.** Short typed things go to typed fields and native relations:
71//! status to the board's `Status` single-select and the issue's own state, the copy
72//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
73//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
74//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
75//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
76//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
77//! why neither can hold a caller's own prose. Setting one caller key on its own — on a task,
78//! a project or a document alike — is one update of the issue body that changes that slot
79//! and not one byte outside it, and it is not sent at all when the key already holds the
80//! value. The link a copy records on an item it copied, `onetaskgraph.copies`, is small and
81//! is kept in that same slot, written by that same update.
82//!
83// llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This public module documentation is a required user-facing description; the loopback plugin tests and shared live journey drive StatusMapping resolution, both mutations, and observed read-back together.
84//! **Status.** `status_mapping` is per-instance configuration, in the shared grammar
85//! [`onetaskgraph_plugin_api::StatusMapping`] documents, from a status category to an
86//! option of the board's one `Status` field for a task and for a project: a bare name is
87//! the option for both kinds, `null` disables the category for both, and `{task, project}`
88//! names it per kind. A category the mapping does not mention keeps its shipped default for
89//! both kinds; one it mentions is exactly what it configures, so a per-kind object no longer
90//! gets the shipped default for the kind it leaves out. Two categories one kind would read
91//! back from one option are refused as the configuration is read, ignoring case, while one
92//! option may stand for different categories of the two kinds. Writes go by the kind of the
93//! item written: a status that kind has no option for, or whose option the board lacks, is
94//! refused before any mutation, naming the source, the kind, the category and the key
95//! `status_mapping.<category>.<kind>` — there is no fallback. `done` selects its mapped
96//! option and closes the issue as `COMPLETED`; `cancelled` selects its mapped option and
97//! closes it as `NOT_PLANNED`, for either kind. Every open category reopens a closed issue
98//! before selecting its option. Reads give a closed issue's reason precedence over its
99//! option, while an open issue's option decides its category through its own kind's
100//! mapping, and an option that mapping does not name reads as `unknown` under its own name.
101//! The guarded [`GitHubProjectsSource::status_options`] and
102//! [`GitHubProjectsSource::fields`] operations are the one path here that calls
103//! `updateProjectV2Field`: GitHub replaces the whole option list, so they preserve every
104//! existing option id and verify the field and item assignments immediately afterwards.
105//! They ask for both kinds' options, counting a terminal category's mapped option as
106//! configured because a terminal write refuses without it. No ordinary source read or
107//! write calls that mutation, whose
108//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
109//! and a mistake destroys every item's status. A status this board cannot represent is a
110//! refusal naming the status and the instance instead.
111//!
112//! `unknown` has no shipped option because this source cannot preserve an open-ended
113//! status word: it writes an existing board option and never
114//! creates an option. An operator may map `unknown` to one existing option, in which case
115//! every unknown word lands on that option and reads back as `unknown` under the option's
116//! name. This differs from `local-md`, which writes and reads the original word itself.
117//!
118//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
119//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
120//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
121//! finished tasks were only moved to a "Done" column would read 0% complete forever.
122// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
123//!
124//! # What this source declares, field by field
125//!
126//! One verdict per field of [`Capabilities`], and what `Native` means when this source
127//! says it. *Proven* means a shared journey drives it against the real
128//! binary over this source's own row in `crates/onetaskgraph-e2e-support/src/fixtures.rs`, and
129//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
130//! [`capabilities`](TaskSource::capabilities) from parting.
131//!
132//! | Field | Verdict |
133//! | --- | --- |
134//! | `projects` | **Supported and proven,** and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one *asks that issue* for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
135//! | `documents` | **Supported and proven.** A board holds issues, so a document is one: the issue whose title begins [`DESIGN_TITLE_PREFIX`]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
136//! | `comments` | **Supported and proven,** over the task issue's own comment connection, oldest first and paged by GitHub's own cursor; added, edited and removed through GitHub's comment mutations, paced as every other mutation is. A draft item has no comments on GitHub and is refused, and so is an author, because GitHub records the signed-in account as every comment's author. |
137//! | `assets` | **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. |
138//! | `priority` | **Supported and proven** by an instance configured with `priority_mapping`, and declared unsupported by one without it, which reports every task's priority as `none` and sends exactly the requests it sent before priorities existed. The priority is the board's single-select `Priority` field: no value is `none`, a mapped option is its level, matched case-insensitively, and an option the mapping does not name fails the read of that task, naming the option. A write selects the mapped option, or clears the value for `none`; a board without the field or the option is refused, pointing at `sources fields`, which is the one thing that creates either. |
139//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
140//! | `filter_by_comment_activity` | **Supported, and exact** for comments created and for comments edited at or after `commented_since`, in every repository — of any owner — the board's items live in. Applied by asking a narrower question rather than by reading the board: GitHub's issue search scoped by `project:<owner>/<number>` alone, with an `updated:>=` qualifier, names the candidates, and each candidate's own comments confirm it, so `ProjectV2.items` is never read. That rests on GitHub moving an issue's `updatedAt` when a comment on it is added **or edited**, which the credentialed journey `an_edited_comment_moves_its_issue_and_is_selected_since` re-takes on every run of this lane. The search is an index that lags a write — the credentialed lane has watched it miss a newly commented issue for thirty seconds — so an issue this process itself commented on in the same command is a candidate whatever the search says, read by its own node if the search did not name it, and has its comments read rather than being ruled out by an `updatedAt` from before the comment. A comment another process wrote is found only once the index has it, so a caller asking again from its last instant should overlap the two generously. |
141//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
142//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
143//! | `filter_by_status` | **Supported and proven,** over the board's `Status` option and the issue's open or closed state, through this instance's own `status_mapping` for the item's kind — a task query by the task half, a project query by the project half, `unknown` included. |
144//! | `filter_by_metadata` | **Supported, and asked of GitHub.** A query naming metadata values is one board-scoped issue search with each value a quoted phrase `in:body` — GitHub's index covers the metadata comment at the end of the body, which is where caller metadata lives — and every candidate is confirmed against its own parsed metadata comment, so only an item holding that string at that key and path is returned. **A value with no letter or digit is refused** — the empty string, whitespace or punctuation alone — before any request, as a `SourceError::Refused` (wire kind `refused`) naming the value: GitHub's index holds words, so no bounded query can find such a value, and this source neither reads the whole board for it nor answers it as empty. |
145//! | `filter_by_origin` | **Supported, and asked of GitHub without enumerating the board.** The union of three reads, each confirmed by an exact match against the item's own origin field: the board's field filter over the `onetaskgraph.origin` text field, the issue search for the id as a phrase in the body where a write of this release mirrors it, and this process's own writes. See *Where a read-after-write guarantee comes from* for the window the three leave. |
146//! | `search_title` | **Supported, and asked of GitHub for a task,** over `Issue.title`: a task query's text is one board-scoped issue search for it as a phrase `in:title`, every candidate confirmed by the case-insensitive substring rule. GitHub matches whole words, so a task holding the text only inside a longer word is not returned — a narrowing this source declares rather than hides. **A text with no letter or digit that is not blank is refused** — `--` for one — before any request, as the same `refused` error naming the text, for the reason a metadata value like it is; a blank text is not refused, and keeps the board read it always had, confirmed by the same substring rule. A project query's text, and a document query's text when the query is scoped to no project, is that same board-scoped search for the same phrase in the same fields, refused on the same terms, every candidate confirmed by its kind and by the same substring rule, so it narrows exactly as a task's does; a document query scoped to one project sends no search, reads that project's sub-issues and confirms its text over them by the substring rule alone, so it is neither narrowed to whole words nor refused for a text with no letter or digit. A board draft is not an issue, so no text search lists one, a draft titled as a document included. |
147//! | `search_content` | **Supported,** on the same terms, `in:body`, over the visible body — the trailing metadata comment is not part of what the substring rule confirms. |
148//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
149//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
150//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
151//!
152//! Image writes use `POST https://uploads.github.com/user-attachments/assets` with `name`,
153//! `content_type` and the issue repository's numeric `repository_id` as query parameters,
154//! the source token as `Authorization: Bearer`, the image content type as `Content-Type`,
155//! and the raw bytes as the request body. The numeric repository id is read at most once
156//! per repository per source instance. A loopback API endpoint also selects the loopback
157//! uploads host; production needs no assets repository, commits or extra configuration.
158//! Every returned JSON `url` is read with the same token before a body is written: only
159//! a 2xx verification succeeds. A refusal names the asset, URL and HTTP status; an
160//! anonymous 404 does not invalidate an authenticated 200. An upload refusal names the
161//! asset and HTTP status and says the token type may not be accepted by the upload
162//! endpoint. A classic PAT was measured accepted; no acceptance claim is made about
163//! fine-grained PATs or OAuth tokens.
164//! The body keeps the authored alt text while `./<name>` becomes the attachment URL,
165//! and `onetaskgraph.assets` records `{sha256, url}` by name. A matching destination
166//! SHA-256 reuses its URL without an upload or verifying read; changed bytes are uploaded
167//! and verified afresh, in the repository the issue already lives in on an update.
168//! An uploaded image renders for the viewers GitHub lets read it, like the issue text beside it.
169//! Uploads take the same mutation spacing as content writes. Both uploads and verification
170//! reads wait out classified rate limits within the configured per-call retry budget,
171//! on the supplied clock; verifying reads take no mutation slot.
172//!
173//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
174//! source has documents and that its tasks have comments, both of which hold — and the three
175//! facts behind the uniform `Native` on the
176//! predicates beside it are recorded below rather than re-derived, because a reader who
177//! takes `Native` to mean *the remote service filters* will read that uniformity as a
178//! lie.
179//!
180//! First, the plugin contract defines `Support::Native` as *the source applies this
181//! predicate itself*, and says nothing about where it applies it. What the declaration
182//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
183//! — so that the engine may push it down and apply nothing of its own.
184//!
185//! Second, this source can keep that promise for every predicate at no additional API
186//! cost, because whichever of the reads below answers a query has already read every
187//! candidate that query will return before it filters anything. Filtering those items is
188//! in-process work over data already in hand.
189//!
190//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
191//! applied in process over what that question returned. A project filter has a relationship — a
192//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
193//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
194//! a search for metadata values, is the board-scoped issue search carrying the text and each
195//! value as quoted phrases; an origin is the board's own field filter over its origin field
196//! beside the same search for the id. **The text search narrows, and that is this source's
197//! declared semantics:** GitHub matches whole words where the substring rule this source and
198//! the local Markdown source confirm with would match inside one, so an item holding the text
199//! only inside a longer word is never a candidate. Every item returned does contain the text.
200//! A project query's text, and a document query's scoped to no project, is that same search
201//! and narrows on the same terms, its candidates confirmed by their kind as well.
202//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
203//! so those three are applied in process over the candidates, and a query carrying none of
204//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
205//! engine compensate for work this source has already done, and declaring `projects` native
206//! while ignoring the filter (which this source once did) silently returns another project's
207//! tasks, because the engine trusts the declaration and applies nothing locally.
208//!
209//! # The three ways this source reaches an item, and what each costs
210//!
211//! A board read is charged for what its *nested* connections could return rather than for
212//! what was asked, so one whole-board read costs the same whether the question was about
213//! one project or about all of them. That is why a question about one project is never
214//! answered by reading the board:
215//!
216//! | The question | What is sent | What it costs |
217//! | --- | --- | --- |
218//! | 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 |
219//! | 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 |
220//! | 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 |
221//! | 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 |
222//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
223//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
224//! | 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 |
225//! | 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 |
226//! | 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 |
227//! | 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 |
228//! | 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 |
229//! | 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 |
230//!
231//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
232//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
233//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
234//! equal to declared points equal to the row. They include the origin lookup and the
235//! field/repository discovery a create needs. A bound re-copy changes status, priority,
236//! content and metadata; comment recount means a subsequent detail read. Each request here
237//! costs one declared point. A membership beyond the embedded page can additionally require
238//! the one-point membership recovery described above. A bound re-copy of a task filed under a
239//! project adds one read, the engine confirming that project's link by its own id once per
240//! command; and the same-source far ends a write newly names — those that do not already block
241//! the item, whose own read answered for them — are read together by their own ids,
242//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
243//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
244//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
245//!
246//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
247//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
248//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
249//! holds both halves.
250//!
251//! **An existing item is written body last.** A bound re-copy and a `task update` send its
252//! board fields first — the `Status` option and the `Priority` together, in one request — then
253//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
254//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
255//! undoing an earlier field when a later one fails, so that order is what makes a write
256//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
257//! the one piece of metadata written before the body, an origin a copy re-points, is put back
258//! when a later write is refused — and when putting it back is refused too, the write's own
259//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph-github-projects-e2e/tests/e2e/write_order.rs` refuses each
260//! of those writes in turn, whole and as one aliased field failing after the one before it.
261//!
262//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
263//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
264//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
265//!
266//! - **A board is accepted at creation but its item is not answered, so a create still files
267//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
268//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
269//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
270//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
271//! against a real board on 2026-10-01 with a create sending the board there and reading the
272//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
273//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
274//! refused "Content already exists in this project" — GitHub had filed the issue after
275//! answering, and refuses a second filing rather than answering with the item it holds. A
276//! create therefore sends no `projectV2Ids` and files the issue with
277//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
278//! real is the read before it: the board's fields and the repository's id together, in
279//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
280//! - **A comment still reads its target first, so a comment is 2 requests.**
281//! `AddCommentInput.subjectId: ID!` is declared there with
282//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
283//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
284//! project's issue, a document's issue, an issue on no board of this source and a pull
285//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
286//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
287//! refuses those by name.
288//!
289//! | Verb | Requests / points | Documents |
290//! | --- | --- | --- |
291//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
292//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
293//! | 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 |
294//! | 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 |
295//! | 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 |
296//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
297//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
298//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
299//! | recount | 1 | ISSUE_DETAIL |
300//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
301//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
302//! | content | 2 | ISSUE, UPDATE_ISSUE |
303//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
304//! | 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 |
305//! | record only | 1 | ISSUE |
306//!
307//! <!-- github-search-paging:start -->
308//! Board-scoped text, metadata, project-name and comment-activity searches send every
309//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
310//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
311//! needs rows. A page is never resized to the rows still needed: GitHub orders one
312//! search differently at different page sizes, so one fixed size makes a paged walk
313//! send exactly the requests one whole read sends, and the answer's order is the order
314//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
315//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
316//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
317//! returned and fetched pages: a limit is sliced from the pages it needs, and local
318//! confirmation can require more candidates than matching rows. Walking all pages
319//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
320//! cursor and how far into that page the last answer stopped, and resumes in the same
321//! process or a new one, without duplicates or gaps. It carries no rows: one process
322//! sends each page's search once, and a new process re-reads only the page it resumes
323//! in, then sends a further page once, never as a re-read, only when its limit still
324//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
325//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
326//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
327//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
328//! not required to include the original process's writes still omitted by the index.
329//! <!-- github-search-paging:end -->
330//!
331//! The board half of an issue — its board item's id, its `Status` option and this
332//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
333//! an item reached any of those ways resolves through the same
334//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
335//! same status, the same labels and the same qualified id. That connection comes back a
336//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
337//! on the page in hand and — only if that page reports more of the connection — in the
338//! last row's read of that one issue's memberships, resumed from the page's own cursor and
339//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
340//! report, which is what keeps an id naming another repository's issue from being answered
341//! as an item of this board; and because the page is where the search starts rather than
342//! where it ends, that answer is one about a connection read to exhaustion and never about
343//! an unread page. Nothing costs the extra read but an issue on more boards than a page
344//! holds: an issue this board really does not hold reports no next page, so its
345//! memberships are already exhausted where they arrived.
346//!
347//! **No document here selects the board's own `Labels` field, and nothing is lost by
348//! that.** An item's labels are read from its content alone, wherever that content is
349//! reached: the three documents above select `Issue.labels` on the fragment, and
350//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
351//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
352//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
353//! create one, and `ProjectV2FieldValue` — the whole of what
354//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
355//! it from the content, for every content type it exists on, and there is nothing it can
356//! hold that the content does not already say: for an `Issue` it *is* that issue's own
357//! labels, so selecting it beside them unions a set with itself.
358//!
359//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
360//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
361//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
362//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
363//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
364//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
365//! label set, and that set is the fixture issue's own, by
366//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
367//! item whose content is a draft reports an empty set, by
368//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
369//! selection is held over [`graphql::DOCUMENTS`] by
370//! `no_document_selects_the_boards_own_labels_field`.
371//!
372//! The whole-board row is still the board's own item connection, and deliberately: a
373//! **draft** board item is not an issue, so no search can list one, and the reads that have
374//! to answer for the whole board are the ones whose cost is the board's size anyway.
375//!
376//! **A question about one item this source already names by id never lists the board.**
377//! Whether that item is on this board, and what its board fields are, is answered by reading
378//! that item — its own `Issue.projectItems`, walked to exhaustion by
379//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
380//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
381//! holds. That covers a write's destination, the project a new item is filed under, a
382//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
383//! and the delete that takes back an item a copy made. What such a write needs of the board
384//! and the item does not carry — the board's id, the `Status` and origin field definitions —
385//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
386//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
387//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
388//! minutes. Scanning this host's 842-item board has refused a document copy and an update
389//! even though the items' own reads named that board. A scan there gives the wrong answer
390//! as well as paying for every page. So a `board.items` lookup does not belong on any of
391//! those paths.
392//!
393//! **What a read may return is capped too, and that cap is on the document rather than on
394//! the board.** GitHub limits the number of nodes **one query may return** to
395//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
396//! an error naming the connection the count crossed at, not a slow or a partial result.
397//! Every board this source reads is refused the same way, so no board is too big for these
398//! documents and none is small enough to save one that is over.
399//!
400//! The count is arithmetic over the document's own text: each connection contributes the
401//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
402//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
403//! restate them — `github-graphql-node-count` implements them, and
404//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
405//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
406//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
407//! same text on every run and fails naming any that reaches the limit — so a connection
408//! added to a shared fragment is caught there rather than by GitHub.
409//!
410//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
411//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
412//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
413//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
414//! effectively squared there, which is why it is the one the limit is most sensitive to.
415//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
416//! page of memberships misses is recovered by one further read rather than refused, so it
417//! buys a bound every read pays for at the price of a request only a multi-board issue
418//! pays.
419//!
420//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
421//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
422//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
423//! `cost` is rate-limit points, metered per hour across everything one credential does; it
424//! is what the two limiters [`Limiter`] tells apart meter, and a document under
425//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
426//! that second number, and `tests/point_cost.rs` pins every document in
427//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
428//! one under, the pin itself is the check. The credentialed lane reconciles both figures
429//! against GitHub's own, off a probe it already sends.
430//!
431//! **What is pinned that way is a per-document price and never a session's.** The record in
432//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
433//! **requests** and **worst-case nodes** — and neither is points. What one whole session
434//! consumes of the hourly point allowance is observable only from a credentialed run's own
435//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
436//! and what `tests/live.rs` prints at the end of every run.
437//!
438//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
439//!
440//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
441//! enumerations of a board can supply one alone.** Resolving a node id is strongly
442//! consistent, so a read by id and a project's own sub-issues are already current. The
443//! other two are not, and they are behind by different amounts and in different directions:
444//!
445//! - GitHub's **issue search** is an index and answers a write made moments ago with the
446//! value from before it — usually for a second or two.
447//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
448//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
449//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
450//! for *minutes*, while `Issue.projectItems` names the same membership at once.
451//!
452//! That second one is a measurement rather than a caution. This repository's own
453//! credentialed journey writes a project and waits for the board to report it, then writes a
454//! task and waits for the same thing seconds later on the same board: the project wait is
455//! answered through the search and converged in two or three attempts in each of three runs,
456//! and the task wait is answered through `ProjectV2.items` and converged in none of them
457//! inside thirty. Separately, an item added to a second and larger board was read back by
458//! `Issue.projectItems` on that board's own id while every one of that connection's nine
459//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
460//! through the lagging one alone is what had a board read deny an issue that had certainly
461//! landed on it.
462//!
463//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
464//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
465//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
466//! search reports what the projection is behind on. What closes the last
467//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
468//! source answers is completed with what this process itself wrote, so an item created
469//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
470//! nothing is written down, and the record dies with the process. **A wait that has to
471//! observe GitHub's own data cannot be answered from that record** — which is why the
472//! credentialed journey asks through a source built afresh, and why the union above rather
473//! than a longer wait is what makes such a wait converge.
474//!
475//! **A narrowed read is the same bargain, stated for each of the three predicates it
476//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
477//! than walking the board, and every such answer is completed with what this process wrote —
478//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
479//! each filtered by the same predicates as the rest — so an item this command wrote a moment
480//! ago is returned by a query that matches it whether or not the index has caught up. An item
481//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
482//! consistent. What is left is stated rather than papered over:
483//!
484//! | Read | Finds | Behind by |
485//! | --- | --- | --- |
486//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
487//! | 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 |
488//! | 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 |
489//! | origin, third read | this process's own writes | nothing |
490//!
491//! So an origin carrier another process added within the last second or two, before either
492//! index has it, can be missing from an origin query, and one written by the release before
493//! this one — its origin in the field alone — can be missing for as long as the board's own
494//! item connection is behind on it. A copy that must not duplicate its own earlier write
495//! relies on the link it records, not on either index. **A board draft is not an issue**, so
496//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
497//! lists one, the origin lookup drops any the board's own field filter names, and one this
498//! process wrote is not added back either.
499//!
500//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
501//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
502//! body's metadata slot, so the issue search can find it in seconds. The field is
503//! authoritative: this source reads an item's origin from the field alone, so a slot that
504//! disagrees with it, or holds one where the field holds none, is never read as a second
505//! origin — and the release before this one reads the slot, drops that key's copy for the
506//! field's, and sees the same one origin.
507//!
508//! Filtering happens before paging, so a page of a filtered result is a page of the
509//! survivors rather than the survivors of a page. Label matching and the substring rule a
510//! text candidate is confirmed by answer the same question the same way the local Markdown
511//! source's do; which candidates a text search has to confirm is GitHub's word match, which
512//! is the one place the two sources can answer the same text differently.
513//!
514//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
515//! has one source, `capabilities`, and the note above is the reasoning behind it rather
516//! than a second copy of it: without the three facts recorded here a reader takes the
517//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
518//! crate's own capabilities test, which pins every field of it against a fully spelled-out
519//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
520//! fails to compile there rather than going unasserted. -->
521//! The fixture-server tests above run wherever this crate is selected; the credentialed
522//! lane runs in the same required check, beside them, and can fail it — it verifies the
523//! 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,
524//! one filed under neither, a label on one of the three and a closed status on another —
525//! because that shape is what tells an honoured predicate from an ignored one: a board
526//! holding a single project answers a project filter the same way whether or not this
527//! source applies it, which is exactly how the defect above went unseen.
528//!
529//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
530//! and the scratch repository `GH_PROJECTS_REPOSITORY` names:
531//! `nickderobertis/onetaskgraph-live-scratch`. It refuses the core repository before any
532//! session or request, independently of the live demand. Fix that variable in the machine's
533//! onetaskgraph `secrets.env` or the environment it pushes from, such as ai-orchestrator's
534//! `.env`. The targets are declared once in `onetaskgraph_github_live`, the lane's own
535//! policy crate; absent credentials or nominations otherwise skip.
536//!
537//! GitHub Actions artifacts carry `ci-<run id>-<attempt>-<micros>`, naming their writing
538//! run and attempt; invalid Actions identity refuses before writing. Other runs retain
539//! `<host>-<process>-<micros>`. Own cleanup matches the whole stamp. Machine residue stays
540//! the owning machine's lock sweep's; Linear always keeps that form and is unaffected.
541//! The hourly janitor is cleanup, never a test lane: scratch CI residue is removed only
542//! after its run reads back as `completed`, immediately before the listed-artifact batch.
543//! Failed or incomplete ownership reads preserve residue. A 24-hour waiting period
544//! is a margin, never the ownership authorisation. Cleanup leaves machine stamps
545//! and the board's `onetaskgraph.origin` field untouched.
546//!
547//! # What a session of requests costs, and where the report is
548//!
549//! This source records **every** request it sends into [`accounting::Accounting`], at
550//! `send_once` — the one place a request leaves this crate, which is why a read path added
551//! later is counted without anybody remembering to count it. That is the whole of what this
552//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
553//! session's spend is arrived at, and what it deliberately does not know are set out.
554//!
555//! What one whole session of the live journey costs, counted that way against this crate's
556//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
557//! reduction it came out of, and with what it does and does not say about rate-limit points.
558//!
559//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
560//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
561//! code path — no environment variable, no feature, no build configuration — because an
562//! instrument nobody switches on measures nothing, and
563//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
564//! source's counts the whole session rather than this source's share. The credentialed lane
565//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
566//! passed or failed.
567//!
568//! **A live session refuses to start unless the account can afford it.** Before it does any
569//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
570//! GitHub documents as not counting against the REST rate limit and which answers both of
571//! its budgets at once — and starts only if, for each of them, what remains minus this
572//! session's estimated cost is still at least
573//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
574//! allowance. A session that cannot **declines**: it did not run, so it is
575//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
576//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
577//! waiting for the budget to come back. The estimate is derived offline from
578//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
579//! which is also where the published rule that model rests on is cited; the accounting
580//! above records the gate's own read like any other request, and
581//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
582//!
583//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
584//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
585//! text, which is what lets it run on every platform and on a pull request from a fork with
586//! no credential — and that is what actually stops a regression merging. But an offline
587//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
588//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
589//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
590//! number of nodes this query may return"* and whose `cost` is what that document would
591//! spend, both for a document **without executing it**, and the lane asks it for every query
592//! document this source sends, under the largest bindings this source sends, and fails when
593//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
594//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
595//! one; the offline pins still cover it. It records what those calls reported about the
596//! account's own allowance, because whether asking is free is a thing to observe rather than
597//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
598//! and `cost` is metered against an hourly allowance the accounting above reads off a
599//! credentialed run's own response headers.
600//!
601//! **GitHub has two rate limiters and this source is refused by both, so nothing here
602//! treats them as one thing.** The primary budget is the hourly allowance `gh api
603//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
604//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
605//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
606//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
607//! one part of.
608#![deny(missing_docs)]
609
610use std::collections::BTreeMap;
611use std::sync::{Arc, Mutex};
612use std::time::Duration;
613
614use chrono::{DateTime, Utc};
615use onetaskgraph_plugin_api::{
616 Capabilities, Classification, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint,
617 DependencyKind, DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind,
618 ItemWrite, Label, LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page,
619 PageRequest, Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver,
620 SharedClock, SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusMapping,
621 Support, Task, TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome,
622 TextFields, TextQuery, UnmappedStatus, UpdatedField, WriteSupport, system_clock,
623};
624use reqwest::{Client, StatusCode, Url};
625use schemars::{Schema, schema_for};
626use secrecy::{ExposeSecret, SecretString};
627use serde::{Deserialize, Serialize};
628use serde_json::{Value, json};
629
630pub mod accounting;
631mod assets;
632mod visibility;
633
634use accounting::Accounting;
635
636/// The registry name for this plugin.
637pub const KIND: &str = "github-projects";
638/// GitHub's maximum connection page size.
639pub const MAX_PAGE_SIZE: u32 = 100;
640/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
641/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
642/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
643pub const SEARCH_PAGE_SIZE: u32 = 20;
644/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
645/// its comments: the largest batch the node-count model prices at one point.
646///
647/// Each aliased item is resolved once, and what GitHub charges for it is the connections
648/// under it — its labels, its page of board memberships, the field values of each of those
649/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
650/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
651/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
652/// one point and fails if one item more would still be priced at one.
653pub const DETAIL_BATCH: usize = 24;
654
655/// The most nodes any one document this source sends may be asked to return.
656///
657/// GitHub's own published per-query ceiling, taken from
658/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
659/// workspace cannot hold a stale copy of somebody else's number. A query above it is
660/// **refused before it is executed**, whoever is asking and whatever board they are
661/// asking about — so this is a bound on the documents rather than a budget that runs out.
662///
663/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
664/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
665/// everything the credential does — two numbers against two limits, and this constant
666/// bounds only the first. The second is computed offline too, per document:
667/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
668/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
669/// lane. There is no constant like this one to hold a price under, because points are an
670/// hourly allowance rather than a per-call bound.
671///
672/// Neither is a session's price. What `session-cost.md` records of a whole session is its
673/// **requests** and its **worst-case nodes**; what a whole session spends in points is
674/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
675/// [`accounting`]. The module section on the three ways this source reaches an item says how
676/// the count is arrived at, and which of the page sizes below decide it.
677pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
678
679/// Nested connection size for the connections that hang off one item.
680///
681/// It multiplies through every document that reaches an item under a page — the count
682/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
683/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
684/// every document under these constants and fails naming any that reaches the limit, so
685/// raising this is caught there rather than by GitHub.
686const NESTED_PAGE_SIZE: u32 = 50;
687/// How many of one issue's board memberships are read when an issue is reached directly.
688///
689/// An issue reached through a search or through its own node id carries its board half in
690/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
691/// under a page of issues, so every point of it multiplies through the whole document and
692/// is paid for whether or not any issue is on a second board — which is why it is
693/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
694///
695/// **Three, because what a page misses is now recovered rather than refused**, and the
696/// recovery is what the value is chosen against. An issue whose entry for this board sits
697/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
698/// that page's own cursor — so the value trades a bound every read pays for a request only
699/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
700/// boards would pay that request *per issue*, which is order N against the one page per
701/// hundred issues a read costs today. At three it is only reached by an issue on four or
702/// more boards at once, which keeps the recovery path exceptional rather than routine for
703/// a plausible deployment.
704const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
705/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
706/// its two connections for.
707///
708/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
709/// second is a duplicate a copy already takes the first of. Both connections are walked to
710/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
711/// never what the answer is. It is small because every point of it is paid on every lookup,
712/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
713/// worst-case nodes than the one whole-board read they replaced.
714const ORIGIN_PAGE_SIZE: u32 = 3;
715
716pub use github_graphql_node_count::{NodeCountError, Variables};
717
718/// The largest value this source can bind to each page-size variable its documents name.
719///
720/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
721/// constant above it wherever a caller's own limit could reach it — `$first` at
722/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
723/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
724/// not one configuration of it, which is what makes a bound computed under it a bound on
725/// every read.
726pub fn largest_page_sizes() -> Variables {
727 Variables::from([
728 ("first".to_owned(), MAX_PAGE_SIZE),
729 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
730 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
731 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
732 ])
733}
734
735/// The most nodes `document` could be asked to return, by GitHub's published rules.
736///
737/// Computed offline from the document's own text under [`largest_page_sizes`] — no
738/// network, no credential and no schema — by
739/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
740/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
741/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
742///
743/// # Errors
744///
745/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
746/// no single operation, or binds a page size this source does not name — each of which is
747/// a defect in the document rather than a number.
748pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
749 node_count(document, &largest_page_sizes())
750}
751
752/// The most rate-limit points one call of `document` could spend, by GitHub's published
753/// rules.
754///
755/// Computed offline from the document's own text under [`largest_page_sizes`] — no
756/// network, no credential and no schema — by
757/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
758/// This is `cost`, metered **per hour** against the allowance one credential shares across
759/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
760/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
761/// under, so what `tests/point_cost.rs` does with it is pin every document in
762/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
763/// figures against GitHub's own reported `cost`.
764///
765/// # Errors
766///
767/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
768/// no single operation, or binds a page size this source does not name — each of which is
769/// a defect in the document rather than a number.
770pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
771 github_graphql_node_count::point_cost(document, &largest_page_sizes())
772}
773
774/// The most nodes `document` could be asked to return under `variables`.
775///
776/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
777/// [`accounting`] is this under the bindings one request really sent — one spelling of the
778/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
779/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
780///
781/// # Errors
782///
783/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
784/// no single operation, or binds a page size `variables` does not name.
785pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
786 github_graphql_node_count::node_count(document, variables)
787}
788
789/// The issue-title prefix that makes a board issue a document.
790///
791/// A GitHub Projects board has no document type — it holds issues — so the discriminator
792/// is the title, and this is the whole of it: an issue whose title begins with these bytes
793/// is a document and every other issue is the task or project the sub-issue rule makes it.
794///
795/// It is spelled **once**, here, and read rather than restated everywhere else — including
796/// by the shared journeys, which take it from this constant so a board fixture cannot
797/// drift from what this source reads. `docs/metadata.md` records the two consequences that
798/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
799/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
800/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
801pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
802
803/// Exact GraphQL query documents issued by this plugin.
804///
805/// Keeping the production documents here lets the pinned-schema test validate the same
806/// bytes that are sent to GitHub, rather than a test-only copy which could drift
807/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
808/// field, and its guarded caller always supplies the complete existing option set with ids.
809pub mod graphql {
810 /// The board half of one item: the field values every document here reads it from.
811 ///
812 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
813 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
814 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
815 /// *the same value*, because
816 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
817 /// one path. Three spellings of it is what would drift, so there is one.
818 ///
819 /// The `Status` option and this source's own origin text field are the whole of it. It
820 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
821 /// content, so it holds nothing the content's own `labels` do not already say, and it
822 /// would sit a label connection two page sizes deep.
823 macro_rules! board_item_values {
824 () => {
825 r#"fieldValues(first:$nestedFirst){nodes{
826 ... on ProjectV2ItemFieldSingleSelectValue{name field{
827 ... on ProjectV2SingleSelectField{id name options{id name}}
828 }}
829 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
830 }pageInfo{hasNextPage}}"#
831 };
832 }
833
834 /// Everything this source reads about one issue, wherever it reaches that issue.
835 ///
836 /// A macro rather than a constant so the three documents below can `concat!` it: one
837 /// spelling of these fields is what makes an issue read through the board-scoped
838 /// search, through its own node id, and through its project's sub-issue relationship
839 /// resolve to *the same* item, which is the whole of what
840 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
841 ///
842 /// `projectItems` is what carries the board half of an issue: the board item's own id
843 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
844 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
845 /// issue rather than on the board, which is what makes the cost of a read proportional
846 /// to what was asked for instead of to the board's size.
847 ///
848 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
849 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
850 /// not on that page: a page here is where the search for the entry starts rather than
851 /// where it ends.
852 ///
853 /// It does **not** select the board's `Labels` field value, and that is the whole of
854 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
855 /// a label connection there sits under `fieldValues` under `projectItems` under a page
856 /// of issues, spending `$nestedFirst` twice down one path, and took
857 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
858 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
859 /// above, and that connection is where every label this source reports comes from. No
860 /// document in this module selects the board field any longer, [`BOARD`] included; the
861 /// module documentation records why nothing it could have held is lost.
862 macro_rules! board_issue {
863 () => {
864 concat!(
865 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
866 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
867 projectItems(first:$boardItems){nodes{id project{id number}
868 "#,
869 board_item_values!(),
870 r#"}pageInfo{hasNextPage endCursor}}}"#
871 )
872 };
873 }
874
875 /// Every issue of one board, found by a search scoped to that board.
876 ///
877 /// This is how the projects a board holds are listed, and it selects no `items`
878 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
879 /// container walked page by page, so nothing nested inside a board item is paid for.
880 /// Which of the issues it returns is a project is then read off `parent` — GitHub
881 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
882 /// discriminator has to be applied to the field, which is a scalar on the issue and
883 /// costs nothing.
884 pub const SEARCH_ISSUES: &str = concat!(
885 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
886 search(query:$search,type:$type,first:$first,after:$after){
887 pageInfo{hasNextPage endCursor}
888 nodes{__typename ...BoardIssue}
889 }
890 }"#,
891 board_issue!()
892 );
893
894 /// What a dependency read selects of each far end: enough to say which kind of item it
895 /// is, its body included for the kind marker.
896 macro_rules! related_issue {
897 () => {
898 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
899 };
900 }
901
902 /// One issue by its own node id, which is what a qualified id names here — with what a
903 /// write of it needs and the issue does not carry in `board_issue!`: the field
904 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
905 ///
906 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
907 /// answers a write made moments ago with the value from before it, and resolving a node
908 /// id does not.
909 ///
910 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
911 /// reads it by its own id, and with them that one read answers everything the write
912 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
913 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
914 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
915 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
916 /// they sit under one item, and this read is still one point.
917 pub const ISSUE: &str = concat!(
918 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
919 node(id:$id){__typename ...BoardIssue ... on Issue{
920 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
921 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
922 ... on ProjectV2Field{__typename id name}
923 }pageInfo{hasNextPage}}}}}
924 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
925 }}
926 }"#,
927 board_issue!(),
928 related_issue!()
929 );
930
931 /// One project's tasks: the sub-issues of the issue that project is, each with a page of
932 /// what blocks it.
933 ///
934 /// The work this costs is the project's own size. Nothing about it grows as the board
935 /// gains projects, or as those projects gain tasks.
936 ///
937 /// **Why `blockedBy` rides here and on no other page of issues.** What reads a project's
938 /// tasks reads their edges next — `project graph` draws them, a copy carries them — and
939 /// without them here that is one [`ISSUE_DEPENDENCIES`] per task, so the requests a graph
940 /// costs grow with its tasks rather than with the pages of them. Carried here, a task
941 /// blocked by no more than `$nestedFirst` issues answers its forward edges from this read,
942 /// exactly as an [`ISSUE`] read of it does, and only one blocked by more is asked again.
943 /// It adds a connection under each issue of the page — one rate-limit point per page,
944 /// and `$nestedFirst` nodes per issue — and is kept off [`SEARCH_ISSUES`], whose pages
945 /// answer questions that never read an edge.
946 pub const SUB_ISSUES: &str = concat!(
947 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
948 node(id:$id){__typename
949 ... on Issue{subIssues(first:$first,after:$after){
950 pageInfo{hasNextPage endCursor}
951 nodes{__typename ...BoardIssue ... on Issue{
952 blockedBy(first:$nestedFirst){nodes{...Related}pageInfo{hasNextPage endCursor}}
953 }}
954 }}}
955 }"#,
956 board_issue!(),
957 related_issue!()
958 );
959
960 /// What a read of the board's own `items` selects of each item's content.
961 ///
962 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
963 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
964 /// content by one spelling.
965 macro_rules! board_item_content {
966 () => {
967 r#" content{
968 ... on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
969 ... on PullRequest{__typename id}
970 ... on DraftIssue{__typename id title body createdAt updatedAt}
971 }"#
972 };
973 }
974
975 /// Reads the board's fields and one page of its items.
976 pub const BOARD: &str = concat!(
977 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
978 owner:repositoryOwner(login:$owner){
979 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
980 }
981 } fragment Board on ProjectV2 { id title
982 fields(first:$nestedFirst){nodes{
983 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
984 ... on ProjectV2Field{__typename id name}
985 }pageInfo{hasNextPage}}
986 items(first:$first,after:$after){nodes{id "#,
987 board_item_values!(),
988 board_item_content!(),
989 r#"} pageInfo{hasNextPage endCursor}}
990 }"#
991 );
992
993 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
994 /// the board.
995 ///
996 /// **`originItems`** is the board's own items narrowed by its own field filter —
997 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
998 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
999 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
1000 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
1001 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
1002 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
1003 /// fresh `addProjectV2ItemById` the way that connection does.
1004 ///
1005 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
1006 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
1007 /// indexes that comment, and the index catches up with a write in a second or two rather
1008 /// than in minutes, so it finds a carrier another process wrote that the first read is
1009 /// still behind on.
1010 ///
1011 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
1012 /// — and resumes from its own cursor; a connection already walked to its end is resumed
1013 /// from its last cursor, which answers an empty page. Every candidate either read returns
1014 /// is confirmed against its own origin field before it is reported, so a token match of
1015 /// the search or anything else the filter admits never is.
1016 ///
1017 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1018 /// own whole reads counts this one among them.
1019 pub const ORIGIN_LOOKUP: &str = concat!(
1020 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1021 originItems:repositoryOwner(login:$owner){
1022 ... on ProjectV2Owner{projectV2(number:$number){
1023 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
1024 board_item_values!(),
1025 board_item_content!(),
1026 r#"} pageInfo{hasNextPage endCursor}}
1027 }}
1028 }
1029 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1030 pageInfo{hasNextPage endCursor}
1031 nodes{__typename ...BoardIssue}
1032 }
1033 }"#,
1034 board_issue!()
1035 );
1036
1037 /// The board's own id and field definitions, and not one of its items.
1038 ///
1039 /// What a write needs of the board when the item it writes does not say: the id a field
1040 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1041 /// origin fields. It selects no `items`, so what it costs is the board's field list
1042 /// however many items the board holds — and it decides nothing about which items those
1043 /// are, which is the question a read of one item by its own id answers instead.
1044 ///
1045 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1046 /// board's item reads by their root counts this one among them.
1047 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1048 boardFields:repositoryOwner(login:$owner){
1049 ... on ProjectV2Owner{projectV2(number:$number){id
1050 fields(first:$nestedFirst){nodes{
1051 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1052 ... on ProjectV2Field{__typename id name}
1053 }pageInfo{hasNextPage}}
1054 }}
1055 }
1056 }"#;
1057
1058 /// One board draft by its own node id, with the board item it sits in.
1059 ///
1060 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1061 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1062 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1063 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1064 /// board listing hands it to, and nothing has to list the board to find one.
1065 pub const DRAFT: &str = concat!(
1066 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1067 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1068 projectV2Items(first:$boardItems){nodes{id project{id number}
1069 "#,
1070 board_item_values!(),
1071 r#"}pageInfo{hasNextPage endCursor}}}}
1072 }"#
1073 );
1074
1075 /// One issue's board memberships alone, walked past the page a read of it carried.
1076 ///
1077 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1078 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1079 /// boards than that page holds may have this board's entry past its end. This asks that
1080 /// one issue for its memberships and nothing else — the caller already holds the issue —
1081 /// so an answer of "this board does not hold it" is only ever given about a connection
1082 /// read to exhaustion.
1083 ///
1084 /// It selects the board item's id, its project number and the same
1085 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1086 /// very same resolver: an issue recovered this way reports the same title, the same
1087 /// status, the same labels and the same qualified id as one whose entry was on the
1088 /// page.
1089 ///
1090 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1091 /// multiplies through it and the membership connection can be walked at
1092 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1093 /// further request for any issue a person really keeps.
1094 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1095 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1096 node(id:$id){
1097 ... on Issue{projectItems(first:$first,after:$after){
1098 nodes{id project{id number}
1099 "#,
1100 board_item_values!(),
1101 r#"}
1102 pageInfo{hasNextPage endCursor}}}
1103 }
1104 }"#
1105 );
1106 /// Whether the board's Project is public — half of what decides whether a write here can
1107 /// be read by anybody. Needs the `read:project` scope.
1108 pub const PROJECT_VISIBILITY: &str = r#"query($owner:String!,$number:Int!){visibility:repositoryOwner(login:$owner){... on ProjectV2Owner{projectV2(number:$number){public}}}}"#;
1109 /// Resolves the configured repository's node id, which creating an issue requires.
1110 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1111 /// What creating an issue needs and has not read yet: the board's own id and field
1112 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1113 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1114 ///
1115 /// Sent at the point a create knows which repository it is for, when neither half is
1116 /// already known to this process; a create needing only one of them sends that one's own
1117 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1118 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1119 /// status.
1120 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1121 boardFields:repositoryOwner(login:$owner){
1122 ... on ProjectV2Owner{projectV2(number:$number){id
1123 fields(first:$nestedFirst){nodes{
1124 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1125 ... on ProjectV2Field{__typename id name}
1126 }pageInfo{hasNextPage}}
1127 }}
1128 }
1129 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1130 }"#;
1131 /// Reads both dependency directions for one issue, with each far end's own kind — and
1132 /// the issue's own body, which is where an edge to another source is recorded, so that
1133 /// half of a dependency read needs no second read of the issue or of the board.
1134 pub const ISSUE_DEPENDENCIES: &str = concat!(
1135 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1136 ... on Issue{body
1137 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1138 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1139 }}}"#,
1140 related_issue!()
1141 );
1142 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1143 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1144 /// answered when it was.
1145 pub const CREATE_ISSUE: &str =
1146 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1147 /// Puts an existing issue on the configured board.
1148 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1149 /// Updates an issue's visible fields and its open or closed state in one call.
1150 pub const UPDATE_ISSUE: &str =
1151 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1152 /// Updates an existing draft's user-visible fields.
1153 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1154 /// Updates a text or single-select value on one project item.
1155 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}}}}}}}}"#;
1156 /// Writes up to three board fields and an optional clear in one ordered mutation.
1157 pub const UPDATE_FIELDS: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!,$second:UpdateProjectV2ItemFieldValueInput!,$third:UpdateProjectV2ItemFieldValueInput!,$clear:ClearProjectV2ItemFieldValueInput!,$writeSecond:Boolean!,$writeThird:Boolean!,$writeClear:Boolean!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}} second:updateProjectV2ItemFieldValue(input:$second) @include(if:$writeSecond){projectV2Item{id}} third:updateProjectV2ItemFieldValue(input:$third) @include(if:$writeThird){projectV2Item{id}} cleared:clearProjectV2ItemFieldValue(input:$clear) @include(if:$writeClear){projectV2Item{id}}}"#;
1158 /// Clears one project item's value of one field, which is what a `none` priority is.
1159 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}}}}}}}}"#;
1160 /// Creates one single-select field with its options. Only the guarded field setup may use
1161 /// this document, and only for a field the board lacks.
1162 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1163 /// Replaces a single-select field's options. Only the guarded field setup — the
1164 /// `status-options` and `fields` operations — may use this document, because GitHub
1165 /// treats the input as the complete option list.
1166 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1167 /// A fresh snapshot of the Status field and every board item's assignment.
1168 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}}}}}}"#;
1169 /// Files one issue under another as a sub-issue, which is what project membership is.
1170 pub const ADD_SUB_ISSUE: &str =
1171 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1172 /// Takes one issue back out of its parent.
1173 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1174 /// Adds GitHub's native issue blocked-by relationship.
1175 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1176 /// Removes one native issue blocked-by relationship.
1177 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1178 /// Deletes one issue, which takes its board item with it.
1179 ///
1180 /// The engine sends this in one situation only: undoing a copy that could not finish,
1181 /// over the items that same copy created. Deleting the issue removes the board item
1182 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1183 pub const DELETE_ISSUE: &str =
1184 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1185
1186 /// Everything this source reads about one issue comment, wherever it reaches one.
1187 ///
1188 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1189 /// and a comment just edited are handed to one mapper, so they are selected by one
1190 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1191 /// longer exists, and `login` is the one member every kind of actor carries.
1192 macro_rules! issue_comment {
1193 () => {
1194 "id author{login} createdAt updatedAt body url"
1195 };
1196 }
1197
1198 /// One task's comments: a page of its issue's own `comments` connection.
1199 ///
1200 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1201 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1202 /// list every time somebody edited it; left unordered the connection answers in the order
1203 /// the comments were written, which is the order GitHub documents for the same collection
1204 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1205 /// node count and the caller's own page size is pushed straight down.
1206 pub const ISSUE_COMMENTS: &str = concat!(
1207 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1208 issue_comment!(),
1209 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1210 );
1211 /// One issue by its own node id, with a page of its comments: what `task show` and a
1212 /// comment listing read, in one request.
1213 ///
1214 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1215 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1216 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1217 /// connection there would multiply through both of those documents' price, and neither
1218 /// needs one.
1219 pub const ISSUE_DETAIL: &str = concat!(
1220 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1221 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1222 issue_comment!(),
1223 r#"}pageInfo{hasNextPage endCursor}}}}
1224 }"#,
1225 board_issue!()
1226 );
1227
1228 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1229 /// page of its comments when `$comments` asks for them.
1230 macro_rules! issue_details_alias {
1231 ($n:literal) => {
1232 concat!(
1233 "\n i",
1234 stringify!($n),
1235 ":node(id:$id",
1236 stringify!($n),
1237 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1238 issue_comment!(),
1239 "}pageInfo{hasNextPage endCursor}}}}"
1240 )
1241 };
1242 }
1243
1244 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1245 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1246 ///
1247 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1248 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1249 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1250 /// neither — so every connection under it would be priced at nothing and the pin in
1251 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1252 /// one-item read the model already prices, so the batch costs what its aliases cost.
1253 ///
1254 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1255 /// slots it has no item for to the last item it does, and reads that item again; the
1256 /// price is the document's, whatever its variables, so a short batch costs what a full
1257 /// one does and nothing more.
1258 pub const ISSUE_DETAILS: &str = concat!(
1259 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!){"#,
1260 issue_details_alias!(0),
1261 issue_details_alias!(1),
1262 issue_details_alias!(2),
1263 issue_details_alias!(3),
1264 issue_details_alias!(4),
1265 issue_details_alias!(5),
1266 issue_details_alias!(6),
1267 issue_details_alias!(7),
1268 issue_details_alias!(8),
1269 issue_details_alias!(9),
1270 issue_details_alias!(10),
1271 issue_details_alias!(11),
1272 issue_details_alias!(12),
1273 issue_details_alias!(13),
1274 issue_details_alias!(14),
1275 issue_details_alias!(15),
1276 issue_details_alias!(16),
1277 issue_details_alias!(17),
1278 issue_details_alias!(18),
1279 issue_details_alias!(19),
1280 issue_details_alias!(20),
1281 issue_details_alias!(21),
1282 issue_details_alias!(22),
1283 issue_details_alias!(23),
1284 "\n }",
1285 board_issue!()
1286 );
1287
1288 /// Which issue one comment is on, read before that comment is edited or removed.
1289 ///
1290 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1291 /// comment id given against the wrong task would change a comment on another issue.
1292 pub const COMMENT_ISSUE: &str =
1293 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1294 /// Adds one comment to an issue, signed as the account the token belongs to.
1295 pub const ADD_COMMENT: &str = concat!(
1296 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1297 issue_comment!(),
1298 r#"}}}}"#
1299 );
1300 /// Replaces the body of one issue comment.
1301 pub const UPDATE_COMMENT: &str = concat!(
1302 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1303 issue_comment!(),
1304 r#"}}}"#
1305 );
1306 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1307 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1308
1309 /// Every document above, with what this source is doing when it sends one.
1310 ///
1311 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1312 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1313 /// document added later with "talking to GitHub" and never say so.
1314 ///
1315 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1316 /// const` here that this list omits, so the two cannot part — which is the same guard
1317 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1318 pub const DOCUMENTS: [(&str, &str); 34] = [
1319 (SEARCH_ISSUES, "searching this board's issues"),
1320 (ISSUE, "reading one issue"),
1321 (
1322 ISSUE_BOARD_ITEMS,
1323 "reading one issue's board memberships past the page it came with",
1324 ),
1325 (SUB_ISSUES, "reading a project's tasks"),
1326 (BOARD, "reading the board"),
1327 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1328 (BOARD_FIELDS, "reading the board's fields"),
1329 (DRAFT, "reading one draft"),
1330 (REPOSITORY, "reading the destination repository"),
1331 (
1332 PROJECT_VISIBILITY,
1333 "reading whether the board's project is public",
1334 ),
1335 (
1336 CREATION_CONTEXT,
1337 "reading the board's fields and the destination repository",
1338 ),
1339 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1340 (CREATE_ISSUE, "creating an issue"),
1341 (ADD_TO_BOARD, "adding an issue to the board"),
1342 (UPDATE_ISSUE, "updating an issue"),
1343 (UPDATE_DRAFT, "updating a draft item"),
1344 (UPDATE_FIELD, "writing a board field"),
1345 (UPDATE_FIELDS, "writing board fields together"),
1346 (CLEAR_FIELD, "clearing a board field"),
1347 (
1348 CREATE_FIELD,
1349 "creating a board single-select field with its options",
1350 ),
1351 (
1352 STATUS_OPTIONS_SNAPSHOT,
1353 "snapshotting board Status options and assignments",
1354 ),
1355 (
1356 STATUS_OPTIONS_UPDATE,
1357 "safely replacing the board Status option list",
1358 ),
1359 (ADD_SUB_ISSUE, "filing an issue under its project"),
1360 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1361 (ADD_BLOCKED_BY, "recording a dependency"),
1362 (REMOVE_BLOCKED_BY, "removing a dependency"),
1363 (DELETE_ISSUE, "deleting an issue"),
1364 (ISSUE_COMMENTS, "reading a task's comments"),
1365 (ISSUE_DETAIL, "reading one issue with its comments"),
1366 (
1367 ISSUE_DETAILS,
1368 "reading a batch of issues with their comments",
1369 ),
1370 (COMMENT_ISSUE, "reading which issue a comment is on"),
1371 (ADD_COMMENT, "adding a comment"),
1372 (UPDATE_COMMENT, "editing a comment"),
1373 (DELETE_COMMENT, "deleting a comment"),
1374 ];
1375}
1376
1377/// Which of GitHub's two rate limiters refused a request.
1378///
1379/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1380/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1381/// the whole reason this is carried rather than collapsed into "rate limited".
1382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1383enum Limiter {
1384 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1385 Primary,
1386 /// The burst limiter over content-generating requests, which nothing reports.
1387 Secondary,
1388}
1389
1390/// The wordings GitHub answers a secondary rate limit with.
1391///
1392/// It sends them under a forbidden status, under a too-many-requests status, and inside
1393/// the `errors` of a *successful* response, which is why the text is what this matches on
1394/// rather than the status. `abuse detection` is the wording GitHub used before the
1395/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1396/// what a burst of content creation is refused with.
1397///
1398/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1399/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1400/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1401/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1402pub const SECONDARY_WORDINGS: [&str; 5] = [
1403 "secondary rate limit",
1404 "temporarily blocked from content creation",
1405 "abuse detection",
1406 "submitted too quickly",
1407 "exceeded a secondary",
1408];
1409
1410/// The wordings GitHub answers an exhausted primary budget with.
1411///
1412/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1413/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1414/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1415/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1416/// two phrases is a substring of it, so without it that answer read as a refusal that will
1417/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1418/// one reason.
1419pub const PRIMARY_WORDINGS: [&str; 4] = [
1420 "api rate limit exceeded",
1421 "api rate limit already exceeded",
1422 "rate limit exceeded",
1423 "rate_limited",
1424];
1425
1426/// What a response *says about itself*, which is the only place a refusal can be read.
1427///
1428/// Deliberately not the whole response body. A board is a place people write about their
1429/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1430/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1431/// reported. So the item data is never read: what is read is GitHub's own REST-style
1432/// `message` envelope, which is what a forbidden status carries, and the `message` and
1433/// `type` of each GraphQL error, which is where a *successful* response says it.
1434///
1435/// A body that is not JSON at all has nothing structured to read, so only a failing
1436/// response's own text is taken — a successful response that is not JSON is malformed
1437/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1438fn refusal_wording(status: StatusCode, body: &str) -> String {
1439 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1440 return if status.is_success() {
1441 String::new()
1442 } else {
1443 body.to_owned()
1444 };
1445 };
1446 let mut said: Vec<&str> = parsed
1447 .get("message")
1448 .and_then(Value::as_str)
1449 .into_iter()
1450 .collect();
1451 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1452 for error in errors {
1453 said.extend(
1454 ["message", "type"]
1455 .into_iter()
1456 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1457 );
1458 }
1459 }
1460 said.join("; ")
1461}
1462
1463impl Limiter {
1464 /// Which limiter refused this response, or `None` when none of them did.
1465 ///
1466 /// The wording is read first and the status only decides what carries none of it,
1467 /// because GitHub answers a secondary limit with a forbidden status far more often
1468 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1469 /// really is a credential this token lacks.
1470 ///
1471 /// A response is a refusal because of its status or its own wording. A spent budget
1472 /// only ever explains one; it never turns an answer into a refusal.
1473 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1474 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1475 if SECONDARY_WORDINGS
1476 .iter()
1477 .any(|wording| normalized.contains(wording))
1478 {
1479 return Some(Self::Secondary);
1480 }
1481 if status == StatusCode::TOO_MANY_REQUESTS {
1482 return Some(Self::Primary);
1483 }
1484 // An exhausted budget *explains* a response that failed; it does not make one that
1485 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1486 // request the budget allowed as well as on the ones it then refuses, so reading
1487 // the header alone threw away a good answer — and, once refusals were retried,
1488 // replayed a request that had already taken effect.
1489 if !status.is_success() && budget_exhausted {
1490 return Some(Self::Primary);
1491 }
1492 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1493 // `errors` of an HTTP 200, where nothing about the status says so at all.
1494 if status.is_success()
1495 && PRIMARY_WORDINGS
1496 .iter()
1497 .any(|wording| normalized.contains(wording))
1498 {
1499 return Some(Self::Primary);
1500 }
1501 None
1502 }
1503
1504 /// What this limiter is called where an operator can look it up.
1505 const fn name(self) -> &'static str {
1506 match self {
1507 Self::Primary => "GitHub's primary API rate limit",
1508 Self::Secondary => "GitHub's secondary rate limit",
1509 }
1510 }
1511
1512 /// What the endpoint an operator would go and check says about this limiter.
1513 const fn where_to_look(self) -> &'static str {
1514 match self {
1515 Self::Primary => {
1516 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1517 comes back."
1518 }
1519 Self::Secondary => {
1520 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1521 primary budget and does not report this one, so budget showing there says \
1522 nothing about this refusal, and every further attempt extends it."
1523 }
1524 }
1525 }
1526
1527 /// The next step this limiter actually calls for.
1528 const fn what_to_do(self) -> &'static str {
1529 match self {
1530 Self::Primary => {
1531 "wait for the reset `gh api rate_limit` reports, then run the command again."
1532 }
1533 Self::Secondary => {
1534 "leave this board alone for a few minutes, then run the command again — or \
1535 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1536 }
1537 }
1538 }
1539}
1540
1541/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1542#[derive(Debug, Clone, Copy)]
1543struct Limited {
1544 limiter: Limiter,
1545 hint: Option<u64>,
1546}
1547
1548impl Limited {
1549 /// What the caller is told once this source has waited as long as it may.
1550 ///
1551 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1552 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1553 /// about *which* limiter it was makes it a different kind of failure. What differs is
1554 /// the operator's next step, and that is what the message carries — a secondary
1555 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1556 /// budget looks fine, and then back to retry the very burst that was refused.
1557 fn exhausted(
1558 self,
1559 doing: &str,
1560 waits: u32,
1561 waited: Duration,
1562 needed: Duration,
1563 budget: Duration,
1564 ) -> SourceError {
1565 SourceError::RateLimited {
1566 retry_after_seconds: self.hint,
1567 message: Some(format!(
1568 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1569 again, and the next wait of {} would take it past the {} one call may spend \
1570 waiting. {} next: {}",
1571 self.limiter.name(),
1572 plural(waits, "refusal"),
1573 seconds(waited),
1574 seconds(needed),
1575 seconds(budget),
1576 self.limiter.where_to_look(),
1577 self.limiter.what_to_do(),
1578 )),
1579 }
1580 }
1581}
1582
1583/// One HTTP attempt's result, with what its response said about the rate limit.
1584///
1585/// The two travel together so the record and the outcome are written from the same place:
1586/// what a response said about the budget is only readable while that response is in hand,
1587/// and what the attempt *meant* is only decidable once its body has been read.
1588struct Attempted {
1589 result: Result<Value, Attempt>,
1590 limits: accounting::RateLimit,
1591 /// GitHub's own reported cost for this call, for a document that asked for it.
1592 reported_cost: Option<u64>,
1593}
1594
1595/// One attempt's outcome: an error to report, or a rate limit to wait out.
1596enum Attempt {
1597 Failed(SourceError),
1598 Limited(Limited),
1599}
1600
1601fn plural(count: u32, thing: &str) -> String {
1602 if count == 1 {
1603 format!("{count} {thing}")
1604 } else {
1605 format!("{count} {thing}s")
1606 }
1607}
1608
1609fn seconds(duration: Duration) -> String {
1610 format!("{:.1}s", duration.as_secs_f64())
1611}
1612
1613/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1614///
1615/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1616/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1617/// header, and neither is what makes a response a refusal — so the whole cost of one this
1618/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1619/// instead. Refusing the response over the header would turn a readable refusal into an
1620/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1621fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1622 value
1623 .and_then(|value| value.to_str().ok())
1624 .and_then(|value| value.trim().parse::<u64>().ok())
1625}
1626
1627/// Every mutation this source sends creates content — an issue, a board item, a field of
1628/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1629/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1630/// and what the keyword says are the same set. That is what makes the keyword a sound test
1631/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1632/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1633fn is_mutation(query: &str) -> bool {
1634 query.trim_start().starts_with("mutation")
1635}
1636
1637/// What this source was doing, for a diagnostic that has to say so.
1638///
1639/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1640/// a document added without a description is caught by that list's own gate instead of
1641/// falling through to the vague arm below.
1642fn operation_description(query: &str) -> &'static str {
1643 graphql::DOCUMENTS
1644 .iter()
1645 .find(|(document, _)| *document == query)
1646 .map_or("talking to GitHub", |(_, doing)| *doing)
1647}
1648
1649/// GitHub's published ceiling on content-generating requests, per minute.
1650///
1651/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1652/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1653/// from it, so a pacing value checked only against itself cannot go stale here.
1654pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1655/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1656/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1657/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1658pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1659/// Shortest interval between two content-creating mutations, in milliseconds.
1660///
1661/// GitHub documents two secondary limits on content-generating requests:
1662/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1663/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1664/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1665/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1666/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1667/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1668/// deliberately *not* what this paces at. An installation that wants the hourly bound
1669/// honoured for a long sequence of copies says so through
1670/// `pacing.min_mutation_interval_ms`.
1671pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1672/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1673///
1674/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1675/// own advice for a secondary limit — wait, and wait longer each time — without spending
1676/// the first minute of a transient refusal doing nothing.
1677pub const RETRY_BACKOFF_MS: u64 = 1_000;
1678/// Total time one call may spend waiting out rate limits before it reports a failure.
1679///
1680/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1681/// short enough that a command an operator is watching returns. The bound is what makes
1682/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1683/// the limiter, not in a process nobody can tell from a wedged one.
1684pub const RETRY_BUDGET_MS: u64 = 120_000;
1685
1686fn default_token_env() -> String {
1687 "GH_PROJECTS_TOKEN".to_owned()
1688}
1689fn default_endpoint() -> String {
1690 "https://api.github.com/graphql".to_owned()
1691}
1692
1693/// The name of a `Status` single-select option on the board.
1694///
1695/// Validated on the way in rather than checked later, so a blank option name — which
1696/// nothing on a board can be — is a state this type cannot hold.
1697#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1698#[serde(try_from = "String")]
1699#[schemars(extend("minLength" = 1))]
1700pub struct ColumnName(String);
1701
1702impl ColumnName {
1703 /// The option name, as the board spells it.
1704 fn as_str(&self) -> &str {
1705 &self.0
1706 }
1707}
1708
1709impl TryFrom<String> for ColumnName {
1710 type Error = String;
1711
1712 fn try_from(name: String) -> Result<Self, Self::Error> {
1713 if name.trim().is_empty() {
1714 return Err("a status_mapping option name cannot be blank".to_owned());
1715 }
1716 Ok(Self(name))
1717 }
1718}
1719
1720/// The two closed states this product can mean.
1721///
1722/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1723/// work nor abandoned work, so nothing here ever writes it.
1724#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1725#[serde(rename_all = "kebab-case")]
1726pub enum ClosedState {
1727 /// `COMPLETED` — precisely done.
1728 Completed,
1729 /// `NOT_PLANNED` — precisely cancelled.
1730 NotPlanned,
1731}
1732
1733impl ClosedState {
1734 const fn reason(self) -> &'static str {
1735 match self {
1736 Self::Completed => "COMPLETED",
1737 Self::NotPlanned => "NOT_PLANNED",
1738 }
1739 }
1740}
1741
1742/// Configuration for one GitHub Projects v2 board.
1743#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1744#[serde(default, deny_unknown_fields)]
1745pub struct GitHubProjectsConfig {
1746 /// Login of the user or organization which owns the board.
1747 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1748 /// The project number shown in the board's GitHub URL.
1749 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1750 // 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.
1751 /// `owner/name` of the repository this source creates an issue in when the item's own
1752 /// `repositories` field does not decide it.
1753 ///
1754 /// An item naming exactly one repository is created there; a task or a document naming
1755 /// none or several is created in its parent project's repository; and a project, or a
1756 /// task or document with no parent, naming none or several is created here. A board
1757 /// has no repository of its own and `createIssue` requires one, so a write without
1758 /// this is refused naming the field. Reads never need it.
1759 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1760 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1761 /// Environment variable containing a fine-grained token with Projects and Issues
1762 /// read/write plus Pull requests read-only access for every repository represented on
1763 /// the board.
1764 #[serde(default = "default_token_env")]
1765 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1766 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1767 #[serde(default = "default_endpoint")]
1768 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1769 /// Per-instance mapping from a status category to the option of the board's one
1770 /// `Status` field it lands on, for a task and for a project.
1771 ///
1772 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1773 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1774 /// where a kind left out leaves the category unmapped for that kind. A category this
1775 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1776 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1777 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1778 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1779 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1780 /// either kind. No two categories may name one option for the same kind, ignoring case.
1781 /// `unknown` may name one existing option; every unknown word then lands on it and
1782 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1783 /// each unknown word because it never creates board options.
1784 #[serde(default)]
1785 pub status_mapping: StatusMapping,
1786 /// Per-instance mapping from a task's priority to an option of this board's
1787 /// single-select field named `Priority`.
1788 ///
1789 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1790 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1791 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1792 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1793 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1794 /// no two levels may name one option. Reads and writes never create the field or an
1795 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1796 /// the board lacks is refused pointing there.
1797 #[serde(default)]
1798 pub priority_mapping: Option<PriorityMappingConfig>,
1799 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1800 ///
1801 /// Every field keeps its shipped default when it is absent, and the defaults are
1802 /// GitHub's own published limits rather than taste. See [`Pacing`].
1803 #[serde(default)]
1804 pub pacing: PacingConfig,
1805}
1806
1807/// Which option of the board's `Priority` field each priority lands on.
1808///
1809/// One member per level rather than a map, so a key that is not a level is refused where
1810/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1811/// value in the field, not an option of it.
1812#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1813#[serde(default, deny_unknown_fields)]
1814pub struct PriorityMappingConfig {
1815 /// The option `urgent` lands on; `Urgent` when absent.
1816 pub urgent: Option<PriorityOptionName>,
1817 /// The option `high` lands on; `High` when absent.
1818 pub high: Option<PriorityOptionName>,
1819 /// The option `medium` lands on; `Medium` when absent.
1820 pub medium: Option<PriorityOptionName>,
1821 /// The option `low` lands on; `Low` when absent.
1822 pub low: Option<PriorityOptionName>,
1823}
1824
1825/// The name of an option of the board's `Priority` single-select field.
1826///
1827/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1828/// blank name.
1829#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1830#[serde(try_from = "String")]
1831#[schemars(extend("minLength" = 1))]
1832pub struct PriorityOptionName(String);
1833
1834impl PriorityOptionName {
1835 /// The option name, as the board spells it.
1836 fn as_str(&self) -> &str {
1837 &self.0
1838 }
1839}
1840
1841impl TryFrom<String> for PriorityOptionName {
1842 type Error = String;
1843
1844 fn try_from(name: String) -> Result<Self, Self::Error> {
1845 if name.trim().is_empty() {
1846 return Err("a priority_mapping option name cannot be blank".to_owned());
1847 }
1848 Ok(Self(name))
1849 }
1850}
1851
1852/// The name of the board field a priority is held in.
1853pub const PRIORITY_FIELD: &str = "Priority";
1854
1855/// The four priorities a board option can hold, in the order a new `Priority` field lists
1856/// them. `none` is not among them: it is the field holding no value.
1857///
1858/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1859/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1860/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1861/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1862pub const PRIORITY_LEVELS: [Priority; 4] = [
1863 Priority::Urgent,
1864 Priority::High,
1865 Priority::Medium,
1866 Priority::Low,
1867];
1868
1869/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1870/// see that list for what this pins.
1871#[must_use]
1872pub const fn level_position(priority: Priority) -> Option<usize> {
1873 match priority {
1874 Priority::None => None,
1875 Priority::Urgent => Some(0),
1876 Priority::High => Some(1),
1877 Priority::Medium => Some(2),
1878 Priority::Low => Some(3),
1879 }
1880}
1881
1882/// This instance's complete priority-to-option mapping, read in both directions.
1883///
1884/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1885/// two levels name one option.
1886#[derive(Debug, Clone)]
1887struct PriorityMapping {
1888 options: [PriorityOptionName; 4],
1889}
1890
1891impl PriorityMapping {
1892 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1893 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1894 let mapping = Self {
1895 options: [
1896 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1897 config.high.unwrap_or_else(|| shipped("High")),
1898 config.medium.unwrap_or_else(|| shipped("Medium")),
1899 config.low.unwrap_or_else(|| shipped("Low")),
1900 ],
1901 };
1902 for (index, option) in mapping.options.iter().enumerate() {
1903 if let Some(earlier) = mapping.options[..index]
1904 .iter()
1905 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1906 {
1907 return Err(SourceError::Config {
1908 message: format!(
1909 "priority_mapping of source {instance} sends both {} and {} to the board \
1910 option {:?}; one option cannot read back as two priorities",
1911 PRIORITY_LEVELS[earlier],
1912 PRIORITY_LEVELS[index],
1913 option.as_str()
1914 ),
1915 });
1916 }
1917 }
1918 Ok(mapping)
1919 }
1920
1921 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1922 fn option(&self, priority: Priority) -> Option<&str> {
1923 level_position(priority).map(|index| self.options[index].as_str())
1924 }
1925
1926 /// The priority a board option name reports, or `None` when nothing maps to it.
1927 fn priority_of(&self, option: &str) -> Option<Priority> {
1928 self.options
1929 .iter()
1930 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1931 .map(|index| PRIORITY_LEVELS[index])
1932 }
1933
1934 /// Every mapped option name, in the order a new `Priority` field lists them.
1935 fn names(&self) -> impl Iterator<Item = &str> {
1936 self.options.iter().map(PriorityOptionName::as_str)
1937 }
1938}
1939
1940/// What one item's `Priority` field says, read through this instance's mapping.
1941#[derive(Debug, Clone, PartialEq, Eq)]
1942enum HeldPriority {
1943 /// A priority this source reports: an option the mapping names, or no value (`none`).
1944 Read(Priority),
1945 /// An option the mapping does not name, which is never read as a level or as `none`.
1946 Unmapped(String),
1947}
1948
1949/// How fast this source writes, and how long it waits out a rate-limit refusal.
1950///
1951/// Configurable because a GitHub Enterprise installation sets its own limits and an
1952/// operator who has already been refused may want to go slower still — not because the
1953/// defaults are guesses.
1954#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1955#[serde(default, deny_unknown_fields)]
1956pub struct PacingConfig {
1957 /// Shortest interval between two content-creating mutations, in milliseconds.
1958 ///
1959 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1960 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1961 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.
1962 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1963 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1964 /// zero while there is a budget to spend, because a schedule of zero-length waits
1965 /// consumes none of it and so never ends.
1966 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.
1967 /// Total time one call may spend waiting out rate limits, in milliseconds.
1968 ///
1969 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1970 /// the bound is what makes this a wait rather than a hang.
1971 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.
1972}
1973
1974/// The largest any pacing setting may be, in milliseconds.
1975///
1976/// One hour. GitHub's own harshest published bound on content-generating requests works
1977/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1978/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1979/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1980/// and an interval beyond it is a command that never sends its second mutation. It also
1981/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1982/// what a `Duration` can hold on every platform.
1983pub const MAX_PACING_MS: u64 = 3_600_000;
1984
1985/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1986/// source holds.
1987#[derive(Debug, Clone, Copy)]
1988struct Pacing {
1989 min_mutation_interval: Duration,
1990 retry_backoff: Duration,
1991 retry_budget: Duration,
1992}
1993
1994impl Pacing {
1995 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1996 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1997 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1998 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1999 message: format!(
2000 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
2001 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
2002 GitHub's own harshest published limit"
2003 ),
2004 }),
2005 Some(value) => Ok(Duration::from_millis(value)),
2006 None => Ok(Duration::from_millis(default)),
2007 };
2008 let retry_backoff = bounded(
2009 config.retry_backoff_ms,
2010 RETRY_BACKOFF_MS,
2011 "retry_backoff_ms",
2012 )?;
2013 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
2014 if retry_backoff.is_zero() && !retry_budget.is_zero() {
2015 return Err(SourceError::Config {
2016 message: format!(
2017 "pacing.retry_backoff_ms of source {instance} is 0 while \
2018 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
2019 none of that budget, so it would retry a refusal forever. Set a backoff of \
2020 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
2021 waiting at all",
2022 retry_budget.as_millis()
2023 ),
2024 });
2025 }
2026 Ok(Self {
2027 min_mutation_interval: bounded(
2028 config.min_mutation_interval_ms,
2029 MIN_MUTATION_INTERVAL_MS,
2030 "min_mutation_interval_ms",
2031 )?,
2032 retry_backoff,
2033 retry_budget,
2034 })
2035 }
2036}
2037
2038/// Factory for [`GitHubProjectsSource`].
2039#[derive(Debug, Clone, Copy, Default)]
2040pub struct Plugin;
2041
2042impl SourcePlugin for Plugin {
2043 fn kind(&self) -> &'static str {
2044 KIND
2045 }
2046 fn config_schema(&self) -> Schema {
2047 schema_for!(GitHubProjectsConfig)
2048 }
2049 fn build(
2050 &self,
2051 name: &SourceName,
2052 config: &Value,
2053 secrets: &dyn SecretResolver,
2054 ) -> Result<Box<dyn TaskSource>, SourceError> {
2055 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2056 }
2057
2058 fn build_with_clock(
2059 &self,
2060 name: &SourceName,
2061 config: &Value,
2062 secrets: &dyn SecretResolver,
2063 clock: SharedClock,
2064 ) -> Result<Box<dyn TaskSource>, SourceError> {
2065 self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2066 }
2067}
2068
2069impl Plugin {
2070 /// Build a source recording every request it sends into an accounting the caller holds.
2071 ///
2072 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2073 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2074 /// session total rather than two — see [`accounting`] and
2075 /// [`GitHubProjectsSource::recording_into`].
2076 ///
2077 /// # Errors
2078 ///
2079 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2080 /// [`SourceError::Config`] for configuration this plugin cannot use and
2081 /// [`SourceError::Auth`] for a credential it cannot find.
2082 pub fn build_recording_into(
2083 &self,
2084 name: &SourceName,
2085 config: &Value,
2086 secrets: &dyn SecretResolver,
2087 ledger: Arc<Accounting>,
2088 ) -> Result<Box<dyn TaskSource>, SourceError> {
2089 self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2090 }
2091
2092 fn build_recording_with_clock(
2093 &self,
2094 name: &SourceName,
2095 config: &Value,
2096 secrets: &dyn SecretResolver,
2097 ledger: Arc<Accounting>,
2098 clock: SharedClock,
2099 ) -> Result<Box<dyn TaskSource>, SourceError> {
2100 let config: GitHubProjectsConfig =
2101 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2102 message: format!("source {name}: {e}"),
2103 })?;
2104 let prefix = format!("source {name}: ");
2105 let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2106 .map_err(|error| match error {
2107 // The shared `StatusMapping::distinct` names the source itself.
2108 SourceError::Config { message } if message.starts_with(&prefix) => {
2109 SourceError::Config { message }
2110 }
2111 SourceError::Config { message } => SourceError::Config {
2112 message: format!("{prefix}{message}"),
2113 },
2114 SourceError::Auth { message } => SourceError::Auth {
2115 message: format!("source {name}: {message}"),
2116 },
2117 other => other,
2118 })?;
2119 source.clock = clock;
2120 Ok(Box::new(source))
2121 }
2122}
2123
2124/// Where a status category lands on this board, once configuration is resolved.
2125#[derive(Debug, Clone, PartialEq, Eq)]
2126enum StatusTarget {
2127 /// Not usable against this instance for this kind, and why.
2128 Disabled(UnmappedStatus),
2129 /// The board's `Status` option of this name.
2130 Column(ColumnName),
2131 /// A closed issue, with both its board option and the reason that says which closed it means.
2132 // 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.
2133 Terminal(ColumnName, ClosedState),
2134}
2135
2136/// Every status category, in the order the vocabulary declares them.
2137///
2138/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2139/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2140/// added to the shared vocabulary fails to compile until it is named there, and this
2141/// crate's suite reconciles this list against that enum's own derived schema, which is
2142/// generated from the variants rather than written beside them. The schema is what
2143/// catches a list left one short — a list checking only the positions it already holds
2144/// would pass while every mapping indexed by the new position panicked.
2145pub const CATEGORIES: [StatusCategory; 8] = [
2146 StatusCategory::Draft,
2147 StatusCategory::Backlog,
2148 StatusCategory::Todo,
2149 StatusCategory::Queued,
2150 StatusCategory::InProgress,
2151 StatusCategory::Done,
2152 StatusCategory::Cancelled,
2153 StatusCategory::Unknown,
2154];
2155
2156/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2157#[must_use]
2158pub const fn category_position(category: StatusCategory) -> usize {
2159 match category {
2160 StatusCategory::Draft => 0,
2161 StatusCategory::Backlog => 1,
2162 StatusCategory::Todo => 2,
2163 StatusCategory::Queued => 3,
2164 StatusCategory::InProgress => 4,
2165 StatusCategory::Done => 5,
2166 StatusCategory::Cancelled => 6,
2167 StatusCategory::Unknown => 7,
2168 }
2169}
2170
2171/// The spelling a status category is configured and reported under.
2172fn category_name(category: StatusCategory) -> &'static str {
2173 match category {
2174 StatusCategory::Draft => "draft",
2175 StatusCategory::Backlog => "backlog",
2176 StatusCategory::Todo => "todo",
2177 StatusCategory::Queued => "queued",
2178 StatusCategory::InProgress => "in-progress",
2179 StatusCategory::Done => "done",
2180 StatusCategory::Cancelled => "cancelled",
2181 StatusCategory::Unknown => "unknown",
2182 }
2183}
2184
2185/// A shipped default's option name.
2186///
2187/// The literals below are this file's own and non-blank, and they are validated by the
2188/// one constructor a configured name goes through rather than beside it.
2189fn shipped_column(name: &'static str) -> ColumnName {
2190 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2191}
2192
2193/// The shipped default for one category this instance's `status_mapping` does not mention,
2194/// for either kind.
2195fn shipped_default(category: StatusCategory) -> StatusTarget {
2196 match category {
2197 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2198 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2199 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2200 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2201 StatusCategory::Done => {
2202 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2203 }
2204 StatusCategory::Cancelled => {
2205 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2206 }
2207 StatusCategory::Draft | StatusCategory::Unknown => {
2208 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2209 }
2210 }
2211}
2212
2213/// The two kinds a status is written and read for, each with its own half of the mapping.
2214const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2215
2216/// This instance's complete category-to-target mapping for each kind, read in both
2217/// directions.
2218///
2219/// One target per category per kind, held at that category's own [`category_position`], so
2220/// a category missing from the mapping, named twice in it, or filed out of order is a state
2221/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2222/// targets are options of the board's one `Status` field.
2223#[derive(Debug, Clone)]
2224struct BoardStatuses {
2225 tasks: [StatusTarget; CATEGORIES.len()],
2226 projects: [StatusTarget; CATEGORIES.len()],
2227}
2228
2229impl BoardStatuses {
2230 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2231 /// would read back from one option.
2232 ///
2233 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2234 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2235 /// omits unmapped rather than defaulted.
2236 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2237 let resolve_kind =
2238 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2239 // `CATEGORIES[position] == category` for every category — the crate's suite
2240 // asserts it — so mapping the list in order fills each category's own slot.
2241 let mut targets = CATEGORIES.map(shipped_default);
2242 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2243 if !configured.mentions(category) {
2244 continue;
2245 }
2246 *slot = match configured.name_for(category, kind) {
2247 Err(why) => StatusTarget::Disabled(why),
2248 Ok(name) => {
2249 let option = ColumnName::try_from(name.as_str().to_owned())
2250 .map_err(|message| SourceError::Config { message })?;
2251 match category {
2252 StatusCategory::Done => {
2253 StatusTarget::Terminal(option, ClosedState::Completed)
2254 }
2255 StatusCategory::Cancelled => {
2256 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2257 }
2258 _ => StatusTarget::Column(option),
2259 }
2260 }
2261 };
2262 }
2263 StatusMapping::distinct(
2264 instance,
2265 kind,
2266 CATEGORIES
2267 .iter()
2268 .zip(&targets)
2269 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2270 )?;
2271 Ok(targets)
2272 };
2273 Ok(Self {
2274 tasks: resolve_kind(ItemKind::Task)?,
2275 projects: resolve_kind(ItemKind::Project)?,
2276 })
2277 }
2278
2279 /// Every category's target for `kind`, in category order.
2280 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2281 match kind {
2282 ItemKind::Task => &self.tasks,
2283 ItemKind::Project => &self.projects,
2284 }
2285 }
2286
2287 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2288 &self.targets(kind)[category_position(category)]
2289 }
2290
2291 /// The category a board option name reports for `kind`, or `None` when nothing of that
2292 /// kind maps to it.
2293 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2294 CATEGORIES.into_iter().find(|category| {
2295 self.target(kind, *category)
2296 .option()
2297 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2298 })
2299 }
2300
2301 /// Every option name either kind maps a category to, each once ignoring case, in
2302 /// category order with a task's name before a project's — what the guarded setup asks
2303 /// the `Status` field to hold.
2304 fn wanted(&self) -> Vec<String> {
2305 let mut wanted: Vec<String> = Vec::new();
2306 for category in CATEGORIES {
2307 for kind in STATUS_KINDS {
2308 if let Some(name) = self.target(kind, category).option()
2309 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2310 {
2311 wanted.push(name.to_owned());
2312 }
2313 }
2314 }
2315 wanted
2316 }
2317
2318 /// The status an item of `kind` reports, from the three things a read of it says: its
2319 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2320 ///
2321 /// The closed state decides the category and the `Status` option decides the name, so
2322 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2323 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2324 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2325 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2326 /// it is read permissively rather than refused — reads are faithful, and refusals
2327 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2328 /// option that mapping does not name reads as `Unknown` under its own name.
2329 ///
2330 /// One function of those three rather than of a response, so a narrow status write can
2331 /// answer what a re-read would report by applying it to the state it has just written.
2332 fn status(
2333 &self,
2334 kind: ItemKind,
2335 option: Option<&str>,
2336 closed: bool,
2337 reason: Option<&str>,
2338 ) -> Status {
2339 if closed {
2340 let category = match reason {
2341 None | Some("COMPLETED") => StatusCategory::Done,
2342 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2343 Some(_) => StatusCategory::Unknown,
2344 };
2345 let fallback = match category {
2346 StatusCategory::Done => "Done",
2347 StatusCategory::Cancelled => "Cancelled",
2348 _ => "Closed",
2349 };
2350 return Status {
2351 category,
2352 name: option.unwrap_or(fallback).to_owned(),
2353 };
2354 }
2355 let name = option.unwrap_or("Open").to_owned();
2356 Status {
2357 category: self
2358 .category_of(kind, &name)
2359 .unwrap_or(StatusCategory::Unknown),
2360 name,
2361 }
2362 }
2363}
2364
2365impl BoardStatuses {
2366 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2367 /// case; a kind lacking none is left out.
2368 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2369 STATUS_KINDS
2370 .into_iter()
2371 .filter_map(|kind| {
2372 let missing: Vec<String> = self
2373 .targets(kind)
2374 .iter()
2375 .filter_map(StatusTarget::option)
2376 .filter(|wanted| {
2377 !existing
2378 .iter()
2379 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2380 })
2381 .map(str::to_owned)
2382 .collect();
2383 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2384 })
2385 .collect()
2386 }
2387}
2388
2389impl StatusTarget {
2390 /// The board option this target selects, or `None` for an unmapped one.
2391 fn option(&self) -> Option<&str> {
2392 match self {
2393 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2394 Self::Disabled(_) => None,
2395 }
2396 }
2397}
2398
2399// 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.
2400/// One repository this source can create an issue in, as `owner/name`.
2401///
2402/// Every `createIssue` this source sends names one of these: the item's own single
2403/// `repositories` entry, else its parent project issue's repository, else the configured
2404/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2405/// that choice and says what it refuses before `createIssue`.
2406// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2407#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2408struct RepositoryTarget {
2409 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2410 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2411}
2412
2413impl RepositoryTarget {
2414 fn parse(value: &str) -> Result<Self, SourceError> {
2415 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2416 message: format!(
2417 "repository must be spelled owner/name; {value:?} names no repository"
2418 ),
2419 })?;
2420 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2421 return Err(SourceError::Config {
2422 message: format!(
2423 "repository must be spelled owner/name with a GitHub login and one \
2424 repository name; {value:?} is not"
2425 ),
2426 });
2427 }
2428 Ok(Self {
2429 owner: owner.to_owned(),
2430 name: name.to_owned(),
2431 })
2432 }
2433
2434 /// The one host whose repositories this source creates issues in, spelled once: it is
2435 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2436 const HOST: &str = "github.com";
2437
2438 fn origin(&self) -> String {
2439 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2440 }
2441
2442 /// The repository a normalized origin names, or why it is none this source can create
2443 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2444 fn from_origin(origin: &Repository) -> Result<Self, String> {
2445 let not_here = || {
2446 format!(
2447 "{} is not a {}/owner/name repository",
2448 origin.as_str(),
2449 Self::HOST
2450 )
2451 };
2452 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2453 if host != Self::HOST {
2454 return Err(not_here());
2455 }
2456 Self::parse(rest).map_err(|_| not_here())
2457 }
2458
2459 fn slug(&self) -> String {
2460 format!("{}/{}", self.owner, self.name)
2461 }
2462}
2463
2464/// A source which reads GitHub afresh for every operation.
2465pub struct GitHubProjectsSource {
2466 /// This source's configured name, used both to tell a far end naming this source
2467 /// from one naming a system it knows nothing about, and to name the instance a
2468 /// status refusal is about.
2469 name: SourceName,
2470 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2471 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2472 repository: Option<RepositoryTarget>,
2473 endpoint: Url,
2474 token: SecretString,
2475 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2476 statuses: BoardStatuses,
2477 /// Where each priority lands on this board, or `None` when this instance holds none.
2478 priorities: Option<PriorityMapping>,
2479 client: Client,
2480 asset_client: Client,
2481 /// Every item this source has created in this command, in the order it created them —
2482 /// dropped by [`TaskSource::end_command`].
2483 ///
2484 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2485 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2486 /// a copy resolving a dependency on an item it had just created refused it as not
2487 /// found. A board read is completed from this — an item remembered here and absent from
2488 /// the read is added back, because the board really does hold it and only the read is
2489 /// behind.
2490 ///
2491 /// It is not a cache of a user's work: nothing is remembered that this process did not
2492 /// itself just write, it lives and dies with the process, and it is never consulted for
2493 /// an item this source did not create.
2494 created: Mutex<Vec<Resolved>>,
2495 /// Every item that already existed and that this source has written in this command, as
2496 /// it wrote it — dropped by [`TaskSource::end_command`].
2497 ///
2498 /// The other half of [`Self::created`], held on the same terms and for the reason a
2499 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2500 /// filter is an index behind a write this process made moments ago, so a query matching
2501 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2502 /// is remembered that this process did not itself just write.
2503 updated: Mutex<Vec<Resolved>>,
2504 /// Every issue this source has added a comment to or edited a comment of in this command
2505 /// — dropped by [`TaskSource::end_command`].
2506 ///
2507 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
2508 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
2509 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
2510 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
2511 /// from before — until the index caught up. Each id here is a candidate of every such
2512 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
2513 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
2514 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
2515 /// process wrote is still found only once the index has it.
2516 commented: Mutex<Vec<NativeId>>,
2517 /// How fast this source writes, and how long it waits out a refusal.
2518 pacing: Pacing,
2519 /// When the last content-creating mutation finished, or the moment the furthest-out
2520 /// reserved slot releases the next one, whichever is later — so the one after it can be
2521 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2522 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2523 /// what it is measured from.
2524 last_mutation: Mutex<Option<Duration>>,
2525 clock: SharedClock,
2526 numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
2527 /// The board as this process last read it, for the length of one command — dropped by
2528 /// [`TaskSource::end_command`].
2529 ///
2530 /// A copy of a project used to re-read the whole board, paged, before writing each of
2531 /// its items, which is by far the largest part of a copy's request count and none of
2532 /// its work. Nothing else changes this board while a command runs — this source's own
2533 /// writes are the only writer — so one read answers them all.
2534 ///
2535 /// It is not a store of a user's work and it is not the cache the no-persistence
2536 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2537 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2538 /// an item this command created and then depends on resolves whether or not GitHub's
2539 /// own eventually-consistent read has caught up. A write to an item already on the
2540 /// board updates the entry here too, so what this holds is the last read plus this
2541 /// process's own writes rather than a snapshot taken before them.
2542 board_cache: Mutex<Option<Board>>,
2543 /// Every issue this board's own search reported, for the length of one command — dropped
2544 /// by [`TaskSource::end_command`].
2545 ///
2546 /// The second half of a board read, and cached for the same reason and on the same
2547 /// terms as the first: it lives and dies with the process, nothing is written down, and
2548 /// a write this process makes updates the entry here exactly as it updates the one in
2549 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2550 /// that lists this board's projects and its tasks pays for one search rather than two.
2551 search_cache: Mutex<Option<Vec<Resolved>>>,
2552 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2553 /// the length of one command — dropped by [`TaskSource::end_command`].
2554 ///
2555 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2556 /// and dies with the process, nothing is written down, a write this process makes
2557 /// updates the entry here as it updates the other two, and every answer is completed
2558 /// with this process's own writes each time it is given. A command that asks the same
2559 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2560 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2561 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2562 search_next: Mutex<BTreeMap<String, Option<String>>>,
2563 /// Records already resolved in this command, reused by writes and for comment identity.
2564 /// Explicit item reads still reach GitHub. Nothing is persisted, and
2565 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2566 /// its item as a person has since left it.
2567 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2568 /// Each project's sub-issues as GitHub answered them, keyed by the selector they were
2569 /// asked for under, with the project that selector named — for the length of one command,
2570 /// dropped by [`TaskSource::end_command`].
2571 ///
2572 /// A caller pages through a project's tasks one engine page at a time, and every page
2573 /// is cut from the whole list of them, so without this each page walked every
2574 /// [`graphql::SUB_ISSUES`] page again and a project of `n` listing pages cost `n²`
2575 /// requests to read once. Held on the terms [`Self::narrowed_cache`] is: it lives and
2576 /// dies with the process, nothing is written down, a write this process makes updates or
2577 /// removes the entry here as it does there, and every answer is completed with this
2578 /// process's own writes each time it is given.
2579 children_cache: Mutex<ProjectChildren>,
2580 /// The board's own id and field definitions as this process last read them on their
2581 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2582 ///
2583 /// What a write needs of the board and its item does not say, read once per command
2584 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2585 /// lives and dies with the process and nothing is written down. It holds no item and so
2586 /// can answer no question about one — see [`Self::board_fields`].
2587 fields_cache: Mutex<Option<BoardFields>>,
2588 /// Each destination repository's node id, resolved once per repository
2589 /// rather than per issue created.
2590 ///
2591 /// A repository's node id does not change, and re-reading it for every issue of a copy
2592 /// spent one request per item on an answer this source already had. It is a map rather
2593 /// than one entry because a copy files each item in the repository its own
2594 /// `repositories` field names, so a plan across five repositories asks GitHub five
2595 /// times and not once per item.
2596 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2597 /// What every request this source sends is recorded into.
2598 ///
2599 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2600 /// a request leaves this crate, so nothing has to be switched on for a session to be
2601 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2602 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2603 /// source's reads and writes — adds up one accounting instead of two. See
2604 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2605 ledger: Arc<Accounting>,
2606}
2607
2608/// GitHub's closed single-select color vocabulary.
2609#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2610#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2611pub enum StatusOptionColor {
2612 /// Gray.
2613 Gray,
2614 /// Blue.
2615 Blue,
2616 /// Green.
2617 Green,
2618 /// Yellow.
2619 Yellow,
2620 /// Purple.
2621 Purple,
2622 /// Red.
2623 Red,
2624 /// Orange.
2625 Orange,
2626 /// Pink.
2627 Pink,
2628}
2629
2630/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2631/// applies its additions.
2632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2633pub enum SetupMode {
2634 /// Read without mutation.
2635 Plan,
2636 /// Apply and verify.
2637 Apply,
2638}
2639
2640/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2641/// against it goes on compiling.
2642pub type StatusOptionsMode = SetupMode;
2643
2644/// The explicit result of the requested operation.
2645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2646#[serde(rename_all = "kebab-case")]
2647pub enum StatusOptionsOutcome {
2648 /// A read-only plan.
2649 Planned,
2650 /// Apply found nothing missing.
2651 Unchanged,
2652 /// Additions were applied and verified.
2653 Applied,
2654}
2655
2656/// A GitHub single-select option's opaque GraphQL node identifier.
2657#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2658#[serde(transparent)]
2659pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2660
2661impl TryFrom<String> for StatusOptionId {
2662 type Error = String;
2663
2664 fn try_from(id: String) -> Result<Self, Self::Error> {
2665 if id.trim().is_empty() {
2666 return Err("a GitHub Status option id cannot be blank".to_owned());
2667 }
2668 Ok(Self(id))
2669 }
2670}
2671
2672/// One existing or proposed option in a guarded Status-field update.
2673#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2674pub struct StatusOption {
2675 /// GitHub's stable id.
2676 pub id: StatusOptionId,
2677 /// The visible option name.
2678 pub name: ColumnName,
2679 /// GitHub's single-select color token.
2680 pub color: StatusOptionColor,
2681 /// The option description, including an empty one.
2682 pub description: String,
2683}
2684
2685/// One board item's Status assignment, retained as recovery data.
2686#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2687pub struct StatusAssignment {
2688 /// The project item id whose assignment this is.
2689 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2690 // carried verbatim as operator recovery data; introducing a semantic type would claim
2691 // validation rules GitHub does not publish and no operation here interprets.
2692 pub item_id: String,
2693 /// The selected option, absent when the item has no status.
2694 #[serde(skip_serializing_if = "Option::is_none")]
2695 pub option: Option<AssignedStatusOption>,
2696}
2697
2698/// The inseparable id and name of an assigned option.
2699#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2700pub struct AssignedStatusOption {
2701 /// GitHub's stable id.
2702 pub id: StatusOptionId,
2703 /// The visible name.
2704 pub name: ColumnName,
2705}
2706
2707/// The plan and verified outcome of reconciling configured Status options.
2708#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2709pub struct StatusOptionsReport {
2710 /// The configured source name.
2711 pub source: SourceName,
2712 /// Configured option names absent before the operation.
2713 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2714 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2715 // serialized string here preserves the report's intentionally simple public contract.
2716 pub missing: Vec<String>,
2717 /// What the requested operation did.
2718 pub outcome: StatusOptionsOutcome,
2719 /// The complete option list observed before any mutation.
2720 pub existing: Vec<StatusOption>,
2721}
2722
2723#[derive(Debug, Clone, PartialEq, Eq)]
2724struct StatusSnapshot {
2725 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2726 // passed back as the mutation's project identity; a newtype could enforce no stronger
2727 // invariant because GitHub publishes no grammar for it.
2728 board_id: String,
2729 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2730 // passed back as the mutation's field identity; a newtype could enforce no stronger
2731 // invariant because GitHub publishes no grammar for it.
2732 field_id: String,
2733 options: Vec<StatusOption>,
2734 assignments: Vec<StatusAssignment>,
2735}
2736
2737/// The name of the board field a status is held in.
2738const STATUS_FIELD: &str = "Status";
2739
2740/// Every item's value of each field `report` names, as it stood before the setup wrote
2741/// anything — what a person puts back when the setup is refused part way.
2742fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2743 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2744 .fields
2745 .iter()
2746 .map(|field| (field.field.name(), before.assignments(field.field)))
2747 .collect();
2748 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2749 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2750 })
2751}
2752
2753/// One board field the guarded setup reads and writes — every one it reads, and the only
2754/// ones it writes.
2755#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2756pub enum BoardField {
2757 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2758 Status,
2759 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2760 Priority,
2761}
2762
2763impl BoardField {
2764 /// The field's name on the board.
2765 #[must_use]
2766 pub const fn name(self) -> &'static str {
2767 match self {
2768 Self::Status => STATUS_FIELD,
2769 Self::Priority => PRIORITY_FIELD,
2770 }
2771 }
2772
2773 /// The field a board calls `name`, or `None` for one this setup does not own.
2774 fn named(name: &str) -> Option<Self> {
2775 [Self::Status, Self::Priority]
2776 .into_iter()
2777 .find(|field| field.name() == name)
2778 }
2779}
2780
2781/// What the guarded setup did to one field.
2782#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2783#[serde(rename_all = "kebab-case")]
2784pub enum FieldOutcome {
2785 /// A read-only plan.
2786 Planned,
2787 /// Apply found the field there with every configured option.
2788 Unchanged,
2789 /// Missing options were added to the field that was there, and verified.
2790 Applied,
2791 /// The field was not there; it was created holding the configured options, and verified.
2792 Created,
2793}
2794
2795/// One field's plan, or its verified outcome.
2796#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2797pub struct FieldReport {
2798 /// Which field.
2799 pub field: BoardField,
2800 /// Whether the board had the field before the operation.
2801 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2802 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2803 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2804 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2805 // one constructor, and it derives `outcome` from `exists` in one match.
2806 pub exists: bool,
2807 /// Configured option names the field lacked before the operation — every one of them,
2808 /// in the order a new field lists them, when the field was not there at all.
2809 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2810 // mapping name and has therefore already passed its nonblank validation; the serialized
2811 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2812 pub missing: Vec<String>,
2813 /// For the `Status` field, which item kind each missing name is configured for: one
2814 /// entry per kind `status_mapping` names a missing option for, task before project, each
2815 /// listing that kind's missing names in category order. A name both kinds use is in
2816 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2817 /// `Priority`, which only a task holds.
2818 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2819 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2820 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2821 #[schemars(!skip_serializing_if)]
2822 pub kinds: Vec<KindMissing>,
2823 /// What the requested operation did.
2824 pub outcome: FieldOutcome,
2825 /// The field's complete option list observed before any mutation; empty when the field
2826 /// was not there.
2827 pub existing: Vec<StatusOption>,
2828}
2829
2830/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2831#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2832pub struct KindMissing {
2833 /// The kind these names are configured for.
2834 pub kind: ItemKind,
2835 /// The names that kind maps a category to and the field lacked, in category order.
2836 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2837 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2838 // intentionally simple public contract.
2839 pub missing: Vec<String>,
2840}
2841
2842/// The plan and verified outcome of setting up every field a source's configuration names.
2843#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2844pub struct FieldsReport {
2845 /// The configured source name.
2846 pub source: SourceName,
2847 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2848 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2849 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2850 // per field would change a published JSON shape. The states the list could hold and the
2851 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2852 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2853 pub fields: Vec<FieldReport>,
2854}
2855
2856/// Which options one field is configured with, in the order a new field would list them.
2857struct FieldPlan {
2858 field: BoardField,
2859 wanted: Vec<String>,
2860}
2861
2862/// One single-select field as the guarded setup snapshots it.
2863#[derive(Debug, Clone, PartialEq, Eq)]
2864struct SnapshotField {
2865 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2866 // passed back as the mutation's field identity; a newtype could enforce no stronger
2867 // invariant because GitHub publishes no grammar for it.
2868 field_id: String,
2869 options: Vec<StatusOption>,
2870}
2871
2872/// Every single-select field of a board and every item's value of each.
2873#[derive(Debug, Clone, PartialEq, Eq)]
2874struct BoardSnapshot {
2875 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2876 // passed back as the mutation's project identity; a newtype could enforce no stronger
2877 // invariant because GitHub publishes no grammar for it.
2878 board_id: String,
2879 fields: BTreeMap<BoardField, SnapshotField>,
2880 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2881 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2882}
2883
2884impl BoardSnapshot {
2885 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2886 /// carries.
2887 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2888 self.items
2889 .iter()
2890 .map(|(item_id, values)| StatusAssignment {
2891 item_id: item_id.clone(),
2892 option: values.get(&field).cloned(),
2893 })
2894 .collect()
2895 }
2896}
2897
2898impl GitHubProjectsSource {
2899 /// Report missing configured Status options and, when `apply` is true, add them with
2900 /// a whole-list mutation that preserves every existing id and verifies the result.
2901 ///
2902 /// # Errors
2903 ///
2904 /// Refuses a board without a single-select `Status` field. A post-write difference in
2905 /// any pre-existing option id or item assignment is refused with the complete pre-write
2906 /// assignment snapshot in the diagnostic for recovery.
2907 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2908 // successful mutation, both drift refusals, source selection, missing Status, casing,
2909 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2910 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2911 // responses from entering the defensive malformed-response branches below.
2912 pub async fn status_options(
2913 &self,
2914 mode: StatusOptionsMode,
2915 ) -> Result<StatusOptionsReport, SourceError> {
2916 let before = self.status_snapshot().await?;
2917 // A terminal category's option is as configured as an open one's: a terminal
2918 // write validates it before closing and refuses when the board lacks it. Both
2919 // kinds' names are options of the one field, so both are asked for.
2920 let missing = self
2921 .statuses
2922 .wanted()
2923 .into_iter()
2924 .filter(|wanted| {
2925 !before
2926 .options
2927 .iter()
2928 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2929 })
2930 .collect::<Vec<_>>();
2931 let report = StatusOptionsReport {
2932 source: self.name.clone(),
2933 missing: missing.clone(),
2934 outcome: match (mode, missing.is_empty()) {
2935 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2936 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2937 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2938 },
2939 existing: before.options.clone(),
2940 };
2941 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2942 return Ok(report);
2943 }
2944 let mut options = before
2945 .options
2946 .iter()
2947 .map(|option| {
2948 json!({
2949 "id": option.id, "name": option.name, "color": option.color,
2950 "description": option.description,
2951 })
2952 })
2953 .collect::<Vec<_>>();
2954 options.extend(missing.iter().map(|name| {
2955 json!({
2956 "name": name, "color": "GRAY", "description": ""
2957 })
2958 }));
2959 self.graphql(
2960 graphql::STATUS_OPTIONS_UPDATE,
2961 json!({"input": {
2962 "projectId": before.board_id, "fieldId": before.field_id,
2963 "singleSelectOptions": options,
2964 }}),
2965 )
2966 .await?;
2967 let after = self.status_snapshot().await?;
2968 let options_preserved = before
2969 .options
2970 .iter()
2971 .all(|old| after.options.iter().any(|new| new == old));
2972 let additions_present = missing.iter().all(|wanted| {
2973 after
2974 .options
2975 .iter()
2976 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2977 });
2978 if !options_preserved || !additions_present || after.assignments != before.assignments {
2979 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2980 SourceError::Malformed {
2981 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2982 }
2983 })?;
2984 return Err(SourceError::Refused {
2985 message: format!(
2986 "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}"
2987 ),
2988 });
2989 }
2990 Ok(report)
2991 }
2992
2993 /// A fresh snapshot of the Status field and every board item's assignment of it.
2994 ///
2995 /// # Errors
2996 ///
2997 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2998 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2999 // Status alone, as this operation has always read it: a `Priority` field is another
3000 // operation's, so nothing about it can refuse this one.
3001 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
3002 let field = board
3003 .fields
3004 .remove(&BoardField::Status)
3005 .ok_or_else(|| self.no_status_field())?;
3006 Ok(StatusSnapshot {
3007 assignments: board.assignments(BoardField::Status),
3008 board_id: board.board_id,
3009 field_id: field.field_id,
3010 options: field.options,
3011 })
3012 }
3013
3014 /// The refusal a board with no `Status` field is answered with by the guarded setup.
3015 fn no_status_field(&self) -> SourceError {
3016 SourceError::Refused {
3017 message: format!("source {} board has no Status field", self.name),
3018 }
3019 }
3020
3021 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
3022 // the real CLI loopback journey, including pagination. The individual malformed guards
3023 // are defensive validation of a schema-pinned third-party response, not separate user
3024 // journeys; drift and missing-field failures cover the operation's recovery behavior.
3025 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
3026 /// every board item's value of each, walked to the end of the board's items. A field not
3027 /// in `owned` is read past whatever it holds.
3028 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
3029 let mut after: Option<String> = None;
3030 let mut snapshot: Option<BoardSnapshot> = None;
3031 loop {
3032 let data = self
3033 .graphql(
3034 graphql::STATUS_OPTIONS_SNAPSHOT,
3035 json!({
3036 "owner": self.owner, "number": self.project_number,
3037 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3038 }),
3039 )
3040 .await?;
3041 let board = data
3042 .pointer("/owner/projectV2")
3043 .filter(|board| board.is_object())
3044 .ok_or_else(|| SourceError::Refused {
3045 message: format!(
3046 "source {} has no accessible GitHub Projects board",
3047 self.name
3048 ),
3049 })?;
3050 if board
3051 .pointer("/fields/pageInfo/hasNextPage")
3052 .and_then(Value::as_bool)
3053 != Some(false)
3054 {
3055 return Err(SourceError::Malformed {
3056 message:
3057 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3058 .into(),
3059 });
3060 }
3061 let mut fields = BTreeMap::new();
3062 // Only the fields this setup owns, by name: a node the single-select fragment did not
3063 // match carries no name, and a person's own single-select field — a `Size`, a
3064 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3065 // `Status` or `Priority` field without its options is malformed, not absent.
3066 // 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.
3067 for (owned, field) in board
3068 .pointer("/fields/nodes")
3069 .and_then(Value::as_array)
3070 .ok_or_else(|| SourceError::Malformed {
3071 message: "GitHub project fields.nodes is not an array".into(),
3072 })?
3073 .iter()
3074 .filter_map(|field| {
3075 let named = BoardField::named(field.get("name")?.as_str()?)?;
3076 owned.contains(&named).then_some((named, field))
3077 })
3078 {
3079 let options = field
3080 .get("options")
3081 .and_then(Value::as_array)
3082 .ok_or_else(|| SourceError::Malformed {
3083 message: "GitHub single-select field options is not an array".into(),
3084 })?
3085 .iter()
3086 .map(|option| {
3087 Ok(StatusOption {
3088 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3089 .map_err(|message| SourceError::Malformed { message })?,
3090 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3091 .map_err(|message| SourceError::Malformed {
3092 message: format!(
3093 "GitHub single-select option name is invalid: {message}"
3094 ),
3095 })?,
3096 color: serde_json::from_value(
3097 option.get("color").cloned().unwrap_or(Value::Null),
3098 )
3099 .map_err(|error| {
3100 SourceError::Malformed {
3101 message: format!(
3102 "GitHub single-select option color is invalid: {error}"
3103 ),
3104 }
3105 })?,
3106 description: optional_str(option, "description")?
3107 .unwrap_or_default()
3108 .to_owned(),
3109 })
3110 })
3111 .collect::<Result<Vec<_>, SourceError>>()?;
3112 let snapshot = SnapshotField {
3113 field_id: required_nonblank_str(field, "id")?.to_owned(),
3114 options,
3115 };
3116 // A board's field names are unique, so a second one is an answer that cannot
3117 // say which field the setup would act on — refused rather than one chosen.
3118 if fields.insert(owned, snapshot).is_some() {
3119 return Err(SourceError::Malformed {
3120 message: format!(
3121 "GitHub answered two {} fields for this board",
3122 owned.name()
3123 ),
3124 });
3125 }
3126 }
3127 let board_id = required_nonblank_str(board, "id")?.to_owned();
3128 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3129 board_id,
3130 fields,
3131 items: Vec::new(),
3132 });
3133 let items = board
3134 .pointer("/items/nodes")
3135 .and_then(Value::as_array)
3136 .ok_or_else(|| SourceError::Malformed {
3137 message: "GitHub project items.nodes is not an array".into(),
3138 })?;
3139 for item in items {
3140 let field_values =
3141 item.get("fieldValues")
3142 .ok_or_else(|| SourceError::Malformed {
3143 message: "GitHub project item is missing fieldValues".into(),
3144 })?;
3145 if field_values
3146 .pointer("/pageInfo/hasNextPage")
3147 .and_then(Value::as_bool)
3148 != Some(false)
3149 {
3150 return Err(SourceError::Malformed {
3151 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3152 });
3153 }
3154 let values = item
3155 .pointer("/fieldValues/nodes")
3156 .and_then(Value::as_array)
3157 .ok_or_else(|| SourceError::Malformed {
3158 message: "GitHub project item fieldValues.nodes is not an array".into(),
3159 })?;
3160 let item_id = required_nonblank_str(item, "id")?;
3161 let mut assigned = BTreeMap::new();
3162 for value in values {
3163 let Some(field) = value
3164 .pointer("/field/name")
3165 .and_then(Value::as_str)
3166 .and_then(BoardField::named)
3167 .filter(|field| owned.contains(field))
3168 else {
3169 continue;
3170 };
3171 let held = assigned.insert(
3172 field,
3173 AssignedStatusOption {
3174 id: StatusOptionId::try_from(
3175 required_str(value, "optionId")?.to_owned(),
3176 )
3177 .map_err(|message| SourceError::Malformed { message })?,
3178 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3179 .map_err(|message| SourceError::Malformed {
3180 message: format!(
3181 "GitHub assigned {} name is invalid: {message}",
3182 field.name()
3183 ),
3184 })?,
3185 },
3186 );
3187 // An item holds one value of a field, so a second one leaves no way to
3188 // tell which it holds — and a verification or recovery built on either
3189 // could restore the wrong one.
3190 if held.is_some() {
3191 return Err(SourceError::Malformed {
3192 message: format!(
3193 "GitHub answered two {} values for board item {item_id}",
3194 field.name()
3195 ),
3196 });
3197 }
3198 }
3199 current.items.push((item_id.to_owned(), assigned));
3200 }
3201 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3202 message: "GitHub project is missing items".into(),
3203 })?;
3204 let has_next = page
3205 .pointer("/pageInfo/hasNextPage")
3206 .and_then(Value::as_bool)
3207 .ok_or_else(|| SourceError::Malformed {
3208 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3209 })?;
3210 if !has_next {
3211 break;
3212 }
3213 let next =
3214 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3215 validate_cursor_progress(after.as_deref(), next)?;
3216 after = Some(next.to_owned());
3217 }
3218 snapshot.ok_or_else(|| SourceError::Malformed {
3219 message: "GitHub returned no board field snapshot".into(),
3220 })
3221 }
3222 // llmlint: ignore-end[changed_behavior_has_e2e]
3223
3224 /// Report every board field this source's configuration names and, with
3225 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3226 /// the `Priority` field when the board has none.
3227 ///
3228 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3229 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3230 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3231 /// color and description: the whole option list goes back with every existing id, because
3232 /// a re-minted id clears every item's value.
3233 ///
3234 /// # Errors
3235 ///
3236 /// Refuses a board without a single-select `Status` field. After an apply the board is
3237 /// read again, and a pre-existing option or any item's value of either field that moved is
3238 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3239 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3240 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3241 // with no Status field and a non-github-projects source through the compiled CLI against
3242 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3243 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3244 let owned: Vec<BoardField> = if self.priorities.is_some() {
3245 vec![BoardField::Status, BoardField::Priority]
3246 } else {
3247 vec![BoardField::Status]
3248 };
3249 let before = self.board_snapshot(&owned).await?;
3250 let mut plans = vec![FieldPlan {
3251 field: BoardField::Status,
3252 wanted: self.statuses.wanted(),
3253 }];
3254 if !before.fields.contains_key(&BoardField::Status) {
3255 return Err(self.no_status_field());
3256 }
3257 if let Some(mapping) = &self.priorities {
3258 plans.push(FieldPlan {
3259 field: BoardField::Priority,
3260 wanted: mapping.names().map(str::to_owned).collect(),
3261 });
3262 }
3263 // The snapshot reads single-select fields alone, so a field it did not find may still
3264 // be on the board under the name, of another type: creating one beside it would fail
3265 // part way, or leave two fields of one name. Asked of the board's own field list, and
3266 // only when a field is missing.
3267 if plans
3268 .iter()
3269 .any(|plan| !before.fields.contains_key(&plan.field))
3270 {
3271 let board = self.board_fields().await?;
3272 for plan in plans
3273 .iter()
3274 .filter(|plan| !before.fields.contains_key(&plan.field))
3275 {
3276 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3277 return Err(SourceError::Refused {
3278 message: format!(
3279 "source {}'s board has a {} field that is not a single-select field \
3280 (it is a {}), so it cannot hold this source's options; next: rename \
3281 or remove that field, then run this again",
3282 self.name,
3283 plan.field.name(),
3284 optional_str(field, "__typename")?.unwrap_or("field of another type")
3285 ),
3286 });
3287 }
3288 }
3289 }
3290 let mut reports = Vec::new();
3291 for plan in &plans {
3292 let held = before.fields.get(&plan.field);
3293 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3294 let mut missing: Vec<String> = Vec::new();
3295 for wanted in &plan.wanted {
3296 let present = existing
3297 .iter()
3298 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3299 || missing
3300 .iter()
3301 .any(|named| named.eq_ignore_ascii_case(wanted));
3302 if !present {
3303 missing.push(wanted.clone());
3304 }
3305 }
3306 let kinds = match plan.field {
3307 BoardField::Status => self.statuses.missing_by_kind(&existing),
3308 BoardField::Priority => Vec::new(),
3309 };
3310 reports.push(FieldReport {
3311 field: plan.field,
3312 exists: held.is_some(),
3313 kinds,
3314 outcome: match (mode, held.is_some(), missing.is_empty()) {
3315 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3316 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3317 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3318 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3319 },
3320 missing,
3321 existing,
3322 });
3323 }
3324 let report = FieldsReport {
3325 source: self.name.clone(),
3326 fields: reports,
3327 };
3328 let writes: Vec<&FieldReport> = report
3329 .fields
3330 .iter()
3331 .filter(|field| !field.missing.is_empty() || !field.exists)
3332 .collect();
3333 if mode == SetupMode::Plan || writes.is_empty() {
3334 return Ok(report);
3335 }
3336 let mut landed: Vec<&str> = Vec::new();
3337 for field in &writes {
3338 let added = field
3339 .missing
3340 .iter()
3341 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3342 let sent = match before.fields.get(&field.field) {
3343 Some(held) => {
3344 let mut options = held
3345 .options
3346 .iter()
3347 .map(|option| {
3348 json!({
3349 "id": option.id, "name": option.name, "color": option.color,
3350 "description": option.description,
3351 })
3352 })
3353 .collect::<Vec<_>>();
3354 options.extend(added);
3355 self.graphql(
3356 graphql::STATUS_OPTIONS_UPDATE,
3357 json!({"input": {
3358 "projectId": before.board_id, "fieldId": held.field_id,
3359 "singleSelectOptions": options,
3360 }}),
3361 )
3362 .await
3363 }
3364 None => {
3365 self.graphql(
3366 graphql::CREATE_FIELD,
3367 json!({"input": {
3368 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3369 "name": field.field.name(),
3370 "singleSelectOptions": added.collect::<Vec<_>>(),
3371 }}),
3372 )
3373 .await
3374 }
3375 };
3376 // A mutation that failed does not establish that GitHub left its field as it was,
3377 // so every failure from here on carries the recovery data a drift refusal does.
3378 match sent {
3379 Ok(_) => landed.push(field.field.name()),
3380 Err(error) => {
3381 let changed = if landed.is_empty() {
3382 String::new()
3383 } else {
3384 format!("changed the {} field and then ", landed.join(" and "))
3385 };
3386 return Err(SourceError::Refused {
3387 message: format!(
3388 "the guarded field setup {changed}failed on the {} field, which it may \
3389 have changed part way: {error}; the pre-write item assignments \
3390 are:\n{}",
3391 field.field.name(),
3392 recovery(&report, &before)?
3393 ),
3394 });
3395 }
3396 }
3397 }
3398 // The board has been written, so a verification read that fails leaves it unverified
3399 // rather than unchanged, and says what to put back.
3400 let after = match self.board_snapshot(&owned).await {
3401 Ok(after) => after,
3402 Err(error) => {
3403 return Err(SourceError::Refused {
3404 message: format!(
3405 "the guarded field setup changed the {} field and then could not read the \
3406 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3407 landed.join(" and "),
3408 recovery(&report, &before)?
3409 ),
3410 });
3411 }
3412 };
3413 let mut moved = Vec::new();
3414 for field in &report.fields {
3415 let name = field.field.name();
3416 let now = after
3417 .fields
3418 .get(&field.field)
3419 .map(|held| held.options.as_slice())
3420 .unwrap_or_default();
3421 if !field.existing.iter().all(|old| now.contains(old)) {
3422 moved.push(format!(
3423 "a pre-existing {name} option id, name, color or description"
3424 ));
3425 }
3426 if !field.missing.iter().all(|wanted| {
3427 now.iter()
3428 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3429 }) {
3430 moved.push(format!("an added {name} option"));
3431 }
3432 if after.assignments(field.field) != before.assignments(field.field) {
3433 moved.push(format!("an item's {name} value"));
3434 }
3435 }
3436 if !moved.is_empty() {
3437 return Err(SourceError::Refused {
3438 message: format!(
3439 "GitHub changed {} after the guarded field setup; the pre-write item \
3440 assignments are:\n{}",
3441 moved.join(", "),
3442 recovery(&report, &before)?
3443 ),
3444 });
3445 }
3446 Ok(report)
3447 }
3448
3449 /// Validate configuration and capture the named credential without exposing it.
3450 ///
3451 /// # Errors
3452 ///
3453 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3454 /// [`SourceError::Auth`] when the named credential is missing or empty.
3455 pub fn new(
3456 name: &SourceName,
3457 config: GitHubProjectsConfig,
3458 secrets: &dyn SecretResolver,
3459 ) -> Result<Self, SourceError> {
3460 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3461 }
3462
3463 /// The same, recording every request it sends into an accounting the caller holds too.
3464 ///
3465 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3466 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3467 /// up — passes the one it records those into, so the session total accounts for the
3468 /// whole session rather than for this source's share of it.
3469 ///
3470 /// # Errors
3471 ///
3472 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3473 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3474 pub fn recording_into(
3475 name: &SourceName,
3476 config: GitHubProjectsConfig,
3477 secrets: &dyn SecretResolver,
3478 ledger: Arc<Accounting>,
3479 ) -> Result<Self, SourceError> {
3480 if !valid_github_owner(&config.owner) {
3481 return Err(SourceError::Config {
3482 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3483 });
3484 }
3485 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3486 return Err(SourceError::Config {
3487 message: format!("project_number must be between 1 and {}", i32::MAX),
3488 });
3489 }
3490 if !valid_environment_name(&config.token_env) {
3491 return Err(SourceError::Config {
3492 message: "token_env must be a valid environment-variable name".into(),
3493 });
3494 }
3495 let repository = config
3496 .repository
3497 .as_deref()
3498 .map(RepositoryTarget::parse)
3499 .transpose()?;
3500 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3501 message: format!("endpoint is not a valid URL: {e}"),
3502 })?;
3503 if endpoint.scheme() != "https"
3504 && !(endpoint.scheme() == "http"
3505 && endpoint
3506 .host_str()
3507 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3508 {
3509 return Err(SourceError::Config {
3510 message:
3511 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3512 .into(),
3513 });
3514 }
3515 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3516 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),
3517 })?;
3518 Ok(Self {
3519 name: name.clone(),
3520 owner: config.owner,
3521 project_number: config.project_number,
3522 repository,
3523 asset_client: assets::client(&endpoint)?,
3524 endpoint,
3525 token,
3526 credential_name: config.token_env,
3527 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3528 priorities: config
3529 .priority_mapping
3530 .map(|mapping| PriorityMapping::resolve(mapping, name))
3531 .transpose()?,
3532 client: Client::builder()
3533 .user_agent("onetaskgraph")
3534 .build()
3535 .map_err(|e| SourceError::Config {
3536 message: format!("cannot build HTTP client: {e}"),
3537 })?,
3538 created: Mutex::new(Vec::new()),
3539 updated: Mutex::new(Vec::new()),
3540 commented: Mutex::new(Vec::new()),
3541 pacing: Pacing::resolve(config.pacing, name)?,
3542 last_mutation: Mutex::new(None),
3543 clock: system_clock(),
3544 numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
3545 board_cache: Mutex::new(None),
3546 search_cache: Mutex::new(None),
3547 narrowed_cache: Mutex::new(BTreeMap::new()),
3548 resolved_cache: Mutex::new(BTreeMap::new()),
3549 children_cache: Mutex::new(BTreeMap::new()),
3550 search_next: Mutex::new(BTreeMap::new()),
3551 fields_cache: Mutex::new(None),
3552 repository_cache: Mutex::new(BTreeMap::new()),
3553 ledger,
3554 })
3555 }
3556
3557 /// A snapshot of every request this source has sent, and what each cost.
3558 ///
3559 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3560 /// of them can sit side by side. When this source was built with
3561 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3562 /// point of building it that way.
3563 #[must_use]
3564 pub fn accounting(&self) -> accounting::Session {
3565 self.ledger.snapshot()
3566 }
3567
3568 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3569 /// rate limit rather than handing it straight back as an error.
3570 ///
3571 /// Retrying is safe for every document here, including the mutations, and the reason
3572 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3573 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3574 /// this replays has already taken effect. An outcome this source cannot know — the
3575 /// send failed, or the body could not be read, so the mutation may well have landed —
3576 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3577 /// attempt. A duplicate write would come from replaying one of those, and none is
3578 /// replayed.
3579 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3580 if is_mutation(query)
3581 && ![
3582 graphql::ADD_COMMENT,
3583 graphql::UPDATE_COMMENT,
3584 graphql::DELETE_COMMENT,
3585 ]
3586 .contains(&query)
3587 {
3588 let mut cache = self.resolved_cache()?;
3589 for argument in ["input", "second", "third", "clear"] {
3590 if let Some(input) = variables.get(argument) {
3591 cache.retain(|id, item| {
3592 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3593 input
3594 .get(key)
3595 .and_then(Value::as_str)
3596 .is_some_and(|value| value == id.0 || value == item.item_id)
3597 })
3598 });
3599 }
3600 }
3601 }
3602 let doing = operation_description(query);
3603 let mut waited = Duration::ZERO;
3604 let mut waits = 0_u32;
3605 let mut backoff = self.pacing.retry_backoff;
3606 loop {
3607 if is_mutation(query) {
3608 let spacing = self.reserve_mutation_slot();
3609 if !spacing.is_zero() {
3610 self.clock.sleep(spacing).await;
3611 }
3612 }
3613 let attempt = self.send_once(query, &variables).await;
3614 if is_mutation(query) {
3615 self.finish_mutation();
3616 }
3617 let limited = match attempt {
3618 Ok(data) => return Ok(data),
3619 Err(Attempt::Failed(error)) => return Err(error),
3620 Err(Attempt::Limited(limited)) => limited,
3621 };
3622 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3623 // move that extends a secondary limit, so a hint below the schedule's own next
3624 // wait is raised to it.
3625 let wait = match limited.hint {
3626 Some(hint) => Duration::from_secs(hint).max(backoff),
3627 None => backoff,
3628 };
3629 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3630 // A wait of nothing spends none of the budget, so it is exhaustion rather
3631 // than a retry. `Pacing::resolve` rules out every way of configuring one
3632 // except a budget of zero, where reporting the first refusal is the ask.
3633 if wait.is_zero() || wait > remaining {
3634 return Err(limited.exhausted(
3635 doing,
3636 waits,
3637 waited,
3638 wait,
3639 self.pacing.retry_budget,
3640 ));
3641 }
3642 self.clock.sleep(wait).await;
3643 waited += wait;
3644 waits += 1;
3645 backoff = backoff.saturating_mul(2);
3646 }
3647 }
3648
3649 /// The next moment a content-creating mutation may leave this source, as a wait from
3650 /// now.
3651 ///
3652 /// The slot is reserved under the lock and the waiting happens outside it, so two
3653 /// callers take two slots rather than the same one — and no lock is held across an
3654 /// await.
3655 ///
3656 /// The moment it is spaced from is the previous mutation's *completion*, which
3657 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3658 /// own is the wrong thing to measure from.
3659 fn reserve_mutation_slot(&self) -> Duration {
3660 if self.pacing.min_mutation_interval.is_zero() {
3661 return Duration::ZERO;
3662 }
3663 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3664 // it would turn an earlier failure into a second one for no gain.
3665 let mut last = self
3666 .last_mutation
3667 .lock()
3668 .unwrap_or_else(std::sync::PoisonError::into_inner);
3669 let now = self.clock.now();
3670 // `checked_add` rather than `+`: adding durations can panic on overflow, and
3671 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3672 let at = last.map_or(now, |previous| {
3673 previous
3674 .checked_add(self.pacing.min_mutation_interval)
3675 .map_or(now, |earliest| earliest.max(now))
3676 });
3677 *last = Some(at);
3678 at.saturating_sub(now)
3679 }
3680
3681 /// Record that a content-creating mutation has finished, so the next one is spaced
3682 /// from here rather than from the moment this one was released.
3683 ///
3684 /// This source can only choose when a request *departs*; the limiter counts when it
3685 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3686 /// departure from the last therefore hands the limiter a gap of the interval less that
3687 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3688 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3689 /// slower machine while passing on a quick one.
3690 ///
3691 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3692 /// previous request had already arrived before its response came back, so its arrival
3693 /// is no later than this moment, and the next mutation is released at least the
3694 /// interval after this moment and arrives no earlier than it is released: the gap the
3695 /// limiter measures is therefore at least the interval, whatever transit costs and on
3696 /// whatever platform. The price is that a mutation's own round trip no longer counts
3697 /// towards its spacing, which makes this source slightly slower than the configured
3698 /// rate rather than slightly faster — the safe side of a limit that punishes being
3699 /// wrong by refusing reads for the next fifty minutes.
3700 ///
3701 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3702 /// and one that never left costs only a wait nobody needed.
3703 fn finish_mutation(&self) {
3704 if self.pacing.min_mutation_interval.is_zero() {
3705 return;
3706 }
3707 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3708 let mut last = self
3709 .last_mutation
3710 .lock()
3711 .unwrap_or_else(std::sync::PoisonError::into_inner);
3712 let now = self.clock.now();
3713 // `max` rather than an assignment: a concurrent caller may already have reserved a
3714 // slot further out, and completing this request must never pull that slot back in.
3715 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3716 }
3717
3718 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3719 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3720 ///
3721 /// This is the one place a request leaves this crate, which is why the accounting is
3722 /// here rather than at each of the callers: a read path added later is counted without
3723 /// anybody remembering to count it, and
3724 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3725 /// when one is not.
3726 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3727 let Attempted {
3728 result,
3729 limits,
3730 reported_cost,
3731 } = self.attempt(query, variables).await;
3732 // No `otherwise` name: every document this source sends is one of its own, and the
3733 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3734 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3735 let outcome = match &result {
3736 Ok(_) => accounting::Outcome::Answered,
3737 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3738 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3739 };
3740 self.ledger.record(sending.finished(outcome, limits));
3741 result
3742 }
3743
3744 /// The attempt itself, with what its response said about the rate limit alongside.
3745 ///
3746 /// The two are returned together rather than recorded here because every one of the
3747 /// early exits below is a different outcome, and a record written at each of them is a
3748 /// record one of them can be added without.
3749 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3750 let mut limits = accounting::RateLimit::default();
3751 let mut reported_cost = None;
3752 let result = self
3753 .attempted(query, variables, &mut limits, &mut reported_cost)
3754 .await;
3755 Attempted {
3756 result,
3757 limits,
3758 reported_cost,
3759 }
3760 }
3761
3762 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3763 async fn attempted(
3764 &self,
3765 query: &str,
3766 variables: &Value,
3767 limits: &mut accounting::RateLimit,
3768 reported_cost: &mut Option<u64>,
3769 ) -> Result<Value, Attempt> {
3770 let response = self
3771 .client
3772 .post(self.endpoint.clone())
3773 .bearer_auth(self.token.expose_secret())
3774 .json(&json!({"query": query, "variables": variables}))
3775 .send()
3776 .await
3777 .map_err(|e| {
3778 Attempt::Failed(SourceError::Unavailable {
3779 message: format!("GitHub GraphQL request failed: {e}"),
3780 })
3781 })?;
3782 let status = response.status();
3783 let header = |name: &str| whole_seconds(response.headers().get(name));
3784 *limits = accounting::RateLimit::read(|name| {
3785 response
3786 .headers()
3787 .get(name)
3788 .and_then(|value| value.to_str().ok())
3789 .map(str::to_owned)
3790 });
3791 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3792 // that are not text at all — is "not known to be exhausted". This never makes a
3793 // response a refusal on its own: it says which limiter a refusal is attributed to
3794 // and where its hint comes from, so a value this cannot read costs a hint rather
3795 // than an answer.
3796 let exhausted = response
3797 .headers()
3798 .get("x-ratelimit-remaining")
3799 .and_then(|value| value.to_str().ok())
3800 == Some("0");
3801 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3802 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3803 // which is the same question answered as an absolute time. Nothing else here is a
3804 // hint, and a schedule is what answers a refusal that carries none.
3805 let hint = header("retry-after").or_else(|| {
3806 exhausted
3807 .then(|| header("x-ratelimit-reset"))
3808 .flatten()
3809 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3810 });
3811 // Read before it is parsed, because the evidence which tells a secondary rate
3812 // limit from a rejected credential is in the body of a response whose status says
3813 // only "forbidden" — and a non-success response was never parsed at all.
3814 let body = response.text().await.map_err(|e| {
3815 Attempt::Failed(SourceError::Unavailable {
3816 message: format!("GitHub GraphQL response could not be read: {e}"),
3817 })
3818 })?;
3819 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3820 return Err(Attempt::Limited(Limited { limiter, hint }));
3821 }
3822 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3823 return Err(Attempt::Failed(SourceError::Auth {
3824 message: format!(
3825 "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"
3826 ),
3827 }));
3828 }
3829 if !status.is_success() {
3830 return Err(Attempt::Failed(SourceError::Unavailable {
3831 message: format!("GitHub GraphQL returned HTTP {status}"),
3832 }));
3833 }
3834 // GitHub reports what a call cost only when the document asked it to, and no
3835 // document this source sends does — so this is `None` here and carries the figure
3836 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3837 // pick up is a `dryRun` probe's cost, which is some other document's.
3838 *reported_cost = serde_json::from_str::<Value>(&body)
3839 .ok()
3840 .as_ref()
3841 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3842 .and_then(Value::as_u64);
3843 self.answer(&body).map_err(Attempt::Failed)
3844 }
3845
3846 /// What one successful HTTP response says, once its GraphQL errors are read.
3847 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3848 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3849 message: format!("GitHub returned invalid JSON: {e}"),
3850 })?;
3851 let errors = body
3852 .get("errors")
3853 .map(|value| {
3854 value.as_array().ok_or_else(|| SourceError::Malformed {
3855 message: "GitHub response errors is not an array".into(),
3856 })
3857 })
3858 .transpose()?;
3859 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3860 let messages = errors
3861 .iter()
3862 .filter_map(|e| e.get("message").and_then(Value::as_str))
3863 .collect::<Vec<_>>()
3864 .join("; ");
3865 let message = if messages.is_empty() {
3866 "GitHub returned GraphQL errors".into()
3867 } else {
3868 messages
3869 };
3870 let normalized = message.to_ascii_lowercase();
3871 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3872 return Err(SourceError::Auth {
3873 message: format!(
3874 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3875 self.credential_name
3876 ),
3877 });
3878 }
3879 return Err(SourceError::Refused { message });
3880 }
3881 body.get("data")
3882 .filter(|data| data.is_object())
3883 .cloned()
3884 .ok_or_else(|| SourceError::Malformed {
3885 message: "GitHub response has no data object".into(),
3886 })
3887 }
3888
3889 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3890 // GraphQL cannot independently page them inside the outer item page. This source page is
3891 // deliberately bounded at that published maximum; the live drift journey exercises it.
3892 async fn board_page(
3893 &self,
3894 items_after: Option<&str>,
3895 items_first: u32,
3896 ) -> Result<Value, SourceError> {
3897 let data = self
3898 .graphql(
3899 graphql::BOARD,
3900 json!({"owner":self.owner,"number":self.project_number,
3901 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3902 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3903 )
3904 .await?;
3905 data.pointer("/owner/projectV2")
3906 .filter(|v| !v.is_null())
3907 .cloned()
3908 .ok_or_else(|| SourceError::Refused {
3909 message: format!(
3910 "GitHub project {}/{} was not found or is not visible to the token",
3911 self.owner, self.project_number
3912 ),
3913 })
3914 }
3915
3916 /// The search that finds the issues of this board, narrowed by `also` when it is
3917 /// given.
3918 ///
3919 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3920 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3921 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3922 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3923 /// from a task by the `parent` field each issue carries rather than by the search.
3924 fn board_search(&self, also: Option<&str>) -> String {
3925 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3926 match also {
3927 Some(also) => format!("{scope} {also}"),
3928 None => scope,
3929 }
3930 }
3931
3932 /// One issue this source reached directly, as the board item a read of the board would
3933 /// have produced — or `None` when this board does not hold it.
3934 ///
3935 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3936 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3937 /// item's own id, that item's field values, and the issue as its content. One resolver
3938 /// for both routes is what makes an issue read through a search, through its own node
3939 /// id, or through its project's sub-issues report the same title, the same status, the
3940 /// same labels and the same qualified id.
3941 ///
3942 /// An issue with no entry for *this* board is not this source's to report, which is
3943 /// what keeps an id naming some other repository's issue from being answered as an item
3944 /// of this board. That answer is given about an **exhausted** connection and never
3945 /// about an unread page: the entry is looked for on the page in hand, and only if that
3946 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3947 /// rest of it.
3948 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3949 if optional_str(issue, "__typename")? != Some("Issue") {
3950 return Ok(None);
3951 }
3952 let memberships = issue
3953 .get("projectItems")
3954 .ok_or_else(|| SourceError::Malformed {
3955 message: "GitHub issue is missing projectItems".into(),
3956 })?;
3957 let nodes = memberships
3958 .get("nodes")
3959 .and_then(Value::as_array)
3960 .ok_or_else(|| SourceError::Malformed {
3961 message: "GitHub issue projectItems.nodes is not an array".into(),
3962 })?;
3963 let held = match self.board_entry(nodes) {
3964 Some(held) => held.clone(),
3965 None => {
3966 let info = memberships
3967 .get("pageInfo")
3968 .ok_or_else(|| SourceError::Malformed {
3969 message: "GitHub issue projectItems has no pageInfo".into(),
3970 })?;
3971 // The page held no entry for this board. Whether that means the issue is
3972 // not on it is a question about the rest of the connection, and only a
3973 // connection with no rest answers it here.
3974 if !required_bool(info, "hasNextPage")? {
3975 return Ok(None);
3976 }
3977 let cursor = required_str(info, "endCursor")?;
3978 validate_cursor_progress(None, cursor)?;
3979 let issue_id = required_str(issue, "id")?;
3980 match self.board_membership(issue_id, cursor).await? {
3981 Some(held) => held,
3982 None => return Ok(None),
3983 }
3984 }
3985 };
3986 let item = json!({
3987 "id": required_str(&held, "id")?,
3988 "project": held.get("project"),
3989 "fieldValues": held.get("fieldValues"),
3990 "content": issue,
3991 });
3992 self.resolve(&item)
3993 }
3994
3995 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3996 ///
3997 /// One spelling of *which membership is this board's*, so the page a read carries and
3998 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3999 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
4000 nodes.iter().find(|node| {
4001 node.pointer("/project/number").and_then(Value::as_u64)
4002 == Some(u64::from(self.project_number))
4003 })
4004 }
4005
4006 /// The rest of one issue's board memberships, from `after`, for this board's entry.
4007 ///
4008 /// The recovery read: a page of memberships that holds no entry for this board says
4009 /// nothing about the memberships past it, so the connection is walked to exhaustion
4010 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
4011 /// positive answer — the whole connection was read and no entry named this board —
4012 /// rather than a failure, and the walk is held to
4013 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
4014 /// with a cursor that does not advance is refused instead of spun on.
4015 async fn board_membership(
4016 &self,
4017 issue: &str,
4018 after: &str,
4019 ) -> Result<Option<Value>, SourceError> {
4020 let mut after = after.to_owned();
4021 loop {
4022 let data = self
4023 .graphql(
4024 graphql::ISSUE_BOARD_ITEMS,
4025 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
4026 "nestedFirst":NESTED_PAGE_SIZE}),
4027 )
4028 .await?;
4029 let Some(connection) = data
4030 .pointer("/node/projectItems")
4031 .filter(|value| !value.is_null())
4032 else {
4033 // The id resolved to nothing, or to something with no memberships to walk —
4034 // which is the same answer as a connection holding no entry for this board.
4035 return Ok(None);
4036 };
4037 let nodes = connection
4038 .get("nodes")
4039 .and_then(Value::as_array)
4040 .ok_or_else(|| SourceError::Malformed {
4041 message: "GitHub issue projectItems.nodes is not an array".into(),
4042 })?;
4043 if let Some(held) = self.board_entry(nodes) {
4044 return Ok(Some(held.clone()));
4045 }
4046 let info = connection
4047 .get("pageInfo")
4048 .ok_or_else(|| SourceError::Malformed {
4049 message: "GitHub issue projectItems has no pageInfo".into(),
4050 })?;
4051 let next = required_bool(info, "hasNextPage")?
4052 .then(|| required_str(info, "endCursor"))
4053 .transpose()?;
4054 match next {
4055 Some(next) => {
4056 validate_cursor_progress(Some(&after), next)?;
4057 after = next.to_owned();
4058 }
4059 None => return Ok(None),
4060 }
4061 }
4062 }
4063
4064 /// One page of a board-scoped issue search, and where the next page resumes.
4065 async fn search_page(
4066 &self,
4067 search: &str,
4068 first: u32,
4069 after: Option<&str>,
4070 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
4071 let data = self
4072 .graphql(
4073 graphql::SEARCH_ISSUES,
4074 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
4075 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
4076 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4077 )
4078 .await?;
4079 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4080 message: "GitHub search response has no search connection".into(),
4081 })?;
4082 let mut found = Vec::new();
4083 for node in connection
4084 .get("nodes")
4085 .and_then(Value::as_array)
4086 .ok_or_else(|| SourceError::Malformed {
4087 message: "GitHub search nodes is not an array".into(),
4088 })?
4089 {
4090 if let Some(resolved) = self.resolve_issue(node).await? {
4091 found.push(resolved);
4092 }
4093 }
4094 let info = connection
4095 .get("pageInfo")
4096 .ok_or_else(|| SourceError::Malformed {
4097 message: "GitHub search connection has no pageInfo".into(),
4098 })?;
4099 let next = required_bool(info, "hasNextPage")?
4100 .then(|| required_str(info, "endCursor"))
4101 .transpose()?
4102 .map(str::to_owned);
4103 if let Some(next) = &next {
4104 validate_cursor_progress(after, next)?;
4105 }
4106 Ok((found, next))
4107 }
4108
4109 /// Every issue this board holds, completed with what this run wrote.
4110 ///
4111 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4112 /// is an index and is eventually consistent, so an issue this run created seconds ago
4113 /// can be absent from it, and a project listed straight after being written would
4114 /// otherwise be missing from its own board. What is added back is only what this
4115 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4116 /// process.
4117 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4118 let found = self.searched_issues().await?;
4119 self.completed_with_written(found, |_| true)
4120 }
4121
4122 /// Every issue this board's own search reports, walked to exhaustion, read once per
4123 /// source.
4124 ///
4125 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4126 /// needs it too and the two would otherwise walk the same search twice in one command.
4127 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4128 /// is.
4129 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4130 let cached = self.search_cache()?.clone();
4131 if let Some(held) = cached {
4132 return Ok(held);
4133 }
4134 let mut after: Option<String> = None;
4135 let mut found = Vec::new();
4136 let search = self.board_search(None);
4137 loop {
4138 let (page, next) = self
4139 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4140 .await?;
4141 found.extend(page);
4142 match next {
4143 Some(next) => after = Some(next),
4144 None => break,
4145 }
4146 }
4147 *self.search_cache()? = Some(found.clone());
4148 Ok(found)
4149 }
4150
4151 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4152 fn search_cache(
4153 &self,
4154 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4155 self.search_cache
4156 .lock()
4157 .map_err(|_| SourceError::Unavailable {
4158 message: "this source's view of the board's issues was left inconsistent by an \
4159 earlier failure; next: run the command again"
4160 .into(),
4161 })
4162 }
4163
4164 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4165 /// report.
4166 ///
4167 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4168 /// at all: the search index is behind, and a node read of an item filed moments ago can
4169 /// be too.
4170 fn completed_with_written(
4171 &self,
4172 mut found: Vec<Resolved>,
4173 keep: impl Fn(&Resolved) -> bool,
4174 ) -> Result<Vec<Resolved>, SourceError> {
4175 for own in self.created()?.iter().filter(|own| keep(own)) {
4176 if !found.iter().any(|item| item.id == own.id) {
4177 found.push(own.clone());
4178 }
4179 }
4180 Ok(found)
4181 }
4182
4183 /// What resolving one node id reached.
4184 ///
4185 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4186 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4187 /// is completed by a read of the draft itself rather than reported as nothing.
4188 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4189 let asked = self
4190 .graphql(
4191 graphql::ISSUE,
4192 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4193 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4194 )
4195 .await;
4196 let data = match asked {
4197 Ok(data) => data,
4198 // A string that is not a node id at all is not a failure to report: it is an id
4199 // this board does not hold, which is what every read of one already answers.
4200 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4201 Err(error) => return Err(error),
4202 };
4203 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4204 return Ok(Reached::Nothing);
4205 };
4206 if optional_str(node, "__typename")? == Some("DraftIssue") {
4207 return Ok(Reached::Draft);
4208 }
4209 Ok(match self.resolve_issue(node).await? {
4210 Some(item) => Reached::Held(Box::new(item)),
4211 None => Reached::Nothing,
4212 })
4213 }
4214
4215 /// One item of this board by its own id, whatever kind it is.
4216 ///
4217 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4218 /// run wrote is read first, because a node read of an item created moments ago can
4219 /// still be behind the board field values written onto it — see [`Self::created`].
4220 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4221 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4222 return Ok(Some(own.clone()));
4223 }
4224 match self.reach(id).await? {
4225 Reached::Held(item) => Ok(Some(*item)),
4226 Reached::Nothing => Ok(None),
4227 Reached::Draft => self.draft_by_id(id).await,
4228 }
4229 }
4230
4231 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4232 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4233 /// than one request per id.
4234 ///
4235 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4236 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4237 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4238 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4239 /// is completed by a read of the draft, exactly as there.
4240 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4241 let mut found: Vec<Option<Option<Resolved>>> = {
4242 let created = self.created()?;
4243 ids.iter()
4244 .map(|id| {
4245 created
4246 .iter()
4247 .find(|own| own.id == *id)
4248 .map(|own| Some(own.clone()))
4249 })
4250 .collect()
4251 };
4252 let unread: Vec<NativeId> = ids
4253 .iter()
4254 .zip(&found)
4255 .filter(|(_, found)| found.is_none())
4256 .map(|(id, _)| id.clone())
4257 .collect();
4258 let mut read = Vec::with_capacity(unread.len());
4259 if let [one] = unread.as_slice() {
4260 read.push(self.item_by_id(one).await?);
4261 } else {
4262 for batch in unread.chunks(DETAIL_BATCH) {
4263 let data = match self
4264 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4265 .await
4266 {
4267 Ok(data) => data,
4268 Err(error) if unresolvable_node(&error) => {
4269 for id in batch {
4270 read.push(self.item_by_id(id).await?);
4271 }
4272 continue;
4273 }
4274 Err(error) => return Err(error),
4275 };
4276 for (slot, id) in batch.iter().enumerate() {
4277 let node =
4278 data.get(format!("i{slot}"))
4279 .ok_or_else(|| SourceError::Malformed {
4280 message: format!(
4281 "GitHub answered a batch read with no item for {}",
4282 id.0
4283 ),
4284 })?;
4285 read.push(if node.is_null() {
4286 None
4287 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4288 self.draft_by_id(id).await?
4289 } else {
4290 if optional_str(node, "__typename")? == Some("Issue")
4291 && required_str(node, "id")? != id.0
4292 {
4293 return Err(SourceError::Malformed {
4294 message: format!(
4295 "GitHub answered the read of {} with issue {}",
4296 id.0,
4297 required_str(node, "id")?
4298 ),
4299 });
4300 }
4301 self.resolve_issue(node).await?
4302 });
4303 }
4304 }
4305 }
4306 let mut read = read.into_iter();
4307 Ok(found
4308 .iter_mut()
4309 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4310 .collect())
4311 }
4312
4313 fn resolved_cache(
4314 &self,
4315 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4316 self.resolved_cache
4317 .lock()
4318 .map_err(|_| SourceError::Unavailable {
4319 message: "resolved item records were left inconsistent; run the command again"
4320 .into(),
4321 })
4322 }
4323
4324 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4325 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4326 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4327 let cached = self.resolved_cache()?.get(id).cloned();
4328 match cached {
4329 Some(item) => Ok(Some(item)),
4330 None => self.item_by_id(id).await,
4331 }
4332 }
4333
4334 /// One board draft by its own id, with the board item it sits in — or `None` when no
4335 /// item of this board is that draft's.
4336 ///
4337 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4338 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4339 /// links a draft to one board item, so the page this read carries is the whole of that
4340 /// connection, and a page that reports more than it holds is refused rather than read
4341 /// as an answer about memberships nobody read.
4342 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4343 let data = self
4344 .graphql(
4345 graphql::DRAFT,
4346 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4347 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4348 )
4349 .await?;
4350 // Gone between the two reads is an answer — the draft is no longer there. Anything
4351 // else than the draft [`Self::reach`] was just told this id is, is not one.
4352 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4353 return Ok(None);
4354 };
4355 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4356 return Err(SourceError::Malformed {
4357 message: format!(
4358 "GitHub answered {} as a draft and then as something else",
4359 id.0
4360 ),
4361 });
4362 }
4363 if required_str(draft, "id")? != id.0 {
4364 return Err(SourceError::Malformed {
4365 message: format!("GitHub answered a different draft for {}", id.0),
4366 });
4367 }
4368 let memberships = draft
4369 .get("projectV2Items")
4370 .ok_or_else(|| SourceError::Malformed {
4371 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4372 })?;
4373 let nodes = memberships
4374 .get("nodes")
4375 .and_then(Value::as_array)
4376 .ok_or_else(|| SourceError::Malformed {
4377 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4378 })?;
4379 let info = memberships
4380 .get("pageInfo")
4381 .ok_or_else(|| SourceError::Malformed {
4382 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4383 })?;
4384 // Read whether or not this board's entry is on the page: a page claiming more than
4385 // the one item GitHub links a draft to is a malformed answer either way.
4386 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4387 return Err(SourceError::Malformed {
4388 message: format!(
4389 "GitHub draft {} reports more board items than the one GitHub links a draft \
4390 to",
4391 id.0
4392 ),
4393 });
4394 }
4395 if let Some(node) = nodes.first()
4396 && node
4397 .pointer("/project/number")
4398 .and_then(Value::as_u64)
4399 .is_none()
4400 {
4401 return Err(SourceError::Malformed {
4402 message: format!(
4403 "GitHub draft {} board item has no numeric project number",
4404 id.0
4405 ),
4406 });
4407 }
4408 let Some(held) = self.board_entry(nodes) else {
4409 return Ok(None);
4410 };
4411 if required_str(
4412 held.get("project").ok_or_else(|| SourceError::Malformed {
4413 message: format!("GitHub draft {} board item has no project", id.0),
4414 })?,
4415 "id",
4416 )? != self.board_fields().await?.id.as_str()
4417 {
4418 return Ok(None);
4419 }
4420 let item = json!({
4421 "id": required_str(held, "id")?,
4422 "project": held.get("project"),
4423 "fieldValues": held.get("fieldValues"),
4424 "content": draft,
4425 });
4426 self.resolve(&item)
4427 }
4428
4429 /// The board's own id and field definitions, for a write whose item does not carry
4430 /// them — never its items.
4431 ///
4432 /// A board this command has already listed supplies them, since it read them beside its
4433 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4434 /// is consulted about which items the board holds: see the module documentation for
4435 /// why a question about one known item is answered by reading that item.
4436 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4437 if let Some(board) = self.board_cache()?.as_ref() {
4438 return Ok(BoardFields {
4439 id: BoardId::parse(&board.id)?,
4440 fields: board.fields.clone(),
4441 });
4442 }
4443 if let Some(held) = self.fields_cache()?.clone() {
4444 return Ok(held);
4445 }
4446 let data = self
4447 .graphql(
4448 graphql::BOARD_FIELDS,
4449 json!({"owner":self.owner,"number":self.project_number,
4450 "nestedFirst":NESTED_PAGE_SIZE}),
4451 )
4452 .await?;
4453 self.fields_read(&data)
4454 }
4455
4456 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4457 /// the rest of this command.
4458 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4459 let board = data
4460 .pointer("/boardFields/projectV2")
4461 .filter(|value| !value.is_null())
4462 .ok_or_else(|| SourceError::Refused {
4463 message: format!(
4464 "GitHub project {}/{} was not found or is not visible to the token",
4465 self.owner, self.project_number
4466 ),
4467 })?;
4468 let read = BoardFields {
4469 id: BoardId::parse(required_str(board, "id")?)?,
4470 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4471 };
4472 *self.fields_cache()? = Some(read.clone());
4473 Ok(read)
4474 }
4475
4476 /// Read what creating an issue in `repository` needs and this command has not read yet —
4477 /// the board's fields and the repository's node id — in one request when it needs both.
4478 ///
4479 /// When either is already known this sends nothing, and the other is read by its own
4480 /// document where it is asked for, so no create reads anything twice.
4481 async fn creation_context(
4482 &self,
4483 repository: &RepositoryTarget,
4484 incoming: &Incoming<'_>,
4485 ) -> Result<(), SourceError> {
4486 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4487 if fields_known || self.repository_cache()?.contains_key(repository) {
4488 return Ok(());
4489 }
4490 let data = self
4491 .graphql(
4492 graphql::CREATION_CONTEXT,
4493 json!({"owner":self.owner,"number":self.project_number,
4494 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4495 "repositoryName":repository.name}),
4496 )
4497 .await?;
4498 self.fields_read(&data)?;
4499 self.repository_read(&data, repository, incoming)?;
4500 Ok(())
4501 }
4502
4503 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4504 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4505 self.fields_cache
4506 .lock()
4507 .map_err(|_| SourceError::Unavailable {
4508 message: "this source's view of the board's fields was left inconsistent by an \
4509 earlier failure; next: run the command again"
4510 .into(),
4511 })
4512 }
4513
4514 /// What a write to `item` needs of the board, read off that item when it says enough and
4515 /// off [`Self::board_fields`] when it does not.
4516 ///
4517 /// A node read of an item names its board and carries the definition of every field it
4518 /// holds a value of — so an item naming its board, holding a value of the origin field,
4519 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4520 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4521 /// of may still be on the board, and a view reading it as absent would refuse a write the
4522 /// board can take or skip a field write the board needs, so such an item — and a create,
4523 /// which has no item yet — takes the board's fields from their own read instead.
4524 async fn fields_for(
4525 &self,
4526 item: Option<&Resolved>,
4527 writes_status: bool,
4528 selects_priority: bool,
4529 ) -> Result<BoardFields, SourceError> {
4530 if let Some(board) = item.and_then(Resolved::carried_board) {
4531 return Ok(board);
4532 }
4533 if let Some(item) = item
4534 && let Some(board_id) = item.named_board()
4535 && item.defines(ORIGIN_FIELD)
4536 && (!writes_status || item.defines("Status"))
4537 && (!selects_priority || item.defines(PRIORITY_FIELD))
4538 {
4539 return Ok(BoardFields {
4540 id: board_id,
4541 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4542 });
4543 }
4544 self.board_fields().await
4545 }
4546
4547 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4548 /// when that id names nothing here with a sub-issue relationship to walk.
4549 ///
4550 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4551 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4552 /// empty vector is a project that holds nothing.
4553 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4554 let mut after: Option<String> = None;
4555 let mut children = Vec::new();
4556 loop {
4557 let asked = self
4558 .graphql(
4559 graphql::SUB_ISSUES,
4560 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4561 "nestedFirst":NESTED_PAGE_SIZE,
4562 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4563 )
4564 .await;
4565 let data = match asked {
4566 Ok(data) => data,
4567 // A string that is not a node id at all is not a failure to report: it is
4568 // the ordinary answer to a selector naming a project by its name.
4569 Err(error) if unresolvable_node(&error) => return Ok(None),
4570 Err(error) => return Err(error),
4571 };
4572 let Some(connection) = data
4573 .pointer("/node/subIssues")
4574 .filter(|value| !value.is_null())
4575 else {
4576 // No such node, or one with no sub-issue relationship — a board draft is
4577 // the one this board can really hold.
4578 return Ok(None);
4579 };
4580 for node in connection
4581 .get("nodes")
4582 .and_then(Value::as_array)
4583 .ok_or_else(|| SourceError::Malformed {
4584 message: "GitHub subIssues.nodes is not an array".into(),
4585 })?
4586 {
4587 if let Some(resolved) = self.resolve_issue(node).await? {
4588 children.push(resolved);
4589 }
4590 }
4591 let info = connection
4592 .get("pageInfo")
4593 .ok_or_else(|| SourceError::Malformed {
4594 message: "GitHub subIssues connection has no pageInfo".into(),
4595 })?;
4596 let next = required_bool(info, "hasNextPage")?
4597 .then(|| required_str(info, "endCursor"))
4598 .transpose()?;
4599 match next {
4600 Some(next) => {
4601 validate_cursor_progress(after.as_deref(), next)?;
4602 after = Some(next.to_owned());
4603 }
4604 None => return Ok(Some(children)),
4605 }
4606 }
4607 }
4608
4609 /// Which issue of this board a project *name* is, or `None` when none is.
4610 ///
4611 /// One bounded query which filters on that name at the server, rather than a walk of
4612 /// every issue the board holds. The name is compared again here: the qualifier narrows
4613 /// what GitHub sends, and this source decides what it names.
4614 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4615 let search = self.board_search(Some(&title_qualifier(name)));
4616 let mut after = None;
4617 loop {
4618 let (candidates, next) = self
4619 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4620 .await?;
4621 if let Some(item) = candidates.into_iter().find(|item| {
4622 item.kind == BoardKind::Work(ItemKind::Project)
4623 && item.title.eq_ignore_ascii_case(name)
4624 }) {
4625 return Ok(Some(item.id));
4626 }
4627 match next {
4628 Some(next) => after = Some(next),
4629 None => return Ok(None),
4630 }
4631 }
4632 }
4633
4634 /// Everything filed under one project of this board: the sub-issues of the issue that
4635 /// project is.
4636 ///
4637 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4638 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4639 /// gains projects, or as another project gains tasks.
4640 ///
4641 /// A qualified id names the issue and is asked for its sub-issues directly: one
4642 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4643 /// read as a project *name*, which costs the one bounded search
4644 /// [`Self::project_by_name`] makes.
4645 ///
4646 /// What GitHub answered is held for the rest of the command — see [`Self::children_cache`]
4647 /// — and each answer is still cut to the items whose parent is this project, so one this
4648 /// process has since filed elsewhere is not reported here, and completed with what this
4649 /// process filed under it.
4650 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4651 let held = self.children_cache()?.get(selector).cloned();
4652 let (project, children) = match held {
4653 Some(held) => held,
4654 None => {
4655 let answered = match self.sub_issues(selector).await? {
4656 Some(children) => (selector.clone(), children),
4657 None => match self.project_by_name(&selector.0).await? {
4658 Some(project) => {
4659 let children = self.sub_issues(&project).await?.unwrap_or_default();
4660 (project, children)
4661 }
4662 None => return Ok(Vec::new()),
4663 },
4664 };
4665 self.children_cache()?
4666 .insert(selector.clone(), answered.clone());
4667 answered
4668 }
4669 };
4670 let mut children: Vec<Resolved> = children
4671 .into_iter()
4672 .filter(|child| child.parent.as_ref() == Some(&project))
4673 .collect();
4674 for own in self.updated()?.iter() {
4675 if own.parent.as_ref() == Some(&project) && !children.iter().any(|c| c.id == own.id) {
4676 children.push(own.clone());
4677 }
4678 }
4679 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4680 }
4681
4682 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4683 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4684 ///
4685 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4686 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4687 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4688 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4689 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4690 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4691 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4692 /// rather than silently narrowing a caller's answer.
4693 ///
4694 /// The instant is written to the second, rounded down, which can only widen what the
4695 /// search returns; confirmation against each candidate's own comments is what makes the
4696 /// answer exact. The search is an index that lags a write by a second or two — the module
4697 /// documentation records it — so a caller that asks again from its last instant should
4698 /// overlap the two by more than that.
4699 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4700 let found = self.searched(&updated_qualifier(since)).await?;
4701 self.completed_with_written(found, |_| true)
4702 }
4703
4704 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4705 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4706 ///
4707 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4708 /// own record is the fresher of the two.
4709 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4710 let search = self.board_search(Some(also));
4711 let mut after: Option<String> = None;
4712 let mut found = Vec::new();
4713 loop {
4714 let (page, next) = self
4715 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4716 .await?;
4717 found.extend(page);
4718 match next {
4719 Some(next) => after = Some(next),
4720 None => return Ok(found),
4721 }
4722 }
4723 }
4724
4725 /// A bounded task answer; the versioned cursor carries the connection position, how
4726 /// many rows of the page starting there were already handed out, and the own-write ids
4727 /// already observed, including across a new source instance.
4728 ///
4729 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4730 /// sliced from the pages it needs; why is the module documentation's paging contract.
4731 async fn search_tasks(
4732 &self,
4733 query: &TaskQuery,
4734 page: &PageRequest,
4735 also: &str,
4736 ) -> Result<Page<Task>, SourceError> {
4737 let mut position = match &page.cursor {
4738 None => SearchPosition::default(),
4739 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4740 .ok()
4741 .filter(|position| {
4742 position.version == SEARCH_CURSOR_VERSION
4743 && position.connection.valid_resume(position.offset)
4744 })
4745 .ok_or_else(|| SourceError::Config {
4746 message: "page cursor is invalid".into(),
4747 })?,
4748 };
4749 let search = self.board_search(Some(also));
4750 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4751 let own = self.with_own_writes(Vec::new())?;
4752 // An issue this process commented on is a candidate of a comment-activity read
4753 // whether or not the search has caught up with the comment; see `Self::commented`.
4754 let commented = match query.commented_since {
4755 Some(_) => self.commented()?.clone(),
4756 None => Vec::new(),
4757 };
4758 for id in own.iter().map(|item| &item.id).chain(&commented) {
4759 if !position.own.contains(id) {
4760 position.own.push(id.clone());
4761 }
4762 }
4763 let mut tasks = Vec::new();
4764 while !position.connection.exhausted() && tasks.len() < limit {
4765 let first = SEARCH_PAGE_SIZE;
4766 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4767 let key =
4768 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4769 .expect("search page key is serializable");
4770 let cached = if query.commented_since.is_none() {
4771 self.narrowed_cache()?.get(&key).cloned()
4772 } else {
4773 None
4774 };
4775 let (found, next) = match cached {
4776 Some(found) => {
4777 let next = self
4778 .search_next
4779 .lock()
4780 .map_err(|_| SourceError::Unavailable {
4781 message:
4782 "search pagination was left inconsistent; run the command again"
4783 .into(),
4784 })?
4785 .get(&key)
4786 .cloned()
4787 .flatten();
4788 (found, next)
4789 }
4790 None => {
4791 let (found, next) = self
4792 .search_page(&search, first, position.connection.after())
4793 .await?;
4794 if query.commented_since.is_none() {
4795 self.search_next
4796 .lock()
4797 .map_err(|_| SourceError::Unavailable {
4798 message:
4799 "search pagination was left inconsistent; run the command again"
4800 .into(),
4801 })?
4802 .insert(key.clone(), next.clone());
4803 self.narrowed_cache()?.insert(key, found.clone());
4804 }
4805 (found, next)
4806 }
4807 };
4808 let rows = found.len();
4809 for mut item in found.into_iter().skip(position.offset) {
4810 if tasks.len() == limit {
4811 break;
4812 }
4813 position.offset += 1;
4814 if position.own.contains(&item.id) {
4815 if position.seen.contains(&item.id) {
4816 continue;
4817 }
4818 position.seen.push(item.id.clone());
4819 // The search's own copy of an issue this process only commented on is as
4820 // good as a node read of it, since its comments are read either way.
4821 let only_commented = commented.contains(&item.id)
4822 && !own.iter().any(|written| written.id == item.id);
4823 if !only_commented {
4824 let updated_at = item.updated_at;
4825 let Some(written) = self.search_written(&own, &item.id).await? else {
4826 continue;
4827 };
4828 item = written;
4829 item.updated_at = item.updated_at.max(updated_at);
4830 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4831 }
4832 }
4833 if item.kind == BoardKind::Work(ItemKind::Task) {
4834 let task = item.task()?;
4835 if task_matches(&task, query, &query.project)
4836 && self.commented_since(&item, query.commented_since).await?
4837 {
4838 tasks.push(task);
4839 }
4840 }
4841 }
4842 if position.offset < rows {
4843 continue;
4844 }
4845 position.offset = 0;
4846 position.connection = match next {
4847 Some(after) => SearchConnection::Continuing {
4848 after: Cursor(after),
4849 },
4850 None => SearchConnection::Exhausted {},
4851 };
4852 }
4853 if position.connection.exhausted() {
4854 for id in position.own.clone() {
4855 if position.seen.contains(&id) {
4856 continue;
4857 }
4858 if tasks.len() == limit {
4859 break;
4860 }
4861 position.seen.push(id.clone());
4862 let Some(item) = self.search_written(&own, &id).await? else {
4863 continue;
4864 };
4865 if item.kind == BoardKind::Work(ItemKind::Task) {
4866 let task = item.task()?;
4867 if task_matches(&task, query, &query.project)
4868 && self.commented_since(&item, query.commented_since).await?
4869 {
4870 tasks.push(task);
4871 }
4872 }
4873 }
4874 }
4875 let more = !position.connection.exhausted()
4876 || position.own.iter().any(|id| !position.seen.contains(id));
4877 Ok(Page {
4878 items: tasks,
4879 next: more.then(|| {
4880 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4881 }),
4882 })
4883 }
4884
4885 /// A resumed process has the ids but no write records; resolve only a record the
4886 /// current page needs, by its uncached node read rather than the lagging search index.
4887 async fn search_written(
4888 &self,
4889 own: &[Resolved],
4890 id: &NativeId,
4891 ) -> Result<Option<Resolved>, SourceError> {
4892 match own.iter().find(|item| item.id == *id) {
4893 Some(item) => Ok(Some(item.clone())),
4894 None => self.item_by_id(id).await,
4895 }
4896 }
4897
4898 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4899 /// without enumerating the board — or `None` for a query carrying none of the three, which
4900 /// keeps the reads it always had.
4901 ///
4902 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4903 /// because it names at most a handful of items. Text and metadata are answered by one
4904 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4905 /// further by `updated:>=` when the query also asks for comment activity, since both
4906 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4907 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4908 ///
4909 /// Completed with what this process wrote, its own record winning over the index's copy
4910 /// of the same item: see [`Self::with_own_writes`].
4911 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4912 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4913 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4914 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4915 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4916 None => qualifiers,
4917 }),
4918 (None, None) => return Ok(None),
4919 };
4920 // A question about comment activity is asked afresh every time, as it always was: it
4921 // is the one a caller polls from one source while waiting for the index, and an
4922 // answer held from the first poll would be the answer to every later one.
4923 let key = query.commented_since.is_none().then(|| asked.key());
4924 let cached = match &key {
4925 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4926 None => None,
4927 };
4928 let found = match cached {
4929 Some(found) => found,
4930 None => {
4931 let found = match &asked {
4932 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4933 Narrowing::Search(also) => self.searched(also).await?,
4934 };
4935 if let Some(key) = key {
4936 self.narrowed_cache()?.insert(key, found.clone());
4937 }
4938 found
4939 }
4940 };
4941 self.with_own_writes(found).map(Some)
4942 }
4943
4944 /// The candidates for a project or unscoped document query carrying a searchable text,
4945 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4946 /// which keeps the read it always had.
4947 ///
4948 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4949 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4950 /// costs is the issues that match and never the board. Its answer is held for the command
4951 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4952 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4953 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4954 /// process wrote: see [`Self::with_own_writes`].
4955 async fn text_searched(
4956 &self,
4957 text: Option<&TextQuery>,
4958 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4959 let Some(also) = text_qualifiers(text) else {
4960 return Ok(None);
4961 };
4962 let key = Narrowing::Search(also.clone()).key();
4963 let cached = self.narrowed_cache()?.get(&key).cloned();
4964 let found = match cached {
4965 Some(found) => found,
4966 None => {
4967 let found = self.searched(&also).await?;
4968 self.narrowed_cache()?.insert(key, found.clone());
4969 found
4970 }
4971 };
4972 self.with_own_writes(found).map(Some)
4973 }
4974
4975 /// Every item of this board that may carry `origin` — a superset of those that do — found
4976 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4977 ///
4978 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4979 /// which reads the field every carrier holds, whichever release wrote it — and the
4980 /// board-scoped issue search for the same id as a phrase in the body, where this source
4981 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4982 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4983 /// query's, exactly.
4984 ///
4985 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4986 /// already ended is sent its last cursor again, which answers an empty page, so the one
4987 /// document serves every page of either. What the two leave is stated in the module
4988 /// documentation: a carrier another process added within the last second or two, before
4989 /// either index has it.
4990 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4991 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4992 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4993 let mut items_after: Option<String> = None;
4994 let mut search_after: Option<String> = None;
4995 let mut found: Vec<Resolved> = Vec::new();
4996 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4997 if !found.iter().any(|held| held.id == resolved.id) {
4998 found.push(resolved);
4999 }
5000 };
5001 loop {
5002 let data = self
5003 .graphql(
5004 graphql::ORIGIN_LOOKUP,
5005 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
5006 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
5007 "itemsAfter":items_after,"searchAfter":search_after,
5008 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
5009 "duplicates":true}),
5010 )
5011 .await?;
5012 let items = data
5013 .pointer("/originItems/projectV2/items")
5014 .filter(|value| !value.is_null())
5015 .ok_or_else(|| SourceError::Refused {
5016 message: format!(
5017 "GitHub project {}/{} was not found or is not visible to the token",
5018 self.owner, self.project_number
5019 ),
5020 })?;
5021 for item in optional_nodes(Some(items), "project items")?
5022 .into_iter()
5023 .flatten()
5024 {
5025 // The board's own items list its drafts too, and a draft is not an issue: no
5026 // narrowed read answers with one, whatever its origin field holds.
5027 if let Some(resolved) = self.resolve(item)?
5028 && resolved.content_kind == ContentKind::Issue
5029 {
5030 keep(resolved, &mut found);
5031 }
5032 }
5033 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
5034 message: "GitHub search response has no search connection".into(),
5035 })?;
5036 for node in optional_nodes(Some(searched), "search")?
5037 .into_iter()
5038 .flatten()
5039 {
5040 if let Some(resolved) = self.resolve_issue(node).await? {
5041 keep(resolved, &mut found);
5042 }
5043 }
5044 let items_next = resumed(items, items_after.as_deref())?;
5045 let search_next = resumed(searched, search_after.as_deref())?;
5046 if !items_next.has_more() && !search_next.has_more() {
5047 return Ok(found);
5048 }
5049 items_after = items_next.cursor();
5050 search_after = search_next.cursor();
5051 }
5052 }
5053
5054 /// `found`, with every item this process created or wrote in its place, and every one of
5055 /// them the read did not report added.
5056 ///
5057 /// This process's own record wins over the read's copy of the same item, because a read
5058 /// of an item written moments ago can still be behind what was written onto it — the
5059 /// origin field included, which is the one a narrowed read is confirmed against — and a
5060 /// read that still names an item under a predicate this process's write moved it out of
5061 /// must not return it. The one thing the read knows that the record cannot is when GitHub
5062 /// last saw the item change, which is what a comment-activity read rules a candidate out
5063 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
5064 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
5065 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
5066 // A board draft is not an issue, so no narrowed read returns one, and this process
5067 // having written one does not make it an answer either.
5068 let own: Vec<Resolved> = self
5069 .created()?
5070 .iter()
5071 .chain(self.updated()?.iter())
5072 .filter(|own| own.content_kind == ContentKind::Issue)
5073 .cloned()
5074 .collect();
5075 for mut own in own {
5076 self.resolved_cache()?.insert(own.id.clone(), own.clone());
5077 match found.iter_mut().find(|read| read.id == own.id) {
5078 Some(read) => {
5079 own.updated_at = own.updated_at.max(read.updated_at);
5080 *read = own;
5081 }
5082 None => found.push(own),
5083 }
5084 }
5085 Ok(found)
5086 }
5087
5088 /// Whether `item` has a comment created or last edited at or after `since` — always, when
5089 /// there is no instant to hold it to.
5090 ///
5091 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
5092 /// or after the instant moved it there: an issue not updated since holds no such comment,
5093 /// and its comments are never asked for — unless this process commented on it in this
5094 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
5095 /// only as far as the first that matches. A board draft is not an issue and has no
5096 /// comments, so it never matches.
5097 async fn commented_since(
5098 &self,
5099 item: &Resolved,
5100 since: Option<DateTime<Utc>>,
5101 ) -> Result<bool, SourceError> {
5102 let Some(since) = since else {
5103 return Ok(true);
5104 };
5105 if item.content_kind == ContentKind::DraftIssue {
5106 return Ok(false);
5107 }
5108 // An `updatedAt` this process's own record or a lagging index holds can predate a
5109 // comment this process wrote since, so only an issue it did not comment on is ruled
5110 // out by one.
5111 if item.updated_at.is_some_and(|updated| updated < since)
5112 && !self.commented()?.contains(&item.id)
5113 {
5114 return Ok(false);
5115 }
5116 let query = TaskQuery {
5117 commented_since: Some(since),
5118 ..TaskQuery::default()
5119 };
5120 let mut after: Option<String> = None;
5121 loop {
5122 let data = self
5123 .graphql(
5124 graphql::ISSUE_COMMENTS,
5125 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5126 )
5127 .await?;
5128 let Some(connection) = data
5129 .get("node")
5130 .filter(|value| !value.is_null())
5131 .and_then(|node| node.get("comments"))
5132 .filter(|value| !value.is_null())
5133 else {
5134 // Removed since the search reported it: no longer an issue with comments.
5135 return Ok(false);
5136 };
5137 let comments = optional_nodes(Some(connection), "issue comments")?
5138 .into_iter()
5139 .flatten()
5140 .map(comment_from)
5141 .collect::<Result<Vec<_>, _>>()?;
5142 if query.comments_match(&comments) {
5143 return Ok(true);
5144 }
5145 match next_cursor(connection)? {
5146 Some(next) => {
5147 validate_cursor_progress(after.as_deref(), &next.0)?;
5148 after = Some(next.0);
5149 }
5150 None => return Ok(false),
5151 }
5152 }
5153 }
5154
5155 /// Every item on the board: the union of both enumerations GitHub offers of one.
5156 ///
5157 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5158 /// board **draft** and reads the board's own fields beside its items, and only the search
5159 /// reports an item that connection is behind on. The module documentation is where the lag and the
5160 /// measurements behind it are written down.
5161 ///
5162 /// A search result is admitted on the same terms as any other issue this source reaches
5163 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5164 /// names *this* board — so an issue the index still believes is here after it was taken
5165 /// off is refused rather than reported.
5166 ///
5167 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5168 /// which is what the cache could otherwise have broken.
5169 async fn board(&self) -> Result<Board, SourceError> {
5170 let cached = self.board_cache()?.clone();
5171 let mut board = match cached {
5172 Some(board) => board,
5173 None => {
5174 let read = self.read_board().await?;
5175 *self.board_cache()? = Some(read.clone());
5176 read
5177 }
5178 };
5179 for held in self.searched_issues().await? {
5180 if !board.items.iter().any(|item| item.id == held.id) {
5181 board.items.push(held);
5182 }
5183 }
5184 for own in self.created()?.iter() {
5185 if !board.items.iter().any(|item| item.id == own.id) {
5186 board.items.push(own.clone());
5187 }
5188 }
5189 Ok(board)
5190 }
5191
5192 /// This process's own view of the board, or the refusal a poisoned lock is.
5193 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5194 self.board_cache
5195 .lock()
5196 .map_err(|_| SourceError::Unavailable {
5197 message: "this source's view of the board was left inconsistent by an earlier \
5198 failure; next: run the command again"
5199 .into(),
5200 })
5201 }
5202
5203 /// Bring this process's own view of the board up to an item it has just written.
5204 ///
5205 /// A created item goes to `created`, which is what completes a board read GitHub's own
5206 /// eventual consistency has left behind. An item that was already there is replaced
5207 /// where it sits, so a second write of it in the same command reads its real parent
5208 /// rather than the one it had before the first write.
5209 ///
5210 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5211 /// that wins: an item this same run created is held in `created` and not in the cached
5212 /// board, and `board` completes the cached board *from* `created`, so replacing only
5213 /// the cached copy of such an item replaces nothing and the read still reports the
5214 /// title it was created with. The search is the third, and it is the one an item the
5215 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5216 /// source is least able to re-read, so leaving it out would put the stale title back on
5217 /// the only items the completion in [`Self::board`] exists for.
5218 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5219 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5220 if created {
5221 self.created()?.push(item);
5222 return Ok(());
5223 }
5224 {
5225 let mut own = self.created()?;
5226 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5227 *held = item;
5228 return Ok(());
5229 }
5230 }
5231 {
5232 let mut own = self.updated()?;
5233 match own.iter_mut().find(|held| held.id == item.id) {
5234 Some(held) => *held = item.clone(),
5235 None => own.push(item.clone()),
5236 }
5237 }
5238 if let Some(board) = self.board_cache()?.as_mut()
5239 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5240 {
5241 *held = item.clone();
5242 }
5243 if let Some(found) = self.search_cache()?.as_mut()
5244 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5245 {
5246 *held = item.clone();
5247 }
5248 for found in self.narrowed_cache()?.values_mut() {
5249 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5250 *held = item.clone();
5251 }
5252 }
5253 for (_, found) in self.children_cache()?.values_mut() {
5254 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5255 *held = item.clone();
5256 }
5257 }
5258 Ok(())
5259 }
5260
5261 /// Forget one item this process has just deleted, from every half of its own view.
5262 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5263 self.resolved_cache()?.remove(id);
5264 self.created()?.retain(|own| own.id != *id);
5265 self.updated()?.retain(|own| own.id != *id);
5266 self.commented()?.retain(|own| own != id);
5267 if let Some(board) = self.board_cache()?.as_mut() {
5268 board.items.retain(|item| item.id != *id);
5269 }
5270 if let Some(found) = self.search_cache()?.as_mut() {
5271 found.retain(|item| item.id != *id);
5272 }
5273 for found in self.narrowed_cache()?.values_mut() {
5274 found.retain(|item| item.id != *id);
5275 }
5276 for (_, found) in self.children_cache()?.values_mut() {
5277 found.retain(|item| item.id != *id);
5278 }
5279 Ok(())
5280 }
5281
5282 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5283 fn narrowed_cache(
5284 &self,
5285 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5286 self.narrowed_cache
5287 .lock()
5288 .map_err(|_| SourceError::Unavailable {
5289 message: "this source's view of a narrowed read was left inconsistent by an \
5290 earlier failure; next: run the command again"
5291 .into(),
5292 })
5293 }
5294
5295 /// This process's own record of each project's sub-issues, or the refusal a poisoned lock
5296 /// is.
5297 fn children_cache(&self) -> Result<std::sync::MutexGuard<'_, ProjectChildren>, SourceError> {
5298 self.children_cache
5299 .lock()
5300 .map_err(|_| SourceError::Unavailable {
5301 message: "this source's view of a project's tasks was left inconsistent by an \
5302 earlier failure; next: run the command again"
5303 .into(),
5304 })
5305 }
5306
5307 /// Every page of the board, read from GitHub.
5308 async fn read_board(&self) -> Result<Board, SourceError> {
5309 let mut after: Option<String> = None;
5310 let mut items = Vec::new();
5311 let mut board;
5312 loop {
5313 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5314 for item in page
5315 .pointer("/items/nodes")
5316 .and_then(Value::as_array)
5317 .ok_or_else(|| SourceError::Malformed {
5318 message: "GitHub project items.nodes is not an array".into(),
5319 })?
5320 {
5321 if let Some(resolved) = self.resolve(item)? {
5322 items.push(resolved);
5323 }
5324 }
5325 let info = page
5326 .pointer("/items/pageInfo")
5327 .ok_or_else(|| SourceError::Malformed {
5328 message: "GitHub project items have no pageInfo".into(),
5329 })?;
5330 let has_next = required_bool(info, "hasNextPage")?;
5331 let next = has_next
5332 .then(|| required_str(info, "endCursor"))
5333 .transpose()?;
5334 board = page.clone();
5335 match next {
5336 Some(next) => {
5337 validate_cursor_progress(after.as_deref(), next)?;
5338 after = Some(next.to_owned());
5339 }
5340 None => break,
5341 }
5342 }
5343 Ok(Board {
5344 id: required_str(&board, "id")?.to_owned(),
5345 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5346 items,
5347 })
5348 }
5349
5350 /// The existing items this source has written, for completing a narrowed read that is
5351 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5352 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5353 self.updated.lock().map_err(|_| SourceError::Unavailable {
5354 message: "this source's record of what it wrote in this run was left inconsistent \
5355 by an earlier failure; next: run the command again"
5356 .into(),
5357 })
5358 }
5359
5360 /// The issues this source has commented on in this command; see
5361 /// [`Self::commented`](GitHubProjectsSource::commented).
5362 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
5363 self.commented.lock().map_err(|_| SourceError::Unavailable {
5364 message: "this source's record of what it commented on in this run was left \
5365 inconsistent by an earlier failure; next: run the command again"
5366 .into(),
5367 })
5368 }
5369
5370 /// Called only once GitHub has answered the comment write, so an issue whose comment
5371 /// failed is never made a candidate a later read would pay a node read for.
5372 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
5373 let mut commented = self.commented()?;
5374 if !commented.contains(issue) {
5375 commented.push(issue.clone());
5376 }
5377 Ok(())
5378 }
5379
5380 /// The items this source has created, for completing a board read that is behind.
5381 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5382 self.created.lock().map_err(|_| SourceError::Unavailable {
5383 message: "this source's record of what it created in this run was left \
5384 inconsistent by an earlier failure; next: run the command again"
5385 .into(),
5386 })
5387 }
5388
5389 /// One board item as this source reports it, or `None` for content it ignores.
5390 ///
5391 /// A pull request is neither a project nor a task — it is somebody's change, not a
5392 /// unit of plan — and an item whose content the token cannot see has nothing to
5393 /// report at all.
5394 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5395 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5396 message: "GitHub project item is missing content".into(),
5397 })?;
5398 if content.is_null() {
5399 return Ok(None);
5400 }
5401 let content_kind = match required_str(content, "__typename")? {
5402 "Issue" => ContentKind::Issue,
5403 "DraftIssue" => ContentKind::DraftIssue,
5404 _ => return Ok(None),
5405 };
5406 let field_values = item
5407 .get("fieldValues")
5408 .ok_or_else(|| SourceError::Malformed {
5409 message: "GitHub project item is missing fieldValues".into(),
5410 })?;
5411 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5412 let nodes = field_values
5413 .get("nodes")
5414 .and_then(Value::as_array)
5415 .ok_or_else(|| SourceError::Malformed {
5416 message: "GitHub project item fieldValues.nodes is not an array".into(),
5417 })?;
5418 if let Some(labels) = content.get("labels") {
5419 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5420 }
5421 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5422 let (body, slot) = metadata_body(raw_body.clone())?;
5423 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5424 .map(|id| NativeId(id.to_owned()));
5425 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5426 // to read one from; it is a task, and never a project.
5427 let sub_issues = match content_kind {
5428 ContentKind::Issue => sub_issue_total(content)?,
5429 ContentKind::DraftIssue => 0,
5430 };
5431 let content_id = required_str(content, "id")?;
5432 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5433 message: format!("GitHub issue {content_id}: {message}"),
5434 })?;
5435 let raw_title = required_str(content, "title")?;
5436 // The design prefix is read *first*, before either of the two rules that separate
5437 // a project from a task. A document is not work whatever sub-issues it has and
5438 // whatever marker it carries, and reading the prefix later would make a design
5439 // issue with none of either an empty project.
5440 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5441 BoardKind::Document
5442 } else if parent.is_some() {
5443 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5444 // under a project is that project's task even when it has sub-issues of its
5445 // own.
5446 BoardKind::Work(ItemKind::Task)
5447 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5448 BoardKind::Work(ItemKind::Project)
5449 } else {
5450 BoardKind::Work(ItemKind::Task)
5451 };
5452 // The title a person wrote, which for a document is the one without the prefix —
5453 // the same way `content` above is the body without this source's metadata slot.
5454 let title = match kind {
5455 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5456 BoardKind::Work(_) => raw_title.to_owned(),
5457 };
5458 let own_repository = content
5459 .pointer("/repository/nameWithOwner")
5460 .and_then(Value::as_str)
5461 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5462 .transpose()
5463 .map_err(|message| SourceError::Malformed { message })?;
5464 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5465 Repository::from_metadata(&slot)
5466 .map_err(|message| SourceError::Malformed { message })?
5467 } else {
5468 own_repository.clone().into_iter().collect()
5469 };
5470 let classification = Classification::from_metadata(&slot)
5471 .map_err(|message| SourceError::Malformed { message })?;
5472 let id = NativeId(content_id.to_owned());
5473 // Read only for a task, because only a task has either list: a project or a
5474 // document holding one of these keys holds nothing this source reports, and the
5475 // keys are left out of its caller-visible metadata all the same.
5476 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5477 let listed = |key: &str| {
5478 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5479 .map_err(|message| SourceError::Malformed { message })
5480 };
5481 (
5482 listed(TaskRef::DELIVERS_KEY)?,
5483 listed(TaskRef::DELIVERED_BY_KEY)?,
5484 )
5485 } else {
5486 (Vec::new(), Vec::new())
5487 };
5488 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5489 let priority = self.held_priority(nodes)?;
5490 // Present when the item was reached through its own issue, whose board entry
5491 // names the board; a read of the board's own items has the board already. An
5492 // empty id names nothing a field write could address, so it is read as absent and
5493 // the write goes back to reading the board.
5494 let board_id = item
5495 .pointer("/project/id")
5496 .and_then(Value::as_str)
5497 .filter(|id| !id.is_empty());
5498 let resolved = Resolved {
5499 item_id: required_str(item, "id")?.to_owned(),
5500 id,
5501 content_kind,
5502 kind,
5503 title,
5504 body: body.filter(|value| !value.is_empty()),
5505 raw_body,
5506 status: self
5507 .statuses
5508 .status(kind.status_kind(), option, closed, reason),
5509 option: option.map(str::to_owned),
5510 priority,
5511 closed,
5512 delivers,
5513 delivered_by,
5514 labels: labels(content)?,
5515 parent,
5516 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5517 number: match content_kind {
5518 ContentKind::Issue => Some(issue_number(content)?),
5519 // A draft is filed in no repository, so nothing ever numbered it:
5520 // `DraftIssue` declares no `number` at all, exactly as it declares no
5521 // `subIssuesSummary` the branch above reads.
5522 ContentKind::DraftIssue => None,
5523 },
5524 url: optional_str(content, "url")?.map(str::to_owned),
5525 created_at: optional_time(content, "createdAt")?,
5526 updated_at: optional_time(content, "updatedAt")?,
5527 own_repository,
5528 repositories,
5529 classification,
5530 slot,
5531 board_id: board_id.map(str::to_owned),
5532 fields: field_definitions(nodes),
5533 board_fields: Self::carried_board_fields(content, board_id)?,
5534 blocked_by: carried_blocked_by(content)?,
5535 };
5536 self.resolved_cache()?
5537 .insert(resolved.id.clone(), resolved.clone());
5538 Ok(Some(resolved))
5539 }
5540
5541 /// The field definitions of the board `board_id` names — the project this issue's own
5542 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5543 /// `None` when the read carried none, carried no entry for that board, or the board item
5544 /// named no board, which a write then answers by reading the board's fields itself.
5545 ///
5546 /// Matched by the board's node id and never by its number alone: a project number is
5547 /// unique only within its owner, so another owner's board numbered alike can sit on the
5548 /// same page, and its field and option ids address nothing on this one.
5549 fn carried_board_fields(
5550 content: &Value,
5551 board_id: Option<&str>,
5552 ) -> Result<Option<Value>, SourceError> {
5553 let (Some(nodes), Some(board_id)) = (
5554 content.pointer("/boards/nodes").and_then(Value::as_array),
5555 board_id,
5556 ) else {
5557 return Ok(None);
5558 };
5559 let Some(board) = nodes.iter().find_map(|node| {
5560 let project = node.get("project")?;
5561 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5562 }) else {
5563 return Ok(None);
5564 };
5565 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5566 return Ok(None);
5567 };
5568 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5569 Ok(Some(fields.clone()))
5570 }
5571
5572 /// What one board item's `Priority` field says, through this instance's mapping.
5573 ///
5574 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5575 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5576 /// option the mapping does not name is kept as itself — never read as a level or as
5577 /// `none` — for a read of the task to report by name.
5578 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5579 let Some(mapping) = &self.priorities else {
5580 return Ok(HeldPriority::Read(Priority::None));
5581 };
5582 // A value of the field that names no option — a text field someone called `Priority` —
5583 // is malformed rather than `none`: reading it as no priority would let the next copy
5584 // clear one a person set.
5585 let Some(option) = field_values
5586 .iter()
5587 .find(|value| {
5588 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5589 })
5590 .map(|value| required_str(value, "name"))
5591 .transpose()?
5592 else {
5593 return Ok(HeldPriority::Read(Priority::None));
5594 };
5595 Ok(mapping.priority_of(option).map_or_else(
5596 || HeldPriority::Unmapped(option.to_owned()),
5597 HeldPriority::Read,
5598 ))
5599 }
5600
5601 /// What one board item's status is read from: its `Status` option, whether its issue
5602 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5603 /// three into the status it reports.
5604 fn status_parts<'a>(
5605 field_values: &'a [Value],
5606 content: &'a Value,
5607 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5608 let option = field_values
5609 .iter()
5610 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5611 .map(|value| required_str(value, "name"))
5612 .transpose()?;
5613 let closed = optional_str(content, "state")? == Some("CLOSED");
5614 Ok((option, closed, optional_str(content, "stateReason")?))
5615 }
5616
5617 /// The board Status option this write selects, or the refusal that says why not.
5618 ///
5619 /// The mapped option is required for both open and terminal targets. A terminal write
5620 /// validates it before changing either representation, so it can never fall back to
5621 /// closing an issue whose board cannot display the matching status.
5622 ///
5623 /// Answers the field's id, the option's id, and the option's name as the board spells
5624 /// it — which is the name a read of the item reports once it sits there.
5625 fn column_for(
5626 &self,
5627 fields: &Value,
5628 kind: ItemKind,
5629 category: StatusCategory,
5630 target: &StatusTarget,
5631 ) -> Result<Option<(String, String, String)>, SourceError> {
5632 let Some(wanted) = target.option() else {
5633 return Ok(None);
5634 };
5635 let missing = |detail: &str| SourceError::Refused {
5636 message: format!(
5637 "{} status {} of source {} needs the board Status option {wanted:?}, and \
5638 {detail}; next: add that option to the board, which `onetaskgraph sources \
5639 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5640 it has",
5641 kind.marker(),
5642 category_name(category),
5643 self.name,
5644 self.name,
5645 category_name(category),
5646 kind.marker()
5647 ),
5648 };
5649 let Some(field) = Board::field(fields, "Status")? else {
5650 return Err(missing("this board has no Status field"));
5651 };
5652 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5653 return Err(missing(
5654 "this board's Status field is not a single-select field",
5655 ));
5656 }
5657 let option = field
5658 .get("options")
5659 .and_then(Value::as_array)
5660 .and_then(|options| {
5661 options.iter().find(|option| {
5662 option
5663 .get("name")
5664 .and_then(Value::as_str)
5665 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5666 })
5667 });
5668 match option {
5669 None => Err(missing("this board does not have it")),
5670 Some(option) => Ok(Some((
5671 required_str(field, "id")?.to_owned(),
5672 required_str(option, "id")?.to_owned(),
5673 required_str(option, "name")?.to_owned(),
5674 ))),
5675 }
5676 }
5677
5678 /// The refusal a status that closes an issue is answered with over a board draft.
5679 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5680 SourceError::Refused {
5681 message: format!(
5682 "status {} of source {} closes the item's issue, and GitHub draft items have \
5683 no open or closed state",
5684 category_name(category),
5685 self.name
5686 ),
5687 }
5688 }
5689
5690 /// What a status write to one item needs of the board: the board's id and the
5691 /// definition of its `Status` field, read off the item when the item says both.
5692 ///
5693 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5694 /// and its `Status` value carries that field's definition, options and all. An item that
5695 /// does not say — no board id, or no `Status` value to read the field off — takes them
5696 /// from [`Self::board_fields`], which reads no item.
5697 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5698 if let Some(board) = item.carried_board() {
5699 return Ok(board);
5700 }
5701 if item.defines("Status")
5702 && let Some(board_id) = item.named_board()
5703 {
5704 return Ok(BoardFields {
5705 id: board_id,
5706 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5707 });
5708 }
5709 self.board_fields().await
5710 }
5711
5712 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5713 async fn set_status(
5714 &self,
5715 id: &NativeId,
5716 category: StatusCategory,
5717 ) -> Result<Option<Status>, SourceError> {
5718 // Refused before anything is read, in the words a write of the same status is.
5719 let target = self.resolved_target(ItemKind::Task, category)?;
5720 let Some(mut item) = self
5721 .bound_item(id)
5722 .await?
5723 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5724 else {
5725 return Ok(None);
5726 };
5727 let board = self.status_board(&item).await?;
5728 let (field, option, name) = self
5729 .column_for(&board.fields, ItemKind::Task, category, &target)?
5730 .ok_or_else(|| SourceError::Malformed {
5731 message: format!(
5732 "status {} of source {} names no board Status option",
5733 category_name(category),
5734 self.name
5735 ),
5736 })?;
5737 if item.status.category == category && item.option.as_deref() == Some(&name) {
5738 return Ok(Some(item.status));
5739 }
5740 match &target {
5741 StatusTarget::Terminal(_, reason) => {
5742 if item.content_kind == ContentKind::DraftIssue {
5743 return Err(self.closes_a_draft(category));
5744 }
5745 self.set_item_field(
5746 board.id.as_str(),
5747 &item.item_id,
5748 &field,
5749 json!({"singleSelectOptionId": option}),
5750 )
5751 .await?;
5752 self.update_content(
5753 ContentKind::Issue,
5754 &item.id,
5755 json!({"stateInput": state_input(Some(&target))}),
5756 )
5757 .await?;
5758 item.closed = true;
5759 item.status =
5760 self.statuses
5761 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5762 item.option = Some(name);
5763 }
5764 StatusTarget::Column(_) => {
5765 // An option is what an open item's status is, so a closed issue is reopened
5766 // first — sitting closed in the column, it would read back as closed. A draft has
5767 // no state to reopen.
5768 if item.content_kind == ContentKind::Issue && item.closed {
5769 self.update_content(
5770 ContentKind::Issue,
5771 &item.id,
5772 json!({"stateInput": state_input(Some(&target))}),
5773 )
5774 .await?;
5775 item.closed = false;
5776 }
5777 self.set_item_field(
5778 board.id.as_str(),
5779 &item.item_id,
5780 &field,
5781 json!({"singleSelectOptionId": option}),
5782 )
5783 .await?;
5784 item.status = self
5785 .statuses
5786 .status(ItemKind::Task, Some(&name), false, None);
5787 item.option = Some(name);
5788 }
5789 StatusTarget::Disabled(_) => {
5790 unreachable!("resolved_target refused a disabled status")
5791 }
5792 }
5793 let status = item.status.clone();
5794 self.remember_written(item, false)?;
5795 Ok(Some(status))
5796 }
5797
5798 /// Replace one task's `delivered_by` and nothing else; see
5799 /// [`TaskSource::set_delivered_by`].
5800 ///
5801 /// One update of the body, which differs from the body GitHub holds only inside the
5802 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5803 async fn replace_delivered_by(
5804 &self,
5805 id: &NativeId,
5806 delivered_by: &[TaskRef],
5807 ) -> Result<Option<()>, SourceError> {
5808 let entries = TaskRef::listed(
5809 TaskRef::DELIVERED_BY_KEY,
5810 id,
5811 Some(&self.name),
5812 delivered_by.to_vec(),
5813 )
5814 .map_err(|message| SourceError::Refused { message })?;
5815 let Some(mut item) = self
5816 .bound_item(id)
5817 .await?
5818 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5819 else {
5820 return Ok(None);
5821 };
5822 let mut slot = item.slot.clone();
5823 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5824 self.write_slot(&mut item, &slot).await?;
5825 item.delivered_by = entries;
5826 self.remember_written(item, false)?;
5827 Ok(Some(()))
5828 }
5829
5830 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5831 /// see [`TaskSource::set_task_metadata`].
5832 ///
5833 /// `None` when this board holds no item by that id, or holds one of another kind. The
5834 /// answer is the item as this source now reads it, so what a caller is told the key
5835 /// holds is what the slot holds.
5836 ///
5837 /// A key already holding the value is answered without a write, compared as JSON rather
5838 /// than as the body's bytes: a slot a person spelled with other whitespace would
5839 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5840 async fn set_slot_key(
5841 &self,
5842 id: &NativeId,
5843 kind: BoardKind,
5844 key: &MetadataKey,
5845 value: &Value,
5846 ) -> Result<Option<Resolved>, SourceError> {
5847 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5848 return Ok(None);
5849 };
5850 if item.slot.get(key.as_str()) == Some(value) {
5851 return Ok(Some(item));
5852 }
5853 let mut slot = item.slot.clone();
5854 slot.insert(key.as_str().to_owned(), value.clone());
5855 self.write_slot(&mut item, &slot).await?;
5856 self.remember_written(item.clone(), false)?;
5857 Ok(Some(item))
5858 }
5859
5860 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5861 /// `item` up to what that write left.
5862 ///
5863 /// The body sent differs from the body GitHub holds only inside the slot — see
5864 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5865 /// the mutation the item's content takes, so a board draft's body is written with
5866 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5867 async fn write_slot(
5868 &self,
5869 item: &mut Resolved,
5870 slot: &BTreeMap<String, Value>,
5871 ) -> Result<(), SourceError> {
5872 let held = item.raw_body.clone().unwrap_or_default();
5873 let body = with_slot(&held, slot)?;
5874 if body != held {
5875 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5876 .await?;
5877 }
5878 let (visible, slot) = metadata_body(Some(body.clone()))?;
5879 item.body = visible.filter(|value| !value.is_empty());
5880 item.raw_body = Some(body);
5881 item.slot = slot;
5882 Ok(())
5883 }
5884
5885 /// This instance's target for a category written to an item of `kind`, refusing one
5886 /// that kind has no option for — before anything is read or written.
5887 ///
5888 /// Nothing here mutates the board's option set to make room for a status. GitHub
5889 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5890 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5891 /// field and every item's status.
5892 fn resolved_target(
5893 &self,
5894 kind: ItemKind,
5895 category: StatusCategory,
5896 ) -> Result<StatusTarget, SourceError> {
5897 let target = self.statuses.target(kind, category).clone();
5898 let StatusTarget::Disabled(why) = target else {
5899 return Ok(target);
5900 };
5901 let refusal = why.refusal(&self.name, category, kind);
5902 // Why there is no shipped default, which is the question a person meeting this
5903 // refusal on a source that never mentioned the category asks.
5904 let shipped_none = match category {
5905 StatusCategory::Draft => Some(
5906 "draft has no shipped default because GitHub draft issues cannot have \
5907 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5908 ),
5909 StatusCategory::Unknown => Some(
5910 "unknown has no shipped default because this board keeps no open-ended status \
5911 word: every word classified unknown is written to the one board Status option \
5912 status_mapping.unknown names",
5913 ),
5914 _ => None,
5915 };
5916 Err(match (refusal, shipped_none, why) {
5917 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5918 SourceError::Refused {
5919 message: format!("{message}; {note}"),
5920 }
5921 }
5922 (refusal, _, _) => refusal,
5923 })
5924 }
5925
5926 /// What writing `priority` does to one item's `Priority` field on this board, or the
5927 /// refusal naming what the board lacks.
5928 ///
5929 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5930 /// none already, or of an item not created yet. Every other priority selects the option
5931 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5932 /// without that option, is refused rather than given one: reads and writes never create
5933 /// a field or an option.
5934 fn priority_write(
5935 &self,
5936 fields: &Value,
5937 existing: Option<&Resolved>,
5938 priority: Priority,
5939 ) -> Result<Option<PriorityWrite>, SourceError> {
5940 let Some(mapping) = &self.priorities else {
5941 return Err(self.holds_no_priority());
5942 };
5943 let Some(wanted) = mapping.option(priority) else {
5944 if !existing.is_some_and(Resolved::holds_priority) {
5945 return Ok(None);
5946 }
5947 let field =
5948 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5949 message: format!(
5950 "an item holding a {PRIORITY_FIELD} value was read without that field"
5951 ),
5952 })?;
5953 return Ok(Some(PriorityWrite::Clear {
5954 field: required_str(field, "id")?.to_owned(),
5955 }));
5956 };
5957 let missing = |detail: &str| SourceError::Refused {
5958 message: format!(
5959 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5960 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5961 it, or point priority_mapping.{priority} of this source at an option the board \
5962 has",
5963 self.name, self.name
5964 ),
5965 };
5966 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5967 return Err(missing(&format!(
5968 "this board has no {PRIORITY_FIELD} field"
5969 )));
5970 };
5971 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5972 return Err(missing(&format!(
5973 "this board's {PRIORITY_FIELD} field is not a single-select field"
5974 )));
5975 }
5976 // An options list that is absent or not a list is an answer this source cannot read,
5977 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5978 let option = field
5979 .get("options")
5980 .and_then(Value::as_array)
5981 .ok_or_else(|| SourceError::Malformed {
5982 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5983 })?
5984 .iter()
5985 .find(|option| {
5986 option
5987 .get("name")
5988 .and_then(Value::as_str)
5989 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5990 })
5991 .ok_or_else(|| missing("this board does not have it"))?;
5992 Ok(Some(PriorityWrite::Select {
5993 field: required_str(field, "id")?.to_owned(),
5994 option: required_str(option, "id")?.to_owned(),
5995 }))
5996 }
5997
5998 /// Apply one priority write to one board item.
5999 async fn write_priority(
6000 &self,
6001 board_id: &str,
6002 item_id: &str,
6003 write: &PriorityWrite,
6004 ) -> Result<(), SourceError> {
6005 match write {
6006 PriorityWrite::Select { field, option } => {
6007 self.set_item_field(
6008 board_id,
6009 item_id,
6010 field,
6011 json!({"singleSelectOptionId": option}),
6012 )
6013 .await
6014 }
6015 PriorityWrite::Clear { field } => {
6016 let data = self
6017 .graphql(
6018 graphql::CLEAR_FIELD,
6019 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
6020 "readPriority":false,"priorityName":PRIORITY_FIELD}),
6021 )
6022 .await?;
6023 let returned = data
6024 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
6025 .ok_or_else(|| SourceError::Malformed {
6026 message: "GitHub field clear returned no project item".into(),
6027 })?;
6028 if required_str(returned, "id")? != item_id {
6029 return Err(SourceError::Malformed {
6030 message: "GitHub field clear returned the wrong project item".into(),
6031 });
6032 }
6033 Ok(())
6034 }
6035 }
6036 }
6037
6038 /// The refusal a priority is answered with by an instance configured with no
6039 /// `priority_mapping`, which holds none.
6040 fn holds_no_priority(&self) -> SourceError {
6041 SourceError::Refused {
6042 message: format!(
6043 "source {} holds no task priority: its configuration sets no priority_mapping; \
6044 next: set priority_mapping on this source, then run `onetaskgraph sources \
6045 fields {} --apply` to set its board up",
6046 self.name, self.name
6047 ),
6048 }
6049 }
6050
6051 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
6052 ///
6053 /// One field write — a select, or a clear for `none` — and no title, body, label, state
6054 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
6055 async fn set_priority(
6056 &self,
6057 id: &NativeId,
6058 priority: Priority,
6059 ) -> Result<Option<Priority>, SourceError> {
6060 if self.priorities.is_none() {
6061 return Err(self.holds_no_priority());
6062 }
6063 let Some(mut item) = self
6064 .bound_item(id)
6065 .await?
6066 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6067 else {
6068 return Ok(None);
6069 };
6070 if priority == Priority::None && !item.holds_priority() {
6071 return Ok(Some(priority));
6072 }
6073 // The item's own read carries the field's definition whenever it holds a value of
6074 // it, which a clear always does; a select onto an item holding none reads the board.
6075 let board = match (item.carried_board(), item.named_board()) {
6076 (Some(board), _) => board,
6077 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
6078 id,
6079 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
6080 },
6081 _ => self.board_fields().await?,
6082 };
6083 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
6084 return Ok(Some(priority));
6085 };
6086 let (document, root, input) = match write {
6087 PriorityWrite::Select { field, option } => (
6088 graphql::UPDATE_FIELD,
6089 "updateProjectV2ItemFieldValue",
6090 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
6091 ),
6092 PriorityWrite::Clear { field } => (
6093 graphql::CLEAR_FIELD,
6094 "clearProjectV2ItemFieldValue",
6095 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
6096 ),
6097 };
6098 let data = self
6099 .graphql(
6100 document,
6101 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
6102 )
6103 .await?;
6104 let returned = data
6105 .get(root)
6106 .and_then(|value| value.get("projectV2Item"))
6107 .ok_or_else(|| SourceError::Malformed {
6108 message: "GitHub priority write returned no project item".into(),
6109 })?;
6110 if required_str(returned, "id")? != item.item_id {
6111 return Err(SourceError::Malformed {
6112 message: "GitHub priority write returned the wrong project item".into(),
6113 });
6114 }
6115 let value = returned
6116 .get("fieldValueByName")
6117 .ok_or_else(|| SourceError::Malformed {
6118 message: "GitHub priority write returned no priority read-back".into(),
6119 })?;
6120 if !value.is_null()
6121 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
6122 {
6123 return Err(SourceError::Malformed {
6124 message: "GitHub priority read-back is not a Priority field value".into(),
6125 });
6126 }
6127 let values = if value.is_null() {
6128 Vec::new()
6129 } else {
6130 vec![value.clone()]
6131 };
6132 item.priority = self.held_priority(&values)?;
6133 let answer = item.task()?.priority;
6134 self.remember_written(item, false)?;
6135 Ok(Some(answer))
6136 }
6137
6138 /// Replace one task's visible body and nothing else; see
6139 /// [`TaskSource::set_task_content`].
6140 ///
6141 /// One update of the body, which differs from the body GitHub holds only outside the
6142 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
6143 /// this source keeps there reads back as it was. A body that would not change is not
6144 /// sent at all.
6145 async fn replace_content(
6146 &self,
6147 id: &NativeId,
6148 content: &str,
6149 ) -> Result<Option<()>, SourceError> {
6150 let Some(mut item) = self
6151 .bound_item(id)
6152 .await?
6153 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6154 else {
6155 return Ok(None);
6156 };
6157 let held = item.raw_body.clone().unwrap_or_default();
6158 let body = with_content(&held, content)?;
6159 // Checked before anything is sent: content ending in what this source reads as its own
6160 // metadata slot would read back as metadata rather than as the content it was.
6161 let (visible, slot) = metadata_body(Some(body.clone()))?;
6162 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
6163 return Err(SourceError::Refused {
6164 message: format!(
6165 "this content ends in what source {} reads as its own metadata slot \
6166 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6167 as content; next: remove that trailing block from the content",
6168 self.name
6169 ),
6170 });
6171 }
6172 if body != held {
6173 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6174 .await?;
6175 }
6176 item.body = visible.filter(|value| !value.is_empty());
6177 item.raw_body = Some(body);
6178 item.slot = slot;
6179 self.remember_written(item, false)?;
6180 Ok(Some(()))
6181 }
6182
6183 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
6184 ///
6185 /// One read of the item — which carries the board's field definitions and the issue's
6186 /// `blockedBy`, so neither is read again — and then only what differs from it: the
6187 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
6188 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
6189 /// the title, the body — visible content and metadata slot together — and a state change.
6190 /// So an update naming any of title, body, metadata, status and priority is one read and
6191 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
6192 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6193 /// as a whole write does; an open one selects its option and then reopens. The origin
6194 /// field is never written: an update is of an item that already exists, whose origin is
6195 /// what it is.
6196 ///
6197 /// The task answered is the item as those writes left it, built from the read and what was
6198 /// sent rather than read again — the same record a later read in this run answers from.
6199 async fn targeted_update(
6200 &self,
6201 id: &NativeId,
6202 update: &TaskUpdate,
6203 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6204 // Everything this source can refuse without reading the item is refused first, in the
6205 // words a whole write of the same fields is refused with.
6206 update.consistent()?;
6207 if update
6208 .title
6209 .as_deref()
6210 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6211 {
6212 return Err(SourceError::Refused {
6213 message: format!(
6214 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6215 spells a document, so it would read back as one rather than as a task; \
6216 retitle it",
6217 self.name
6218 ),
6219 });
6220 }
6221 if let Some(delivers) = &update.delivers {
6222 TaskRef::listed(
6223 TaskRef::DELIVERS_KEY,
6224 id,
6225 Some(&self.name),
6226 delivers.clone(),
6227 )
6228 .map_err(|message| SourceError::Refused { message })?;
6229 }
6230 if self.priorities.is_none()
6231 && update
6232 .priority
6233 .is_some_and(|priority| priority != Priority::None)
6234 {
6235 return Err(self.holds_no_priority());
6236 }
6237 let target = update
6238 .status
6239 .as_ref()
6240 .map(|status| self.resolved_target(ItemKind::Task, status.category))
6241 .transpose()?;
6242 let Some(mut item) = self
6243 .bound_item(id)
6244 .await?
6245 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6246 else {
6247 return Ok(None);
6248 };
6249 let before = item.task()?;
6250
6251 let mut status_move = None;
6252 if let (Some(status), Some(target)) = (&update.status, target) {
6253 let board = self.status_board(&item).await?;
6254 let (field, option, name) = self
6255 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6256 .ok_or_else(|| SourceError::Malformed {
6257 message: format!(
6258 "status {} of source {} names no board Status option",
6259 category_name(status.category),
6260 self.name
6261 ),
6262 })?;
6263 let terminal = matches!(target, StatusTarget::Terminal(_, _));
6264 if terminal && item.content_kind == ContentKind::DraftIssue {
6265 return Err(self.closes_a_draft(status.category));
6266 }
6267 let landed = match &target {
6268 StatusTarget::Terminal(_, reason) => {
6269 self.statuses
6270 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6271 }
6272 _ => self
6273 .statuses
6274 .status(ItemKind::Task, Some(&name), false, None),
6275 };
6276 let option_moves = item
6277 .option
6278 .as_deref()
6279 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6280 let state_moves = item.content_kind == ContentKind::Issue
6281 && (item.closed != terminal || (terminal && item.status != landed));
6282 if let Some(moves) = Moves::of(option_moves, state_moves) {
6283 status_move = Some(StatusMove {
6284 board: board.id,
6285 field,
6286 option,
6287 name,
6288 target,
6289 landed,
6290 moves,
6291 });
6292 }
6293 }
6294
6295 let mut priority_move = None;
6296 if let Some(priority) = update.priority
6297 && self.priorities.is_some()
6298 && item.priority != HeldPriority::Read(priority)
6299 {
6300 let board = match (item.carried_board(), item.named_board()) {
6301 (Some(board), _) => board,
6302 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6303 id: board,
6304 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6305 },
6306 _ => self.board_fields().await?,
6307 };
6308 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6309 priority_move = Some((board.id, write, priority));
6310 }
6311 }
6312
6313 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6314 // recorded in the slot, and the slot travels in the one body update below.
6315 let edges = match &update.depends_on {
6316 Some(edges) => Some(
6317 self.partition_edges(
6318 BoardKind::Work(ItemKind::Task),
6319 item.content_kind,
6320 item.blocked_by.as_deref(),
6321 edges,
6322 )
6323 .await?,
6324 ),
6325 None => None,
6326 };
6327
6328 let mut slot = item.slot.clone();
6329 for (key, value) in &update.metadata_set {
6330 slot.insert(key.as_str().to_owned(), value.clone());
6331 }
6332 for key in &update.metadata_remove {
6333 slot.remove(key.as_str());
6334 }
6335 if let Some(delivers) = &update.delivers {
6336 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6337 }
6338 if let Some((_, recorded)) = &edges {
6339 record_edges(&mut slot, recorded);
6340 }
6341 let held = item.raw_body.clone().unwrap_or_default();
6342 let content = match &update.content {
6343 Some(content) => with_content(&held, content)?,
6344 None => held.clone(),
6345 };
6346 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6347 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6348 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6349 let body = if slot == item.slot {
6350 content
6351 } else {
6352 with_slot(&content, &slot)?
6353 };
6354 // Checked before anything is sent, as a content write checks it: content ending in
6355 // what this source reads as its own slot would read back as metadata.
6356 let (visible, read) = metadata_body(Some(body.clone()))?;
6357 let wanted = update.content.as_deref().or(item.body.as_deref());
6358 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6359 return Err(SourceError::Refused {
6360 message: format!(
6361 "this content ends in what source {} reads as its own metadata slot \
6362 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6363 as content; next: remove that trailing block from the content",
6364 self.name
6365 ),
6366 });
6367 }
6368 let recorded_moves =
6369 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6370
6371 // One `updateIssue` carries all three, because every mutation spends the secondary
6372 // limiter and the title, body and state are one mutation's inputs.
6373 let mut fields = serde_json::Map::new();
6374 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6375 fields.insert("title".to_owned(), json!(title));
6376 }
6377 if body != held {
6378 fields.insert("body".to_owned(), json!(body));
6379 }
6380 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6381 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6382 }
6383 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6384 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6385 // without undoing an earlier field when a later one fails — so a body written before a
6386 // board field the board then refused would be left changed. Written after every other
6387 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6388 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6389 // first, together in one request — a terminal option selected before the issue
6390 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6391 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6392 let mut clear: Option<(&BoardId, &str)> = None;
6393 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6394 board_writes.push((
6395 &moving.board,
6396 (
6397 moving.field.clone(),
6398 json!({"singleSelectOptionId": moving.option}),
6399 ),
6400 ));
6401 }
6402 match &priority_move {
6403 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6404 board,
6405 (field.clone(), json!({"singleSelectOptionId": option})),
6406 )),
6407 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6408 None => {}
6409 }
6410 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6411 boards.extend(clear.map(|(board, _)| board));
6412 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6413 for board in boards {
6414 let writes = board_writes
6415 .iter()
6416 .filter(|(on, _)| on.as_str() == board.as_str())
6417 .map(|(_, write)| write.clone())
6418 .collect::<Vec<_>>();
6419 let cleared = clear
6420 .filter(|(on, _)| on.as_str() == board.as_str())
6421 .map(|(_, field)| field);
6422 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6423 .await?;
6424 }
6425 let mut blocked_by_moved = false;
6426 if let Some((native, _)) = &edges
6427 && item.content_kind == ContentKind::Issue
6428 {
6429 blocked_by_moved = self
6430 .reconcile_blocked_by(
6431 &item.id,
6432 native,
6433 Issue::Existing(item.blocked_by.as_deref()),
6434 )
6435 .await?;
6436 }
6437 if !fields.is_empty() {
6438 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6439 .await?;
6440 }
6441
6442 if let Some(title) = &update.title {
6443 item.title.clone_from(title);
6444 }
6445 item.body = visible.filter(|value| !value.is_empty());
6446 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6447 item.slot = slot;
6448 if let Some(delivers) = &update.delivers {
6449 item.delivers.clone_from(delivers);
6450 }
6451 if let Some(moving) = status_move {
6452 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6453 && item.content_kind == ContentKind::Issue;
6454 item.status = moving.landed;
6455 item.option = Some(moving.name);
6456 }
6457 if let Some((_, _, priority)) = priority_move {
6458 item.priority = HeldPriority::Read(priority);
6459 }
6460 let task = item.task()?;
6461 let mut written = update.changed(&before, &task);
6462 if blocked_by_moved || recorded_moves {
6463 written.insert(UpdatedField::DependsOn);
6464 }
6465 self.remember_written(item, false)?;
6466 Ok(Some(TaskUpdateOutcome {
6467 task,
6468 written,
6469 delivers_before: before.delivers,
6470 }))
6471 }
6472
6473 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6474 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6475 ///
6476 /// One update of the body: the content outside the slot, and inside it that one entry,
6477 /// every other entry kept as it was. This source keeps no template answers — an issue has
6478 /// no room beside itself that is not its body, and answers written there would duplicate
6479 /// what the content already says and count against GitHub's body limit — so `answers`
6480 /// reaches nothing here. A body that would not change is not sent at all.
6481 async fn replace_rendering(
6482 &self,
6483 id: &NativeId,
6484 kind: BoardKind,
6485 content: &str,
6486 provenance: &Value,
6487 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
6488 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
6489 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6490 return Ok(None);
6491 };
6492 let held = item.raw_body.clone().unwrap_or_default();
6493 let mut slot = item.slot.clone();
6494 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6495 let rewritten;
6496 let content = if let Some(assets) = assets {
6497 let uploads = self
6498 .upload_assets(item.own_repository.as_ref(), assets)
6499 .await?;
6500 rewritten =
6501 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
6502 rewritten.as_str()
6503 } else {
6504 content
6505 };
6506 let body = with_slot(&with_content(&held, content)?, &slot)?;
6507 // Checked before anything is sent, as a content write checks it.
6508 let (visible, read) = metadata_body(Some(body.clone()))?;
6509 if visible.as_deref().unwrap_or_default() != content || read != slot {
6510 return Err(SourceError::Refused {
6511 message: format!(
6512 "this content ends in what source {} reads as its own metadata slot \
6513 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6514 as content; next: remove that trailing block from the template",
6515 self.name
6516 ),
6517 });
6518 }
6519 if body != held {
6520 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6521 .await?;
6522 }
6523 item.body = visible.filter(|value| !value.is_empty());
6524 item.raw_body = Some(body);
6525 item.slot = read;
6526 self.remember_written(item, false)?;
6527 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
6528 id: id.clone(),
6529 content: Some(content.to_owned()),
6530 }))
6531 }
6532
6533 async fn set_item_field(
6534 &self,
6535 board_id: &str,
6536 item_id: &str,
6537 field_id: &str,
6538 value: Value,
6539 ) -> Result<(), SourceError> {
6540 let data = self
6541 .graphql(
6542 graphql::UPDATE_FIELD,
6543 json!({"input":{
6544 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6545 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6546 )
6547 .await?;
6548 let returned = data
6549 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6550 .ok_or_else(|| SourceError::Malformed {
6551 message: "GitHub field update returned no project item".into(),
6552 })?;
6553 if required_str(returned, "id")? != item_id {
6554 return Err(SourceError::Malformed {
6555 message: "GitHub field update returned the wrong project item".into(),
6556 });
6557 }
6558 Ok(())
6559 }
6560
6561 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6562 /// one request. Every returned item id is checked, including optional aliases.
6563 async fn set_item_fields(
6564 &self,
6565 board: &str,
6566 item: &str,
6567 fields: &[(String, Value)],
6568 clear: Option<&str>,
6569 ) -> Result<(), SourceError> {
6570 if fields.len() <= 1 && clear.is_none() {
6571 if let Some((field, value)) = fields.first() {
6572 self.set_item_field(board, item, field, value.clone())
6573 .await?;
6574 }
6575 return Ok(());
6576 }
6577 if fields.is_empty() {
6578 if let Some(field) = clear {
6579 self.write_priority(
6580 board,
6581 item,
6582 &PriorityWrite::Clear {
6583 field: field.to_owned(),
6584 },
6585 )
6586 .await?;
6587 }
6588 return Ok(());
6589 }
6590 let input = |index: usize| {
6591 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6592 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6593 };
6594 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6595 "input":input(0),"second":input(1),"third":input(2),
6596 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6597 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6598 })).await?;
6599 for alias in [
6600 Some("updateProjectV2ItemFieldValue"),
6601 (fields.len() > 1).then_some("second"),
6602 (fields.len() > 2).then_some("third"),
6603 clear.map(|_| "cleared"),
6604 ]
6605 .into_iter()
6606 .flatten()
6607 {
6608 let returned = data
6609 .get(alias)
6610 .and_then(|value| value.get("projectV2Item"))
6611 .ok_or_else(|| SourceError::Malformed {
6612 message: format!("GitHub field update {alias} returned no project item"),
6613 })?;
6614 if required_str(returned, "id")? != item {
6615 return Err(SourceError::Malformed {
6616 message: format!("GitHub field update {alias} returned the wrong project item"),
6617 });
6618 }
6619 }
6620 Ok(())
6621 }
6622
6623 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6624 let mut after: Option<String> = None;
6625 let mut ids = Vec::new();
6626 loop {
6627 let data = self
6628 .graphql(
6629 graphql::ISSUE_DEPENDENCIES,
6630 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6631 )
6632 .await?;
6633 let connection =
6634 data.pointer("/node/blockedBy")
6635 .ok_or_else(|| SourceError::Malformed {
6636 message: "GitHub dependency response has no blockedBy connection".into(),
6637 })?;
6638 ids.extend(
6639 connection
6640 .get("nodes")
6641 .and_then(Value::as_array)
6642 .ok_or_else(|| SourceError::Malformed {
6643 message: "GitHub dependency response nodes is not an array".into(),
6644 })?
6645 .iter()
6646 .map(|value| required_str(value, "id").map(str::to_owned))
6647 .collect::<Result<Vec<_>, _>>()?,
6648 );
6649 let next = next_cursor(connection)?;
6650 if let Some(next) = &next {
6651 validate_cursor_progress(after.as_deref(), &next.0)?;
6652 }
6653 after = next.map(|cursor| cursor.0);
6654 if after.is_none() {
6655 return Ok(ids);
6656 }
6657 }
6658 }
6659
6660 async fn dependencies(
6661 &self,
6662 id: &NativeId,
6663 near_kind: ItemKind,
6664 direction: Direction,
6665 page: &PageRequest,
6666 ) -> Result<Page<DependencyEdge>, SourceError> {
6667 validate_page(page)?;
6668 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6669 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6670 let recorded = recorded_offset(cursor, direction)?;
6671 // What this issue is blocked by, when a read of it by its own id in this command
6672 // already carried the whole connection — a copy reads the item it writes before it
6673 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6674 // after it. Answered from that read, in the shape the dependency read answers in;
6675 // anything else is asked of GitHub.
6676 let carried = match direction {
6677 Direction::DependsOn => self
6678 .resolved_cache()?
6679 .get(id)
6680 .filter(|item| item.content_kind == ContentKind::Issue)
6681 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6682 Direction::DependedOnBy => None,
6683 }
6684 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6685 // Asked for even in the recorded phase, whose page reads nothing from the
6686 // connection: `__typename` is what says whether this item has a native
6687 // relationship at all, and that is what decides which far ends the reserved key is
6688 // allowed to hold.
6689 let data = match carried {
6690 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6691 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6692 None => {
6693 self.graphql(
6694 graphql::ISSUE_DEPENDENCIES,
6695 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6696 "after":if recorded.is_some() {None} else {cursor}}),
6697 )
6698 .await?
6699 }
6700 };
6701 let node =
6702 data.get("node")
6703 .filter(|v| !v.is_null())
6704 .ok_or_else(|| SourceError::Refused {
6705 message: format!(
6706 "GitHub item {} was not found or does not support dependencies",
6707 id.0
6708 ),
6709 })?;
6710 let connection_name = match direction {
6711 Direction::DependsOn => "blockedBy",
6712 Direction::DependedOnBy => "blocking",
6713 };
6714 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6715 // named natively and the reserved key may hold any far end. An issue's connections
6716 // hold issues, and this source reads them at the near item's own level.
6717 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6718 if let Some(offset) = recorded {
6719 return Ok(recorded_page(
6720 self.recorded_edges(id, near_kind, direction, natively_names, node)
6721 .await?,
6722 offset,
6723 limit,
6724 ));
6725 }
6726 if natively_names.is_none() {
6727 return Ok(recorded_page(
6728 self.recorded_edges(id, near_kind, direction, natively_names, node)
6729 .await?,
6730 0,
6731 limit,
6732 ));
6733 }
6734 let connection = node
6735 .get(connection_name)
6736 .ok_or_else(|| SourceError::Malformed {
6737 message: "GitHub dependency response is missing its connection".into(),
6738 })?;
6739 let nodes = connection
6740 .get("nodes")
6741 .and_then(Value::as_array)
6742 .ok_or_else(|| SourceError::Malformed {
6743 message: "GitHub dependency response nodes is not an array".into(),
6744 })?;
6745 // `from` depends on `to`, always. GitHub spells the same relationship from either
6746 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6747 // it — so the near item is `from` in one direction and `to` in the other.
6748 let items = nodes
6749 .iter()
6750 .map(|value| {
6751 let related = NativeId(required_str(value, "id")?.into());
6752 let related_kind = related_kind(value)?;
6753 let (from, to) = match direction {
6754 Direction::DependsOn => (
6755 DependencyEndpoint::from_native(id.clone(), near_kind),
6756 DependencyEndpoint::from_native(related, related_kind),
6757 ),
6758 Direction::DependedOnBy => (
6759 DependencyEndpoint::from_native(related, related_kind),
6760 DependencyEndpoint::from_native(id.clone(), near_kind),
6761 ),
6762 };
6763 Ok(DependencyEdge {
6764 from,
6765 to,
6766 kind: DependencyKind::Blocks,
6767 })
6768 })
6769 .collect::<Result<Vec<_>, SourceError>>()?;
6770 let mut next = next_cursor(connection)?;
6771 if let Some(next) = &next {
6772 validate_cursor_progress(cursor, &next.0)?;
6773 }
6774 if next.is_none()
6775 && !self
6776 .recorded_edges(id, near_kind, direction, natively_names, node)
6777 .await?
6778 .is_empty()
6779 {
6780 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6781 }
6782 Ok(Page { items, next })
6783 }
6784
6785 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6786 /// a far end in another source has to live: no GitHub issue relationship can name one.
6787 ///
6788 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6789 /// source never writes one down.
6790 ///
6791 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6792 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6793 /// request beyond the read already made, and reading the board for them would be a
6794 /// walk of every item for one field of one. A draft has no body in that answer, because
6795 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6796 /// listing of the board, which can be behind on the very item asked about.
6797 async fn recorded_edges(
6798 &self,
6799 id: &NativeId,
6800 near_kind: ItemKind,
6801 direction: Direction,
6802 natively_names: Option<ItemKind>,
6803 node: &Value,
6804 ) -> Result<Vec<DependencyEdge>, SourceError> {
6805 if direction != Direction::DependsOn {
6806 return Ok(Vec::new());
6807 }
6808 let slot = match node.get("body") {
6809 Some(body) if natively_names.is_some() => {
6810 metadata_body(body.as_str().map(str::to_owned))?.1
6811 }
6812 _ => {
6813 let Some(item) = self.bound_item(id).await? else {
6814 return Ok(Vec::new());
6815 };
6816 item.slot
6817 }
6818 };
6819 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6820 .map_err(|message| SourceError::Malformed { message })
6821 }
6822
6823 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6824 self.repository
6825 .as_ref()
6826 .ok_or_else(|| SourceError::Refused {
6827 message: format!(
6828 "source {} has no repository configured, and a GitHub Projects board has no \
6829 repository of its own to create an issue in; set repository: owner/name on \
6830 this source",
6831 self.name
6832 ),
6833 })
6834 }
6835
6836 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6837 /// states.
6838 ///
6839 /// The fallback is demanded first, whichever arm answers: a write without a configured
6840 /// repository is refused naming the field exactly as it was before the rule existed,
6841 /// so a source that could not write before cannot write now, rather than writing for
6842 /// the one item whose own field happens to decide it.
6843 ///
6844 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6845 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6846 /// entry owned by someone other than the owner of the parent issue's repository —
6847 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6848 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6849 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6850 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6851 /// and is visible to the token is checked where its node id is resolved, still before
6852 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6853 /// looked up in a listing of the board, which can be minutes behind an issue its own
6854 /// `projectItems` already places on it — and that read answers first from this process's
6855 /// own record, so a project created moments ago in this command answers though GitHub
6856 /// has not caught up.
6857 async fn creation_target(
6858 &self,
6859 incoming: &Incoming<'_>,
6860 ) -> Result<RepositoryTarget, SourceError> {
6861 let fallback = self.configured_repository()?;
6862 let what = |incoming: &Incoming<'_>| {
6863 format!(
6864 "{} {:?}",
6865 incoming.written.kind().describes(),
6866 incoming.title
6867 )
6868 };
6869 let parent = match incoming.parent {
6870 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6871 SourceError::Refused {
6872 message: format!(
6873 "GitHub project issue {} was not found on the board of source {}, so {} \
6874 cannot be filed under it",
6875 parent.0,
6876 self.name,
6877 what(incoming)
6878 ),
6879 }
6880 })?),
6881 None => None,
6882 };
6883 let parents_repository = parent
6884 .as_ref()
6885 .map(|parent| {
6886 // A draft is on the board and so is found, but it has no repository to
6887 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6888 // would refuse the task only once `createIssue` had made it.
6889 if parent.content_kind == ContentKind::DraftIssue {
6890 return Err(SourceError::Refused {
6891 message: format!(
6892 "GitHub project item {} on the board of source {} is a draft, \
6893 which cannot have sub-issues, so {} cannot be filed under it",
6894 parent.id.0,
6895 self.name,
6896 what(incoming)
6897 ),
6898 });
6899 }
6900 // An issue's repository is where a sub-issue is placed and whose owner it
6901 // is compared against, so a parent whose repository this source cannot
6902 // spell as `owner/name` — GitHub's login grammar is wider than this
6903 // source's floor — is one nothing can be filed under.
6904 parent
6905 .own_repository
6906 .as_ref()
6907 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6908 .ok_or_else(|| SourceError::Malformed {
6909 message: format!(
6910 "GitHub project issue {} on the board of source {} is in {}, which \
6911 is not a {}/owner/name repository this source can place {} in",
6912 parent.id.0,
6913 self.name,
6914 parent
6915 .own_repository
6916 .as_ref()
6917 .map_or("no repository", Repository::as_str),
6918 RepositoryTarget::HOST,
6919 what(incoming)
6920 ),
6921 })
6922 })
6923 .transpose()?;
6924 match incoming.repositories {
6925 [named] => {
6926 let target =
6927 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6928 message: format!(
6929 "{} names repository {}, which is not a {}/owner/name repository \
6930 source {} can create an issue in; name one that is, or name none",
6931 what(incoming),
6932 named.as_str(),
6933 RepositoryTarget::HOST,
6934 self.name
6935 ),
6936 })?;
6937 if let Some(parents) = &parents_repository
6938 && parents.owner != target.owner
6939 {
6940 return Err(SourceError::Refused {
6941 message: format!(
6942 "{} names repository {}, owned by {}, but its project's issue is in \
6943 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6944 of the same owner as its parent issue; name a repository of {}, or \
6945 name none",
6946 what(incoming),
6947 target.slug(),
6948 target.owner,
6949 parents.slug(),
6950 parents.owner,
6951 parents.owner
6952 ),
6953 });
6954 }
6955 Ok(target)
6956 }
6957 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6958 }
6959 }
6960
6961 /// The node id of the repository `incoming` is being created in, or the refusal naming
6962 /// the item and the repository the token cannot see.
6963 ///
6964 /// Resolved once per command per repository; see [`Self::repository_cache`].
6965 async fn repository_id(
6966 &self,
6967 repository: &RepositoryTarget,
6968 incoming: &Incoming<'_>,
6969 ) -> Result<String, SourceError> {
6970 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6971 return Ok(id);
6972 }
6973 let data = self
6974 .graphql(
6975 graphql::REPOSITORY,
6976 json!({"owner":repository.owner,"name":repository.name}),
6977 )
6978 .await?;
6979 self.repository_read(&data, repository, incoming)
6980 }
6981
6982 /// The repository's node id out of an answer carrying the `repository` root, held for
6983 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6984 fn repository_read(
6985 &self,
6986 data: &Value,
6987 repository: &RepositoryTarget,
6988 incoming: &Incoming<'_>,
6989 ) -> Result<String, SourceError> {
6990 let node = data
6991 .get("repository")
6992 .filter(|value| !value.is_null())
6993 .ok_or_else(|| SourceError::Refused {
6994 message: format!(
6995 "GitHub repository {} was not found or is not visible to the token, so {} \
6996 {:?} cannot be created in it",
6997 repository.slug(),
6998 incoming.written.kind().describes(),
6999 incoming.title
7000 ),
7001 })?;
7002 let id = required_str(node, "id")?.to_owned();
7003 self.repository_cache()?
7004 .insert(repository.clone(), id.clone());
7005 Ok(id)
7006 }
7007
7008 fn repository_cache(
7009 &self,
7010 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
7011 self.repository_cache
7012 .lock()
7013 .map_err(|_| SourceError::Unavailable {
7014 message: "this source's record of the destination repository was left \
7015 inconsistent by an earlier failure; next: run the command again"
7016 .into(),
7017 })
7018 }
7019
7020 /// Create or update one board item, whichever kind it is.
7021 async fn write_item(
7022 &self,
7023 incoming: &Incoming<'_>,
7024 target: Option<&NativeId>,
7025 depends_on: &[DependencyEdge],
7026 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
7027 // Refused before anything is read or written: a task or a project titled the way
7028 // this board spells a document would land as an issue this same source reads back
7029 // as a document, so the field this destination cannot carry is named rather than
7030 // written and silently reclassified.
7031 if let Written::Work(kind, _) = incoming.written
7032 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
7033 {
7034 return Err(SourceError::Refused {
7035 message: format!(
7036 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
7037 spells a document, so it would read back as one rather than as a {}; \
7038 retitle it, or copy it as a document",
7039 kind.marker(),
7040 self.name,
7041 kind.marker()
7042 ),
7043 });
7044 }
7045 // The destination is read by its own id, and whether this board holds it is decided
7046 // by that read — its own `projectItems` — rather than by whether a listing of the
7047 // board happens to include it yet. See the module documentation.
7048 let existing = match target {
7049 Some(target) => {
7050 Some(
7051 self.bound_item(target)
7052 .await?
7053 .ok_or_else(|| SourceError::Refused {
7054 message: format!("GitHub destination item {} was not found", target.0),
7055 })?,
7056 )
7057 }
7058 None => None,
7059 };
7060 let existing = existing.as_ref();
7061 // An existing issue is never moved; a new one is created where the rule says — and
7062 // knowing where is what lets the board's fields and that repository's id be read
7063 // together, before anything below needs either.
7064 let creation_target = match existing {
7065 Some(_) => None,
7066 None => {
7067 let target = self.creation_target(incoming).await?;
7068 self.creation_context(&target, incoming).await?;
7069 Some(target)
7070 }
7071 };
7072 let board = self
7073 .fields_for(
7074 existing,
7075 incoming.written.status().is_some(),
7076 incoming
7077 .priority
7078 .is_some_and(|priority| priority != Priority::None),
7079 )
7080 .await?;
7081 let status_target = incoming
7082 .written
7083 .work_status()
7084 .map(|(kind, status)| self.resolved_target(kind, status.category))
7085 .transpose()?;
7086 let column = match (incoming.written.work_status(), status_target.as_ref()) {
7087 (Some((kind, status)), Some(target)) => {
7088 self.column_for(&board.fields, kind, status.category, target)?
7089 }
7090 _ => None,
7091 };
7092 // Resolved before anything is created, for the reason the column above is: a
7093 // priority this board has no option for is refused while nothing has been written.
7094 let priority_write = match incoming.priority {
7095 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
7096 None => None,
7097 };
7098 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
7099 if content_kind == ContentKind::DraftIssue {
7100 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
7101 (status_target.as_ref(), incoming.written.status())
7102 {
7103 return Err(self.closes_a_draft(status.category));
7104 }
7105 if incoming.parent.is_some() {
7106 return Err(SourceError::Refused {
7107 message: "GitHub draft items cannot be a project's sub-issue".into(),
7108 });
7109 }
7110 }
7111 match existing {
7112 Some(item) if content_kind == ContentKind::Issue => {
7113 if item.labels != incoming.labels {
7114 return Err(SourceError::Refused {
7115 message: "GitHub issue labels differ from the labels being written".into(),
7116 });
7117 }
7118 }
7119 _ => {
7120 if !incoming.labels.is_empty() {
7121 return Err(SourceError::Refused {
7122 message: "GitHub items created by this destination carry no labels".into(),
7123 });
7124 }
7125 }
7126 }
7127
7128 // The repository the issue really lives in is what the slot below is written against,
7129 // so a single entry that is where the issue is created travels as no key at all, and
7130 // the read side derives it back from the issue.
7131 let own_repository = match (existing, &creation_target) {
7132 (Some(item), _) => item.own_repository.clone(),
7133 (None, Some(target)) => Some(
7134 Repository::try_from(target.origin())
7135 .map_err(|message| SourceError::Config { message })?,
7136 ),
7137 (None, None) => None,
7138 };
7139 let (native, fallback) = self
7140 .partition_edges(
7141 incoming.written.kind(),
7142 content_kind,
7143 existing.and_then(|item| item.blocked_by.as_deref()),
7144 depends_on,
7145 )
7146 .await?;
7147 let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
7148 let content = match incoming.assets {
7149 Some(assets) => {
7150 let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
7151 let rewritten = onetaskgraph_plugin_api::serve_asset_references(
7152 incoming.content.unwrap_or_default(),
7153 &mut slot,
7154 &uploads,
7155 );
7156 incoming.content.map(|_| rewritten)
7157 }
7158 None => incoming.content.map(str::to_owned),
7159 };
7160 let body = compose_body(content.as_deref(), &slot)?;
7161 // Read before anything is created, for the reason the field below is: a value
7162 // this destination cannot store has to refuse, and refusing after `createIssue`
7163 // would leave an issue behind that nothing asked for. The engine writes a
7164 // qualified id here; a caller handing this key anything else is told so rather
7165 // than having it silently stored as no origin at all.
7166 // 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.
7167 let origin = match incoming.metadata.get(ORIGIN_KEY) {
7168 None => "",
7169 Some(Value::String(origin)) => origin.as_str(),
7170 Some(other) => {
7171 return Err(SourceError::Refused {
7172 message: format!(
7173 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
7174 is {other}"
7175 ),
7176 });
7177 }
7178 };
7179 // Resolved before anything is created: a board that cannot carry the copy origin
7180 // has to refuse the write, and refusing it after `createIssue` would leave an
7181 // issue behind that nothing asked for.
7182 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
7183 Some(field) => {
7184 if required_str(field, "__typename")? != "ProjectV2Field" {
7185 return Err(SourceError::Refused {
7186 message: format!(
7187 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
7188 ),
7189 });
7190 }
7191 Some(required_str(field, "id")?.to_owned())
7192 }
7193 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
7194 return Err(SourceError::Refused {
7195 message: format!(
7196 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
7197 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
7198 the board"
7199 ),
7200 });
7201 }
7202 None => None,
7203 };
7204
7205 let Landed {
7206 content_id,
7207 item_id,
7208 url,
7209 number,
7210 } = match existing {
7211 // Its content is written last, below, once everything else has landed.
7212 Some(item) => Landed {
7213 content_id: item.id.clone(),
7214 item_id: item.item_id.clone(),
7215 url: item.url.clone(),
7216 number: item.number,
7217 },
7218 None => {
7219 let target = creation_target
7220 .as_ref()
7221 .ok_or_else(|| SourceError::Malformed {
7222 message: "a new item was decided without a repository to create it in"
7223 .into(),
7224 })?;
7225 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7226 .await?
7227 }
7228 };
7229
7230 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7231 let column = column
7232 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7233 .map(|(field, option, _)| (field, option));
7234 // Creating an item here is several calls — `createIssue`, which files it on the
7235 // board, then its board fields, the parent and the dependencies — and GitHub can fail
7236 // at any of them. Everything this source can refuse *before* the first of those is
7237 // already checked above, so what is left is GitHub itself failing part way. When it
7238 // does over an item this call created, the issue is taken back: a write that
7239 // refused must not leave an item behind that nobody asked for, and one that does
7240 // makes the retry create a second.
7241 // Whether the board-field write carrying a moved origin was answered as landing whole.
7242 // When it was refused, GitHub does not say which of its fields ran before the one that
7243 // failed, so the origin may or may not have moved.
7244 let mut origin_landed = false;
7245 let landed = self
7246 .finish_write(
7247 board.id.as_str(),
7248 incoming,
7249 &content_id,
7250 &item_id,
7251 content_kind,
7252 existing,
7253 origin_field.as_deref(),
7254 origin,
7255 column,
7256 status_target.as_ref(),
7257 priority_write.as_ref(),
7258 &native,
7259 &mut origin_landed,
7260 )
7261 .await;
7262 // An existing item's title, body and state go last, in one `updateIssue`, once its board
7263 // fields and its relationships have landed: a refusal of any of those then leaves its
7264 // body — and the metadata slot inside it — exactly as it stood.
7265 let landed = match (landed, existing) {
7266 (Ok(()), Some(item)) => {
7267 self.update_existing(item, incoming, &body, status_target.as_ref())
7268 .await
7269 }
7270 (landed, _) => landed,
7271 };
7272 if let Err(error) = landed {
7273 match existing {
7274 // Best effort, and the write's own failure is what the caller is told: a
7275 // refusal naming the tidy-up would hide why the write failed at all.
7276 None => {
7277 let _ = self.delete_issue(&content_id).await;
7278 }
7279 // The origin field is the one piece of an existing item's metadata written
7280 // before its body, so a write refused after it puts it back as it was. When
7281 // that is refused too, the write's own failure is still what the caller is
7282 // told — with what it left behind added, because the item's metadata is then
7283 // not as it stood and a caller retrying has to know which key moved.
7284 Some(item) => {
7285 let before = item.origin.as_deref().unwrap_or("");
7286 if let Some(field) = origin_field.as_deref()
7287 && before != origin
7288 && let Err(restore) = self
7289 .set_item_field(
7290 board.id.as_str(),
7291 &item.item_id,
7292 field,
7293 json!({"text": before}),
7294 )
7295 .await
7296 {
7297 let left = if origin_landed {
7298 format!(
7299 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7300 not be put back to {before:?} ({restore}), so item {} still \
7301 holds {origin:?} there",
7302 item.id.0
7303 )
7304 } else {
7305 format!(
7306 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7307 {origin:?}, GitHub does not say whether that part of it ran, \
7308 and putting it back to {before:?} was refused ({restore}), so \
7309 item {} holds {origin:?} or {before:?} there",
7310 item.id.0
7311 )
7312 };
7313 return Err(noting(
7314 error,
7315 &format!(
7316 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7317 run the write again"
7318 ),
7319 ));
7320 }
7321 }
7322 }
7323 return Err(error);
7324 }
7325
7326 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7327 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7328 self.statuses
7329 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7330 }
7331 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7332 self.statuses
7333 .status(kind, written_option.as_deref(), false, None)
7334 }
7335 (Some((_, status)), _) => status.clone(),
7336 (None, _) => Status {
7337 category: StatusCategory::Unknown,
7338 name: "Open".to_owned(),
7339 },
7340 };
7341
7342 // So the rest of this command reads what it just did rather than what the board
7343 // said before it. See `remember_written` for which half takes it.
7344 let remembered = Resolved {
7345 item_id,
7346 id: content_id.clone(),
7347 content_kind,
7348 kind: incoming.written.kind(),
7349 title: incoming.title.to_owned(),
7350 // The visible half of the body this write composed, split back off it the
7351 // way a read splits it — so what this record reports is what a read of the
7352 // same issue reports, rather than the person's text with the metadata slot
7353 // still on the end of it.
7354 body: metadata_body(body.clone())?.0,
7355 raw_body: body.clone(),
7356 // A document has no status of its own; what it reads back as is whatever
7357 // the issue's own state says, which is what a re-read reports.
7358 status: written_status,
7359 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7360 priority: match incoming.priority {
7361 Some(priority) => HeldPriority::Read(priority),
7362 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7363 item.priority.clone()
7364 }),
7365 },
7366 // What `state_input` asked for: closed for a terminal target, open for any other
7367 // status, and the issue's own state left as it was by a document write.
7368 closed: content_kind == ContentKind::Issue
7369 && match status_target.as_ref() {
7370 Some(StatusTarget::Terminal(_, _)) => true,
7371 Some(_) => false,
7372 None => existing.is_some_and(|item| item.closed),
7373 },
7374 delivers: incoming.delivers.to_vec(),
7375 delivered_by: incoming.delivered_by.to_vec(),
7376 labels: incoming.labels.to_vec(),
7377 parent: incoming.parent.cloned(),
7378 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7379 number,
7380 // In the update path this is the item's own url, read off `existing` where the
7381 // record above was bound, so one expression serves both halves.
7382 url,
7383 created_at: existing.and_then(|item| item.created_at),
7384 updated_at: existing.and_then(|item| item.updated_at),
7385 own_repository,
7386 repositories: incoming.repositories.to_vec(),
7387 classification: incoming.classification,
7388 slot,
7389 board_id: Some(board.id.as_str().to_owned()),
7390 fields: board
7391 .fields
7392 .get("nodes")
7393 .and_then(Value::as_array)
7394 .cloned()
7395 .unwrap_or_default(),
7396 board_fields: Some(board.fields.clone()),
7397 // What this write left the relationship holding is known by id alone, and a
7398 // later read of its edges needs each far end's kind, so it reads them again.
7399 blocked_by: None,
7400 };
7401 self.remember_written(remembered, existing.is_none())?;
7402 Ok(onetaskgraph_plugin_api::AssetsWritten {
7403 id: content_id,
7404 content,
7405 })
7406 }
7407
7408 /// Everything a write does after the item exists: its board fields, its parent, and
7409 /// its dependencies.
7410 ///
7411 /// Split out of `write_item` so there is one place a failure past the point of no
7412 /// return is caught, rather than a tidy-up repeated at each `?` above.
7413 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7414 // so there is one place a failure past the point of no return is caught, and its
7415 // arguments are exactly the values that tail already had in scope. Bundling them into a
7416 // struct would describe no concept — it would be "the arguments of this function" — and
7417 // would put the whole of `write_item`'s locals behind one more indirection.
7418 #[allow(clippy::too_many_arguments)]
7419 async fn finish_write(
7420 &self,
7421 board_id: &str,
7422 incoming: &Incoming<'_>,
7423 content_id: &NativeId,
7424 item_id: &str,
7425 content_kind: ContentKind,
7426 existing: Option<&Resolved>,
7427 origin_field: Option<&str>,
7428 origin: &str,
7429 column: Option<(String, String)>,
7430 status_target: Option<&StatusTarget>,
7431 priority: Option<&PriorityWrite>,
7432 native: &[String],
7433 origin_landed: &mut bool,
7434 ) -> Result<(), SourceError> {
7435 let mut fields = Vec::new();
7436 if let Some(field_id) = origin_field
7437 && existing.map_or(!origin.is_empty(), |item| {
7438 item.origin.as_deref().unwrap_or("") != origin
7439 })
7440 {
7441 fields.push((field_id.to_owned(), json!({"text":origin})));
7442 }
7443 if let Some((field_id, option_id)) = column {
7444 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7445 }
7446 let clear = match priority {
7447 Some(PriorityWrite::Select { field, option }) => {
7448 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7449 None
7450 }
7451 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7452 None => None,
7453 };
7454 self.set_item_fields(board_id, item_id, &fields, clear)
7455 .await?;
7456 *origin_landed = true;
7457
7458 // An existing issue closes in the `updateIssue` its write ends with; one created just
7459 // now closes here, once its option is selected.
7460 if existing.is_none()
7461 && content_kind == ContentKind::Issue
7462 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7463 {
7464 self.update_content(
7465 ContentKind::Issue,
7466 content_id,
7467 json!({"stateInput":state_input(status_target)}),
7468 )
7469 .await?;
7470 }
7471
7472 if content_kind == ContentKind::Issue {
7473 self.reparent(
7474 existing.and_then(|item| item.parent.clone()),
7475 content_id,
7476 incoming.parent,
7477 )
7478 .await?;
7479 // A document takes part in no dependency graph, so writing one neither reads
7480 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7481 // against the empty list a document write carries would *delete* whatever
7482 // relationships a person had made on that issue, which is a write nobody
7483 // asked for.
7484 if incoming.written.kind() != BoardKind::Document {
7485 let issue = match existing {
7486 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7487 None => Issue::Created,
7488 };
7489 self.reconcile_blocked_by(content_id, native, issue).await?;
7490 }
7491 }
7492 Ok(())
7493 }
7494
7495 /// Delete one issue, which takes its board item with it.
7496 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7497 let data = self
7498 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7499 .await?;
7500 data.pointer("/deleteIssue/repository")
7501 .filter(|value| !value.is_null())
7502 .ok_or_else(|| SourceError::Malformed {
7503 message: "GitHub issue deletion returned no repository".into(),
7504 })?;
7505 self.forget(id)?;
7506 Ok(())
7507 }
7508
7509 /// Remove one item this copy created, so a copy that could not finish leaves the board
7510 /// as it found it.
7511 ///
7512 /// Deleting the issue takes its board item with it, so there is no second mutation to
7513 /// keep in step. An id the board does not hold is not an error: the item is already
7514 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7515 /// by its own id — a listing of the board can still be missing an item it holds, and
7516 /// reading that as *already gone* would leave behind the very item this was asked to
7517 /// take back.
7518 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7519 let Some(item) = self.bound_item(id).await? else {
7520 return Ok(());
7521 };
7522 if item.content_kind == ContentKind::DraftIssue {
7523 return Err(SourceError::Refused {
7524 message: format!(
7525 "GitHub item {} is a draft, and this source removes an item by deleting \
7526 its issue; next: remove it from the board by hand",
7527 id.0
7528 ),
7529 });
7530 }
7531 let data = self
7532 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7533 .await?;
7534 data.pointer("/deleteIssue/repository")
7535 .filter(|value| !value.is_null())
7536 .ok_or_else(|| SourceError::Malformed {
7537 message: "GitHub issue deletion returned no repository".into(),
7538 })?;
7539 self.forget(id)?;
7540 Ok(())
7541 }
7542
7543 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7544 /// task.
7545 ///
7546 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7547 /// read of the task cannot disagree about which ids name one: a project or a document of
7548 /// this board is not a task here either.
7549 ///
7550 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7551 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7552 /// which would read as a task nobody has commented on yet.
7553 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7554 let cached = self.resolved_cache()?.get(task).cloned();
7555 let Some(item) = (match cached {
7556 Some(item) => Some(item),
7557 None => self.item_by_id(task).await?,
7558 })
7559 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7560 return Ok(None);
7561 };
7562 if item.content_kind == ContentKind::DraftIssue {
7563 return Err(self.draft_has_no_comments(task));
7564 }
7565 Ok(Some(item.id))
7566 }
7567
7568 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7569 /// issues, and a draft is not one.
7570 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7571 SourceError::Refused {
7572 message: format!(
7573 "task {} of source {} is a draft item on the board, and GitHub keeps \
7574 comments on issues alone, so a draft has none to read or write; next: \
7575 convert the draft to an issue on the board, then comment on the issue it \
7576 becomes",
7577 task.0, self.name
7578 ),
7579 }
7580 }
7581
7582 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7583 /// request — or `None` when this board holds no task by that id.
7584 ///
7585 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7586 /// is answered with the draft and the refusal, at the price of the draft's own read.
7587 async fn issue_detail(
7588 &self,
7589 id: &NativeId,
7590 page: &PageRequest,
7591 ) -> Result<Option<TaskDetailRead>, SourceError> {
7592 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7593 let asked = self
7594 .graphql(
7595 graphql::ISSUE_DETAIL,
7596 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7597 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7598 "duplicates":true}),
7599 )
7600 .await;
7601 let data = match asked {
7602 Ok(data) => data,
7603 Err(error) if unresolvable_node(&error) => return Ok(None),
7604 Err(error) => return Err(error),
7605 };
7606 // `node` is null for an id that names nothing, and absent only from an answer this
7607 // source cannot read — never the same thing.
7608 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7609 message: format!("GitHub answered the read of {} with no node", id.0),
7610 })?;
7611 self.detail_of(id, node, true, after).await
7612 }
7613
7614 /// Several tasks, each with the first page of its comments when `comments` is set, read
7615 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7616 /// order.
7617 ///
7618 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7619 /// one item at a time, so that id is answered as missing and the others as themselves; any
7620 /// other refusal is every id of that batch's answer.
7621 async fn issue_details(
7622 &self,
7623 ids: &[NativeId],
7624 comments: Option<&PageRequest>,
7625 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7626 let mut read = Vec::with_capacity(ids.len());
7627 for batch in ids.chunks(DETAIL_BATCH) {
7628 match self
7629 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7630 .await
7631 {
7632 Ok(data) => {
7633 for (slot, id) in batch.iter().enumerate() {
7634 // Every alias asked for is answered, null for an id naming nothing;
7635 // one missing is an answer this source cannot read.
7636 let read_one = match data.get(format!("i{slot}")) {
7637 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7638 None => Err(SourceError::Malformed {
7639 message: format!(
7640 "GitHub answered a batch read with no item for {}",
7641 id.0
7642 ),
7643 }),
7644 };
7645 read.push(read_one);
7646 }
7647 }
7648 Err(error) if unresolvable_node(&error) => {
7649 for id in batch {
7650 read.push(match comments {
7651 Some(page) => self.issue_detail(id, page).await,
7652 None => self.task_read(id).await,
7653 });
7654 }
7655 }
7656 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7657 }
7658 }
7659 read
7660 }
7661
7662 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7663 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7664 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7665 task,
7666 comments: None,
7667 }))
7668 }
7669
7670 /// What one node a detail read reached says: the task this board holds by `id`, with the
7671 /// page of comments the node carries when `commented` — or `None` for a node that is no
7672 /// task of this board.
7673 ///
7674 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7675 /// and an item this process created answers from this process's own record, which a node
7676 /// read taken moments after the write can still be behind.
7677 async fn detail_of(
7678 &self,
7679 id: &NativeId,
7680 node: &Value,
7681 commented: bool,
7682 after: Option<&str>,
7683 ) -> Result<Option<TaskDetailRead>, SourceError> {
7684 if node.is_null() {
7685 return Ok(None);
7686 }
7687 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7688 // An issue answered under one id is that id's, or the answer is not one this source
7689 // can report: reporting another issue's task and comments under the qualified id asked
7690 // for would be the one wrong answer here. A draft's own read checks the same.
7691 if !draft
7692 && optional_str(node, "__typename")? == Some("Issue")
7693 && required_str(node, "id")? != id.0
7694 {
7695 return Err(SourceError::Malformed {
7696 message: format!(
7697 "GitHub answered the read of {} with issue {}",
7698 id.0,
7699 required_str(node, "id")?
7700 ),
7701 });
7702 }
7703 let item = if draft {
7704 self.draft_by_id(id).await?
7705 } else {
7706 self.resolve_issue(node).await?
7707 };
7708 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7709 return Ok(None);
7710 };
7711 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7712 let task = own.unwrap_or(item).task()?;
7713 let comments = match (commented, draft) {
7714 (false, _) => None,
7715 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7716 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7717 };
7718 Ok(Some(TaskDetailRead { task, comments }))
7719 }
7720
7721 /// Whether the comment `comment` is one of `issue`'s own.
7722 ///
7723 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7724 /// comment's id and nothing else: a comment id given against the wrong task would
7725 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7726 /// names something that is not an issue comment, is a comment this task does not have —
7727 /// which is what GitHub refusing to resolve it means too.
7728 async fn comment_is_on(
7729 &self,
7730 issue: &NativeId,
7731 comment: &NativeId,
7732 ) -> Result<bool, SourceError> {
7733 let asked = self
7734 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7735 .await;
7736 let data = match asked {
7737 Ok(data) => data,
7738 Err(error) if unresolvable_node(&error) => return Ok(false),
7739 Err(error) => return Err(error),
7740 };
7741 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7742 return Ok(false);
7743 };
7744 if optional_str(node, "__typename")? != Some("IssueComment") {
7745 return Ok(false);
7746 }
7747 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7748 message: format!("GitHub issue comment {} names no issue", comment.0),
7749 })?;
7750 Ok(required_str(on, "id")? == issue.0)
7751 }
7752
7753 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7754 async fn partition_edges(
7755 &self,
7756 near_kind: BoardKind,
7757 near_content: ContentKind,
7758 carried: Option<&[Value]>,
7759 depends_on: &[DependencyEdge],
7760 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7761 let mut native = Vec::new();
7762 let mut fallback = Vec::new();
7763 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7764 .iter()
7765 .map(|edge| {
7766 let same_source = edge
7767 .to
7768 .source()
7769 .is_none_or(|source| source == self.name.as_str());
7770 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7771 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7772 // colons of its own, so the far end is everything after that one separator.
7773 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7774 let far_id = if edge.to.is_qualified() {
7775 edge.to
7776 .id()
7777 .split_once(':')
7778 .map_or(edge.to.id(), |(_, native)| native)
7779 } else {
7780 edge.to.id()
7781 };
7782 // One that already blocks the near issue was answered by that issue's own
7783 // read, which carried each of its blockers' kinds — an issue every one — so it
7784 // is not read again.
7785 let blocking = carried.and_then(|nodes| {
7786 nodes
7787 .iter()
7788 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7789 });
7790 (edge, far_id, same_source, blocking)
7791 })
7792 .collect();
7793 // Every other same-source far end is read by its own id, exactly as the item it is a
7794 // far end of is: whether this board holds it is that read's answer, never a listing's.
7795 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7796 let mut unread: Vec<NativeId> = Vec::new();
7797 for (_, far_id, same_source, blocking) in &far_ends {
7798 let id = NativeId((*far_id).to_owned());
7799 if *same_source && blocking.is_none() && !unread.contains(&id) {
7800 unread.push(id);
7801 }
7802 }
7803 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7804 .iter()
7805 .cloned()
7806 .zip(self.items_by_ids(&unread).await?)
7807 .collect();
7808 for (edge, far_id, same_source, blocking) in far_ends {
7809 let far = match (same_source, blocking) {
7810 (false, _) => None,
7811 (true, Some(node)) => Some(FarEnd {
7812 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7813 BoardKind::Document
7814 } else {
7815 BoardKind::Work(related_kind(node)?)
7816 },
7817 content_kind: ContentKind::Issue,
7818 }),
7819 (true, None) => {
7820 let read = read
7821 .get(&NativeId(far_id.to_owned()))
7822 .cloned()
7823 .flatten()
7824 .ok_or_else(|| SourceError::Refused {
7825 message: format!("GitHub dependency item {far_id} was not found"),
7826 })?;
7827 Some(FarEnd {
7828 kind: read.kind,
7829 content_kind: read.content_kind,
7830 })
7831 }
7832 };
7833 let far = far.as_ref();
7834 // The caller says which kind the far end is, and this board holds the far end
7835 // itself, so a disagreement is settled here rather than stored: recorded, the
7836 // wrong kind would read back as a cross-level edge that never existed; written
7837 // natively, it would name a relationship of a different level than the caller
7838 // asked for.
7839 //
7840 // A far end this board holds as a *document* fails the same comparison and is
7841 // refused by the same sentence: `ItemKind` has no document variant because
7842 // nothing may point at one, so no caller can name it correctly and the refusal
7843 // is the only honest answer.
7844 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7845 return Err(SourceError::Refused {
7846 message: format!(
7847 "GitHub dependency item {far_id} is a {} of this board, and this item \
7848 names it as a {}; record the kind it is",
7849 disagreeing.kind.describes(),
7850 edge.to.kind.marker()
7851 ),
7852 });
7853 }
7854 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7855 // however the far end is spelled — and one classified native here would be
7856 // written nowhere at all, because a draft's native reconciliation never runs.
7857 let native_here = near_content == ContentKind::Issue
7858 && far.is_some_and(|far| {
7859 far.content_kind == ContentKind::Issue
7860 && BoardKind::Work(edge.to.kind) == near_kind
7861 });
7862 if native_here {
7863 native.push(far_id.to_owned());
7864 } else {
7865 fallback.push(edge.clone());
7866 }
7867 }
7868 Ok((native, fallback))
7869 }
7870
7871 async fn update_existing(
7872 &self,
7873 item: &Resolved,
7874 incoming: &Incoming<'_>,
7875 body: &Option<String>,
7876 status_target: Option<&StatusTarget>,
7877 ) -> Result<(), SourceError> {
7878 let title = incoming.written_title();
7879 // A terminal status closes the issue here, in the same mutation as its body: its board
7880 // option was selected before this, so a close never lands on an item whose board cannot
7881 // show it.
7882 let fields = match item.content_kind {
7883 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7884 ContentKind::Issue => json!({"title":title,"body":body,
7885 "stateInput":state_input(status_target)}),
7886 };
7887 self.update_content(item.content_kind, &item.id, fields)
7888 .await
7889 }
7890
7891 /// Update one board item's content with exactly `fields` beside its id, through the
7892 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7893 /// a draft.
7894 ///
7895 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7896 /// is what lets a narrow write carry the one thing it changes and nothing else.
7897 async fn update_content(
7898 &self,
7899 kind: ContentKind,
7900 id: &NativeId,
7901 fields: Value,
7902 ) -> Result<(), SourceError> {
7903 let (operation, id_key, pointer) = match kind {
7904 ContentKind::DraftIssue => (
7905 graphql::UPDATE_DRAFT,
7906 "draftIssueId",
7907 "/updateProjectV2DraftIssue/draftIssue",
7908 ),
7909 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7910 };
7911 let mut input = fields;
7912 input[id_key] = json!(id.0);
7913 let data = self.graphql(operation, json!({"input":input})).await?;
7914 let returned = data
7915 .pointer(pointer)
7916 .ok_or_else(|| SourceError::Malformed {
7917 message: "GitHub item update returned no item".into(),
7918 })?;
7919 if required_str(returned, "id")? != id.0 {
7920 return Err(SourceError::Malformed {
7921 message: "GitHub item update returned the wrong item".into(),
7922 });
7923 }
7924 Ok(())
7925 }
7926
7927 /// Creates one issue, files it on the board, and reports what a read of it would say:
7928 /// its content id, its board item id, and the web address GitHub gave it.
7929 ///
7930 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7931 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7932 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7933 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7934 /// "Content already exists in this project". A terminal status is not written here:
7935 /// `finish_write` selects its option first and closes the issue after, so a close never
7936 /// lands on an item whose board cannot show it.
7937 ///
7938 /// The address and the number come back here because this is the only place either is
7939 /// known before GitHub's own board read catches up — an item this run created answers
7940 /// the reads that follow it out of the record below, and one remembered without them
7941 /// would report no location and no key for the rest of the run.
7942 async fn create_and_file_issue(
7943 &self,
7944 board_id: &str,
7945 repository: &RepositoryTarget,
7946 incoming: &Incoming<'_>,
7947 body: &Option<String>,
7948 ) -> Result<Landed, SourceError> {
7949 let repository_id = self.repository_id(repository, incoming).await?;
7950 let data = self
7951 .graphql(
7952 graphql::CREATE_ISSUE,
7953 json!({"input":{
7954 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7955 }}),
7956 )
7957 .await?;
7958 let created = data
7959 .pointer("/createIssue/issue")
7960 .filter(|value| !value.is_null())
7961 .ok_or_else(|| SourceError::Malformed {
7962 message: "GitHub issue creation returned no issue".into(),
7963 })?;
7964 let content_id = NativeId(required_str(created, "id")?.to_owned());
7965 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7966 // a response without it is not worth failing a landed write over — the item simply
7967 // reports no location until the board read catches up, which is what it did before.
7968 let url = optional_str(created, "url")?.map(str::to_owned);
7969 // The issue exists from here on, so an unreadable number and a refused board
7970 // filing below each try, best effort, to take it back: an issue in the repository
7971 // that is on no board is an item nobody asked for and nothing here would find again.
7972 //
7973 // Its number is optional on the same terms its address is — a landed write is not
7974 // worth failing over a member that came back missing, and such an item reports no
7975 // handle until a board read catches up. A number that is *present* and is not an
7976 // unsigned integer is still a response this source cannot read.
7977 let number = match created_issue_number(created) {
7978 Ok(number) => number,
7979 Err(error) => {
7980 let _ = self.delete_issue(&content_id).await;
7981 return Err(error);
7982 }
7983 };
7984 let added = match self
7985 .graphql(
7986 graphql::ADD_TO_BOARD,
7987 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7988 )
7989 .await
7990 {
7991 Ok(added) => added,
7992 Err(error) => {
7993 let _ = self.delete_issue(&content_id).await;
7994 return Err(error);
7995 }
7996 };
7997 let item = added
7998 .pointer("/addProjectV2ItemById/item")
7999 .filter(|value| !value.is_null())
8000 .ok_or_else(|| SourceError::Malformed {
8001 message: "GitHub board addition returned no project item".into(),
8002 })?;
8003 Ok(Landed {
8004 content_id,
8005 item_id: required_str(item, "id")?.to_owned(),
8006 url,
8007 number,
8008 })
8009 }
8010
8011 /// Move one issue under the project it now belongs to, or out of the one it left.
8012 async fn reparent(
8013 &self,
8014 held: Option<NativeId>,
8015 child: &NativeId,
8016 wanted: Option<&NativeId>,
8017 ) -> Result<(), SourceError> {
8018 if held.as_ref() == wanted {
8019 return Ok(());
8020 }
8021 if let Some(held) = &held {
8022 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
8023 .await?;
8024 }
8025 if let Some(wanted) = wanted {
8026 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
8027 .await?;
8028 }
8029 Ok(())
8030 }
8031
8032 async fn sub_issue(
8033 &self,
8034 operation: &str,
8035 parent: &NativeId,
8036 child: &NativeId,
8037 root: &str,
8038 ) -> Result<(), SourceError> {
8039 let data = self
8040 .graphql(
8041 operation,
8042 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
8043 )
8044 .await?;
8045 let issue =
8046 data.pointer(&format!("/{root}/issue"))
8047 .ok_or_else(|| SourceError::Malformed {
8048 message: "GitHub sub-issue update returned no issue".into(),
8049 })?;
8050 let sub =
8051 data.pointer(&format!("/{root}/subIssue"))
8052 .ok_or_else(|| SourceError::Malformed {
8053 message: "GitHub sub-issue update returned no sub-issue".into(),
8054 })?;
8055 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
8056 return Err(SourceError::Malformed {
8057 message: "GitHub sub-issue update returned the wrong issues".into(),
8058 });
8059 }
8060 Ok(())
8061 }
8062
8063 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
8064 /// whether there was one.
8065 ///
8066 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
8067 /// relationships are not read: there is nothing a read of them could find.
8068 async fn reconcile_blocked_by(
8069 &self,
8070 content_id: &NativeId,
8071 native: &[String],
8072 issue: Issue<'_>,
8073 ) -> Result<bool, SourceError> {
8074 let current = match issue {
8075 Issue::Created => Vec::new(),
8076 Issue::Existing(Some(held)) => held
8077 .iter()
8078 .map(|far| required_str(far, "id").map(str::to_owned))
8079 .collect::<Result<Vec<_>, _>>()?,
8080 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
8081 };
8082 let mut changed = false;
8083 for (operation, far_id) in current
8084 .iter()
8085 .filter(|id| !native.contains(id))
8086 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
8087 .chain(
8088 native
8089 .iter()
8090 .filter(|id| !current.contains(id))
8091 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
8092 )
8093 {
8094 let data = self
8095 .graphql(
8096 operation,
8097 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
8098 )
8099 .await?;
8100 let root = if operation == graphql::ADD_BLOCKED_BY {
8101 "addBlockedBy"
8102 } else {
8103 "removeBlockedBy"
8104 };
8105 let issue =
8106 data.pointer(&format!("/{root}/issue"))
8107 .ok_or_else(|| SourceError::Malformed {
8108 message: "GitHub dependency update returned no issue".into(),
8109 })?;
8110 let blocker = data
8111 .pointer(&format!("/{root}/blockingIssue"))
8112 .ok_or_else(|| SourceError::Malformed {
8113 message: "GitHub dependency update returned no blocking issue".into(),
8114 })?;
8115 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
8116 {
8117 return Err(SourceError::Malformed {
8118 message: "GitHub dependency update returned the wrong issues".into(),
8119 });
8120 }
8121 changed = true;
8122 }
8123 Ok(changed)
8124 }
8125}
8126
8127/// What a write needs to know of one far end it names: which kind of item it is, and whether
8128/// it is an issue a native relationship can name.
8129struct FarEnd {
8130 kind: BoardKind,
8131 content_kind: ContentKind,
8132}
8133
8134/// Whether the issue one write reconciles was created by that write or was already there.
8135#[derive(Clone, Copy, PartialEq, Eq)]
8136enum Issue<'a> {
8137 /// Created by this write, so it holds no relationships yet.
8138 Created,
8139 /// On the board before this write, holding whatever relationships it holds — the far
8140 /// ends of its whole `blockedBy`, when the read that reached it carried them.
8141 Existing(Option<&'a [Value]>),
8142}
8143
8144/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
8145enum Reached {
8146 /// An issue this board holds, resolved into everything this source reports about it.
8147 Held(Box<Resolved>),
8148 /// Nothing this board holds: no such node, or a node on some other board.
8149 Nothing,
8150 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
8151 /// again by [`GitHubProjectsSource::draft_by_id`].
8152 Draft,
8153}
8154
8155/// What GitHub says when a string is not a node id it can resolve.
8156///
8157/// Matched because it is the ordinary answer to a project selector naming a project by its
8158/// *name*, and reporting that as a failure would make naming one impossible. It is read
8159/// off the refusal GitHub sent, never guessed from the shape of the string: this source
8160/// does not define the syntax of a GitHub node id and would be wrong about it.
8161const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
8162
8163/// `error` with `note` added to the end of what it says, its kind and every other member
8164/// unchanged — so a caller still branches on the failure that happened, and reads beside it
8165/// what that failure left behind.
8166fn noting(error: SourceError, note: &str) -> SourceError {
8167 match error {
8168 SourceError::Config { message } => SourceError::Config {
8169 message: message + note,
8170 },
8171 SourceError::Auth { message } => SourceError::Auth {
8172 message: message + note,
8173 },
8174 SourceError::Refused { message } => SourceError::Refused {
8175 message: message + note,
8176 },
8177 SourceError::RateLimited {
8178 retry_after_seconds,
8179 message,
8180 } => SourceError::RateLimited {
8181 retry_after_seconds,
8182 message: Some(message.unwrap_or_default() + note),
8183 },
8184 SourceError::Unavailable { message } => SourceError::Unavailable {
8185 message: message + note,
8186 },
8187 SourceError::Malformed { message } => SourceError::Malformed {
8188 message: message + note,
8189 },
8190 }
8191}
8192
8193/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
8194/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
8195/// for them.
8196///
8197/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
8198/// is read again at no added price.
8199fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
8200 let mut variables = serde_json::Map::new();
8201 for slot in 0..DETAIL_BATCH {
8202 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
8203 variables.insert(format!("id{slot}"), json!(id));
8204 }
8205 variables.insert(
8206 "first".to_owned(),
8207 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
8208 );
8209 variables.insert("comments".to_owned(), json!(comments.is_some()));
8210 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
8211 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
8212 variables.insert("duplicates".to_owned(), json!(true));
8213 Value::Object(variables)
8214}
8215
8216/// Whether this refusal is GitHub saying the id names no node at all.
8217fn unresolvable_node(error: &SourceError) -> bool {
8218 matches!(error, SourceError::Refused { message }
8219 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
8220}
8221
8222/// One project name, as a search qualifier which filters on it at the server.
8223///
8224/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8225/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8226/// the way it documents. A title matched here is still compared for equality afterwards:
8227/// the qualifier narrows what the server sends, and this source decides what it names.
8228fn title_qualifier(name: &str) -> String {
8229 format!("in:title {}", quoted(name))
8230}
8231
8232/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8233/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8234/// it documents — so a value holding a qualifier's spelling is searched for rather than
8235/// obeyed.
8236fn quoted(value: &str) -> String {
8237 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8238 format!("\"{escaped}\"")
8239}
8240
8241/// The search qualifier for the issues updated at or after `since`.
8242///
8243/// Written to the second, rounded down, which can only widen what the search returns.
8244fn updated_qualifier(since: DateTime<Utc>) -> String {
8245 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8246}
8247
8248/// The search terms that narrow a board-scoped issue search to a task query's text and
8249/// metadata predicates, or `None` when it carries neither.
8250///
8251/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8252/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8253/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8254/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8255/// a metadata value searches both fields for both — wider than asked, never narrower, and
8256/// every candidate is confirmed in process afterwards.
8257///
8258/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8259/// matches whole tokens where a substring rule would match inside a word, so an item holding
8260/// the text only inside a longer word is not returned. A text of nothing but whitespace
8261/// matches every item, so it narrows nothing and is not sent.
8262fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8263 let text = query
8264 .text
8265 .as_ref()
8266 .filter(|text| !text.terms.trim().is_empty());
8267 if text.is_none() && query.metadata.is_empty() {
8268 return None;
8269 }
8270 let (title, body) = match text.map(|text| text.fields) {
8271 None => (false, true),
8272 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8273 Some(TextFields::Content) => (false, true),
8274 Some(TextFields::TitleOrContent) => (true, true),
8275 };
8276 let fields = match (title, body) {
8277 (true, true) => "in:title,body",
8278 (true, false) => "in:title",
8279 _ => "in:body",
8280 };
8281 let phrases = text
8282 .map(|text| text.terms.clone())
8283 .into_iter()
8284 .chain(
8285 query
8286 .metadata
8287 .iter()
8288 .map(|wanted| as_stored(wanted.value())),
8289 )
8290 .map(|phrase| quoted(&phrase))
8291 .collect::<Vec<_>>();
8292 Some(format!("{fields} {}", phrases.join(" ")))
8293}
8294
8295/// The search terms that narrow a board-scoped issue search to a project or document query's
8296/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8297/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8298fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8299 narrowing_qualifiers(&TaskQuery {
8300 text: text.cloned(),
8301 ..TaskQuery::default()
8302 })
8303}
8304
8305/// Refuses a project or document query's text GitHub's issue search cannot find, before
8306/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8307/// query's.
8308fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8309 refuse_unsearchable(&TaskQuery {
8310 text: text.cloned(),
8311 ..TaskQuery::default()
8312 })
8313}
8314
8315/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8316/// before anything is asked of GitHub.
8317///
8318/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8319/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8320/// left out, the search is every issue of the board. So this source says it cannot answer
8321/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8322/// nothing GitHub could search for, and keeps the board read it always had.
8323fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8324 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8325 letter or digit with a bounded query";
8326 if let Some(text) = &query.text
8327 && !text.terms.trim().is_empty()
8328 && !has_words(&text.terms)
8329 {
8330 return Err(SourceError::Refused {
8331 message: format!(
8332 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8333 text.terms
8334 ),
8335 });
8336 }
8337 if let Some(wanted) = query
8338 .metadata
8339 .iter()
8340 .find(|wanted| !has_words(wanted.value()))
8341 {
8342 return Err(SourceError::Refused {
8343 message: format!(
8344 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8345 wanted.value(),
8346 std::iter::once(wanted.key())
8347 .chain(wanted.path().iter().map(String::as_str))
8348 .collect::<Vec<_>>()
8349 .join("/"),
8350 ),
8351 });
8352 }
8353 Ok(())
8354}
8355
8356/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8357fn has_words(phrase: &str) -> bool {
8358 phrase.chars().any(char::is_alphanumeric)
8359}
8360
8361/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8362///
8363/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8364/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8365/// which GitHub's word match would read as different words.
8366fn as_stored(value: &str) -> String {
8367 let encoded = Value::String(value.to_owned()).to_string();
8368 encoded[1..encoded.len() - 1].to_owned()
8369}
8370
8371/// The one narrower question a task query carrying a text, metadata or origin predicate is
8372/// sent as.
8373enum Narrowing {
8374 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8375 Origin(String),
8376 /// The board-scoped issue search narrowed by these qualifiers.
8377 Search(String),
8378}
8379
8380impl Narrowing {
8381 /// What this question is remembered under for the length of one command.
8382 fn key(&self) -> String {
8383 match self {
8384 Self::Origin(origin) => format!("origin {origin}"),
8385 Self::Search(also) => format!("search {also}"),
8386 }
8387 }
8388}
8389
8390/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8391enum Resumed {
8392 /// It reported another page, which starts after this cursor.
8393 More(String),
8394 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8395 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8396 /// can go on walking the other connection.
8397 Ended(Option<String>),
8398}
8399
8400impl Resumed {
8401 /// Whether the connection has another page.
8402 const fn has_more(&self) -> bool {
8403 matches!(self, Self::More(_))
8404 }
8405
8406 /// The cursor to send this connection next.
8407 fn cursor(self) -> Option<String> {
8408 match self {
8409 Self::More(next) => Some(next),
8410 Self::Ended(last) => last,
8411 }
8412 }
8413}
8414
8415/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8416/// with no cursor to it, or from a cursor that does not advance.
8417fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8418 let info = connection
8419 .get("pageInfo")
8420 .ok_or_else(|| SourceError::Malformed {
8421 message: "GitHub connection has no pageInfo".into(),
8422 })?;
8423 let end = optional_str(info, "endCursor")?;
8424 if required_bool(info, "hasNextPage")? {
8425 let next = end.ok_or_else(|| SourceError::Malformed {
8426 message: "GitHub connection reports another page and no endCursor".into(),
8427 })?;
8428 validate_cursor_progress(after, next)?;
8429 return Ok(Resumed::More(next.to_owned()));
8430 }
8431 Ok(Resumed::Ended(
8432 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8433 ))
8434}
8435
8436/// The board, and every item on it this source reports.
8437#[derive(Clone)]
8438struct Board {
8439 id: String,
8440 fields: Value,
8441 items: Vec<Resolved>,
8442}
8443
8444/// What a write needs of the board and nothing more: its node id and its field
8445/// definitions, in the shape a read of the board's own `fields` gives them.
8446///
8447/// Deliberately no items. A write decides which item it writes, which parent it files
8448/// under and which far ends it names by reading each of them by its own id; this is the
8449/// half of the board those reads cannot carry, and holding no item is what keeps it from
8450/// ever being asked whether an item is there.
8451#[derive(Clone)]
8452struct BoardFields {
8453 id: BoardId,
8454 fields: Value,
8455}
8456
8457/// A board's node id: what a field write and `addProjectV2ItemById` address.
8458///
8459/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8460/// refused where it is read, and one an item names blank is read as not named at all.
8461#[derive(Clone)]
8462struct BoardId(String);
8463
8464/// Where one write left its item, for the record the rest of the command reads it out of.
8465///
8466/// A named record rather than a tuple because the update arm and the create arm each fill
8467/// all four, and two `Option`s of different meaning side by side in a tuple are two
8468/// positions a reader has to count.
8469struct Landed {
8470 /// The issue's own node id, which is the [`NativeId`] this source reports.
8471 content_id: NativeId,
8472 /// The board item's id, which is what a field write addresses.
8473 // 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.
8474 item_id: String,
8475 /// The web address GitHub gave the issue, when it gave one.
8476 // 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.
8477 url: Option<String>,
8478 /// The issue's number on its repository, when GitHub reported one.
8479 number: Option<u64>,
8480}
8481
8482impl BoardId {
8483 fn parse(id: &str) -> Result<Self, SourceError> {
8484 if id.trim().is_empty() {
8485 return Err(SourceError::Malformed {
8486 message: "GitHub named a board with a blank node id".into(),
8487 });
8488 }
8489 Ok(Self(id.to_owned()))
8490 }
8491
8492 fn as_str(&self) -> &str {
8493 &self.0
8494 }
8495}
8496
8497impl Board {
8498 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8499 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8500 let nodes = fields
8501 .get("nodes")
8502 .and_then(Value::as_array)
8503 .ok_or_else(|| SourceError::Malformed {
8504 message: "GitHub project fields.nodes is not an array".into(),
8505 })?;
8506 Ok(nodes
8507 .iter()
8508 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8509 }
8510}
8511
8512/// One board item, resolved into everything this source reports about it.
8513#[derive(Clone)]
8514struct Resolved {
8515 item_id: String,
8516 id: NativeId,
8517 content_kind: ContentKind,
8518 kind: BoardKind,
8519 title: String,
8520 body: Option<String>,
8521 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8522 /// that changes the slot alone has to keep byte for byte outside it.
8523 raw_body: Option<String>,
8524 status: Status,
8525 /// The name of the board `Status` option this item sits in, as the board spells it.
8526 option: Option<String>,
8527 /// What its `Priority` field says, read through this instance's mapping.
8528 priority: HeldPriority,
8529 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8530 closed: bool,
8531 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8532 delivers: Vec<TaskRef>,
8533 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8534 /// task.
8535 delivered_by: Vec<TaskRef>,
8536 labels: Vec<Label>,
8537 parent: Option<NativeId>,
8538 // 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.
8539 origin: Option<String>,
8540 /// The issue's own number on its repository, as GitHub reports it.
8541 ///
8542 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8543 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8544 /// an issue this run created whose creating mutation answered without one, which is a
8545 /// response GitHub's own schema says cannot happen and which a landed write is not
8546 /// worth failing over. An `Issue` read off the board always has one.
8547 number: Option<u64>,
8548 url: Option<String>,
8549 created_at: Option<DateTime<Utc>>,
8550 updated_at: Option<DateTime<Utc>>,
8551 own_repository: Option<Repository>,
8552 repositories: Vec<Repository>,
8553 classification: Classification,
8554 slot: BTreeMap<String, Value>,
8555 /// The node id of the board this item sits on, when the read that reached it said.
8556 board_id: Option<String>,
8557 /// The definition of every board field this item holds a value of, in the shape a read
8558 /// of the board's own `fields` gives one.
8559 ///
8560 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8561 /// which says nothing about whether the board has it.
8562 fields: Vec<Value>,
8563 /// Every field the board this item sits on defines, as its own read of the board's
8564 /// `fields` gives them — when the read that reached the item carried them, which a read
8565 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8566 /// of the board.
8567 board_fields: Option<Value>,
8568 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8569 /// selects one — when the read that reached it carried the connection to its end, which a
8570 /// read of it by its own id does for any issue blocked by no more than a page. What a
8571 /// write reconciles that relationship against, and what a read of its forward edges in
8572 /// the same command answers with.
8573 blocked_by: Option<Vec<Value>>,
8574}
8575
8576impl Resolved {
8577 /// The board this item's own read names it on, when that read named one this source can
8578 /// address.
8579 fn named_board(&self) -> Option<BoardId> {
8580 self.board_id
8581 .as_deref()
8582 .and_then(|id| BoardId::parse(id).ok())
8583 }
8584
8585 /// The board's id and every field it defines, when the read that reached this item
8586 /// carried both — which a read of it by its own id does.
8587 fn carried_board(&self) -> Option<BoardFields> {
8588 Some(BoardFields {
8589 id: self.named_board()?,
8590 fields: self.board_fields.clone()?,
8591 })
8592 }
8593
8594 /// Whether this item holds a value of the board field called `name`, and so carries
8595 /// that field's definition. `false` says nothing about whether the board has the field.
8596 fn defines(&self, name: &str) -> bool {
8597 self.fields
8598 .iter()
8599 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8600 }
8601
8602 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8603 /// in a field of its own, and none of the five keys that are only an encoding.
8604 ///
8605 /// The two delivery keys are left out for every kind, not only for a task: they are
8606 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8607 /// document carrying one holds nothing a caller's own metadata could mean by it.
8608 fn metadata(&self) -> BTreeMap<String, Value> {
8609 let mut metadata = self.slot.clone();
8610 metadata.remove(Repository::METADATA_KEY);
8611 metadata.remove(DependencyEdge::RECORDED_KEY);
8612 metadata.remove(ItemKind::METADATA_KEY);
8613 metadata.remove(TaskRef::DELIVERS_KEY);
8614 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8615 metadata.remove(Classification::METADATA_KEY);
8616 // The board field is the origin, and the body's copy of it is only a mirror for the
8617 // issue search to find: an item whose field holds none has none, whatever its body
8618 // says, so no reader ever sees two answers.
8619 metadata.remove(ORIGIN_KEY);
8620 if let Some(origin) = &self.origin {
8621 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8622 }
8623 metadata
8624 }
8625
8626 /// Where this item is, as a link a reader can open.
8627 ///
8628 /// A board is a hosted place and every issue on it has a web address, so that address
8629 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8630 /// of place it is, so a reader knows to open it rather than to read a file out. It
8631 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8632 /// reported before, and this says what that address *is*.
8633 ///
8634 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8635 /// rather than a third variant, which is the contract's "the source did not say". An
8636 /// issue this run created is not one of those: its address comes back from the
8637 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8638 /// rather than from whenever the board read catches up.
8639 fn location(&self) -> Option<Location> {
8640 self.url.clone().map(Location::Url)
8641 }
8642
8643 /// The short handle this board's backend shows people for a task: the issue's number
8644 /// alone, as a decimal string.
8645 ///
8646 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8647 /// value for this backend. A draft has no number and so no handle, which is the
8648 /// contract's *absent* rather than a handle of some other shape — and the native
8649 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8650 /// derives from.
8651 fn key(&self) -> Option<String> {
8652 self.number.map(|number| number.to_string())
8653 }
8654
8655 /// Whether its `Priority` field holds a value at all, mapped or not.
8656 fn holds_priority(&self) -> bool {
8657 self.priority != HeldPriority::Read(Priority::None)
8658 }
8659
8660 /// The task this item is.
8661 ///
8662 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8663 /// reading that as a level would be a guess, and reading it as `none` would let the next
8664 /// copy clear a priority a person set.
8665 fn task(&self) -> Result<Task, SourceError> {
8666 let priority = match &self.priority {
8667 HeldPriority::Read(priority) => *priority,
8668 HeldPriority::Unmapped(option) => {
8669 return Err(SourceError::Malformed {
8670 message: format!(
8671 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8672 this source's priority_mapping does not name, so its priority cannot be \
8673 read; next: name {option:?} under priority_mapping, or move the item to \
8674 a mapped option",
8675 self.id,
8676 self.number
8677 .map(|number| format!(" (#{number})"))
8678 .unwrap_or_default()
8679 ),
8680 });
8681 }
8682 };
8683 Ok(Task {
8684 id: self.id.clone(),
8685 key: self.key(),
8686 title: self.title.clone(),
8687 content: self.body.clone(),
8688 status: self.status.clone(),
8689 priority,
8690 labels: self.labels.clone(),
8691 project: self.parent.clone(),
8692 url: self.url.clone(),
8693 location: self.location(),
8694 created_at: self.created_at,
8695 updated_at: self.updated_at,
8696 metadata: self.metadata(),
8697 repositories: self.repositories.clone(),
8698 delivers: self.delivers.clone(),
8699 delivered_by: self.delivered_by.clone(),
8700 classification: self.classification,
8701 })
8702 }
8703
8704 fn project(&self) -> Project {
8705 Project {
8706 id: self.id.clone(),
8707 title: self.title.clone(),
8708 content: self.body.clone(),
8709 status: self.status.clone(),
8710 labels: self.labels.clone(),
8711 url: self.url.clone(),
8712 location: self.location(),
8713 created_at: self.created_at,
8714 updated_at: self.updated_at,
8715 metadata: self.metadata(),
8716 repositories: self.repositories.clone(),
8717 classification: self.classification,
8718 }
8719 }
8720
8721 /// The same issue as a document: the project it is filed under, and no status and no
8722 /// dependencies, because a document is not work.
8723 fn document(&self) -> Document {
8724 Document {
8725 id: self.id.clone(),
8726 title: self.title.clone(),
8727 content: self.body.clone(),
8728 project: self.parent.clone(),
8729 labels: self.labels.clone(),
8730 url: self.url.clone(),
8731 location: self.location(),
8732 created_at: self.created_at,
8733 updated_at: self.updated_at,
8734 metadata: self.metadata(),
8735 repositories: self.repositories.clone(),
8736 classification: self.classification,
8737 }
8738 }
8739}
8740
8741/// Where one targeted update moves an item's status, and which of its two halves move.
8742struct StatusMove {
8743 /// The board the item's `Status` field is on.
8744 board: BoardId,
8745 /// The `Status` field's id.
8746 field: String,
8747 /// The option's id.
8748 option: String,
8749 /// The option's name, as the board spells it.
8750 name: String,
8751 /// What the status asks of the issue's state.
8752 target: StatusTarget,
8753 /// The status the item reads as once it is there.
8754 landed: Status,
8755 /// Which of the status's two halves differ from what the item holds.
8756 moves: Moves,
8757}
8758
8759/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8760/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8761/// and is not a value of this type.
8762#[derive(Clone, Copy, PartialEq, Eq)]
8763enum Moves {
8764 /// The option alone.
8765 Option,
8766 /// The issue's state alone: open, closed, or closed with another reason.
8767 State,
8768 /// Both.
8769 Both,
8770}
8771
8772impl Moves {
8773 /// What differs, or `None` when nothing does.
8774 const fn of(option: bool, state: bool) -> Option<Self> {
8775 match (option, state) {
8776 (true, true) => Some(Self::Both),
8777 (true, false) => Some(Self::Option),
8778 (false, true) => Some(Self::State),
8779 (false, false) => None,
8780 }
8781 }
8782
8783 /// Whether the option moves.
8784 const fn option(self) -> bool {
8785 matches!(self, Self::Option | Self::Both)
8786 }
8787
8788 /// Whether the issue's state moves.
8789 const fn state(self) -> bool {
8790 matches!(self, Self::State | Self::Both)
8791 }
8792}
8793
8794/// What one write is, and the status that comes with being it.
8795///
8796/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8797/// status and a task or a project always has one, so "a document carrying a status" and
8798/// "a task carrying none" are states a write cannot be in rather than states every use
8799/// site below has to defend against.
8800enum Written<'a> {
8801 /// A document, which is not work and so has no status at all.
8802 Document,
8803 /// A task or a project, and the status it is being written with.
8804 Work(ItemKind, &'a Status),
8805}
8806
8807impl Written<'_> {
8808 /// Which of the board's three kinds this write is.
8809 const fn kind(&self) -> BoardKind {
8810 match self {
8811 Self::Document => BoardKind::Document,
8812 Self::Work(kind, _) => BoardKind::Work(*kind),
8813 }
8814 }
8815
8816 /// The status this write carries. A document carries none, so a write of one says
8817 /// nothing about the issue's open or closed state and selects no board `Status`
8818 /// option.
8819 const fn status(&self) -> Option<&Status> {
8820 match self {
8821 Self::Document => None,
8822 Self::Work(_, status) => Some(status),
8823 }
8824 }
8825
8826 /// The status this write carries with the kind whose half of `status_mapping` it is
8827 /// written through.
8828 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8829 match self {
8830 Self::Document => None,
8831 Self::Work(kind, status) => Some((*kind, status)),
8832 }
8833 }
8834}
8835
8836/// The item being written, in the one shape all three write methods reach.
8837struct Incoming<'a> {
8838 written: Written<'a>,
8839 /// The title a person wrote. A document's goes onto the issue with
8840 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8841 title: &'a str,
8842 content: Option<&'a str>,
8843 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
8844 labels: &'a [Label],
8845 metadata: &'a BTreeMap<String, Value>,
8846 repositories: &'a [Repository],
8847 /// Recorded in the slot while private, and nowhere while public.
8848 classification: Classification,
8849 parent: Option<&'a NativeId>,
8850 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8851 /// what keeps either key out of their slot.
8852 delivers: &'a [TaskRef],
8853 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8854 delivered_by: &'a [TaskRef],
8855 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8856 /// project, a document, and every write to an instance with no `priority_mapping` —
8857 /// which is what keeps such a write's requests exactly what they were before.
8858 priority: Option<Priority>,
8859}
8860
8861/// What one write does to an item's `Priority` field.
8862enum PriorityWrite {
8863 /// Select this option of this field.
8864 Select {
8865 /// The `Priority` field's id.
8866 field: String,
8867 /// The mapped option's id.
8868 option: String,
8869 },
8870 /// Clear the field's value, which is what `none` is.
8871 Clear {
8872 /// The `Priority` field's id.
8873 field: String,
8874 },
8875}
8876
8877impl Incoming<'_> {
8878 /// The title this write puts on the issue.
8879 fn written_title(&self) -> String {
8880 match self.written {
8881 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8882 Written::Work(..) => self.title.to_owned(),
8883 }
8884 }
8885}
8886
8887#[derive(Clone, Copy, PartialEq, Eq)]
8888enum ContentKind {
8889 DraftIssue,
8890 Issue,
8891}
8892
8893/// What one board issue is: a document, or the work an [`ItemKind`] names.
8894///
8895/// A type of this source's own rather than an `ItemKind` with a third variant, because
8896/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8897/// document — the contract keeps a document out of that enum deliberately. Holding the
8898/// board's three answers in one value is what makes every place that asks "which is this?"
8899/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8900/// two thirds of the board.
8901#[derive(Clone, Copy, PartialEq, Eq)]
8902enum BoardKind {
8903 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8904 Document,
8905 /// Every other issue, and every draft.
8906 Work(ItemKind),
8907}
8908
8909impl BoardKind {
8910 /// Whose half of `status_mapping` an item of this kind reads its status through. A
8911 /// document has no status of its own, so the task half stands in for whatever the issue
8912 /// holds; nothing reports it.
8913 const fn status_kind(self) -> ItemKind {
8914 match self {
8915 Self::Document => ItemKind::Task,
8916 Self::Work(kind) => kind,
8917 }
8918 }
8919
8920 /// How a refusal names this kind to the person reading it.
8921 const fn describes(self) -> &'static str {
8922 match self {
8923 Self::Document => "document",
8924 Self::Work(kind) => kind.marker(),
8925 }
8926 }
8927}
8928
8929/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8930///
8931/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8932/// the shared cross-source journeys assert one answer to one question, so two sources
8933/// that disagree about what "carries the label bug" means fail them.
8934fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8935 let holds = |name: &String| {
8936 labels
8937 .iter()
8938 .any(|label| label.name.eq_ignore_ascii_case(name))
8939 };
8940 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8941 && filter.all_of.iter().all(holds)
8942 && !filter.none_of.iter().any(holds)
8943}
8944
8945/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8946/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8947fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8948 statuses.is_empty() || statuses.contains(&category)
8949}
8950
8951/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8952///
8953/// `content` is the item's own prose — the body with this source's trailing metadata
8954/// comment already taken off — so a search never matches an encoding the author of the
8955/// issue never wrote.
8956fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8957 let terms = query.terms.to_lowercase();
8958 let in_title = title.to_lowercase().contains(&terms);
8959 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8960 match query.fields {
8961 TextFields::Title => in_title,
8962 TextFields::Content => in_content,
8963 TextFields::TitleOrContent => in_title || in_content,
8964 }
8965}
8966
8967/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8968///
8969/// The project predicate is passed separately because a read narrowed to one project has
8970/// already answered it by asking *that project* for its own items — and re-applying it
8971/// there would compare the caller's selector, which may be a project's **name**, against
8972/// the id of the project that name resolved to, and keep nothing. Every other read passes
8973/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8974/// source really does apply.
8975fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8976 labels_match(&task.labels, &query.labels)
8977 && status_matches(task.status.category, &query.statuses)
8978 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8979 && match project {
8980 ProjectFilter::Any => true,
8981 ProjectFilter::Orphans => task.project.is_none(),
8982 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8983 }
8984 && query
8985 .text
8986 .as_ref()
8987 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8988 // Against the parsed metadata slot, and against the origin field, which is where
8989 // `Resolved::metadata` reads each of them from.
8990 && query.metadata_matches(&task.metadata)
8991 && query.origin_matches(&task.metadata)
8992}
8993
8994fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8995 labels_match(&project.labels, &query.labels)
8996 && status_matches(project.status.category, &query.statuses)
8997 && query
8998 .text
8999 .as_ref()
9000 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
9001}
9002
9003/// The same three predicates a task query carries, minus the status filter.
9004///
9005/// A document is not work, so it has no status for one to compare against and the query
9006/// type carries none. The project predicate is the same one — a design issue filed under a
9007/// project issue is in that project, and one filed under nothing is in none — so it is
9008/// spelled the same way here rather than answered differently.
9009fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
9010 labels_match(&document.labels, &query.labels)
9011 && match project {
9012 ProjectFilter::Any => true,
9013 ProjectFilter::Orphans => document.project.is_none(),
9014 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
9015 }
9016 && query
9017 .text
9018 .as_ref()
9019 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
9020}
9021
9022#[async_trait::async_trait]
9023impl TaskSource for GitHubProjectsSource {
9024 fn kind(&self) -> &'static str {
9025 KIND
9026 }
9027 fn capabilities(&self) -> Capabilities {
9028 Capabilities {
9029 projects: Support::Native,
9030 documents: Support::Native,
9031 comments: Support::Native,
9032 assets: Support::Native,
9033 priority: if self.priorities.is_some() {
9034 Support::Native
9035 } else {
9036 Support::Unsupported
9037 },
9038 filter_by_priority: Support::Native,
9039 filter_by_comment_activity: Support::Native,
9040 filter_by_metadata: Support::Native,
9041 filter_by_origin: Support::Native,
9042 orphan_tasks: Support::Native,
9043 filter_by_label: Support::Native,
9044 filter_by_status: Support::Native,
9045 search_title: Support::Native,
9046 search_content: Support::Native,
9047 task_dependencies: DependencySupport::BothDirections,
9048 project_dependencies: DependencySupport::BothDirections,
9049 max_page_size: MAX_PAGE_SIZE,
9050 }
9051 }
9052 async fn visibility(
9053 &self,
9054 target: &onetaskgraph_plugin_api::WriteTarget<'_>,
9055 ) -> Result<onetaskgraph_plugin_api::Visibility, SourceError> {
9056 self.write_visibility(target).await
9057 }
9058 async fn health(&self) -> Result<Health, SourceError> {
9059 let board = self.board_page(None, 1).await?;
9060 Ok(Health {
9061 reachable: true,
9062 detail: Some(format!(
9063 "reading GitHub project {}/{} ({})",
9064 self.owner,
9065 self.project_number,
9066 required_str(&board, "title")?
9067 )),
9068 })
9069 }
9070 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
9071 self.item_by_id(id)
9072 .await?
9073 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9074 .map(|item| item.task())
9075 .transpose()
9076 }
9077 async fn task_assets(
9078 &self,
9079 id: &NativeId,
9080 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
9081 self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
9082 }
9083 async fn task_asset(
9084 &self,
9085 id: &NativeId,
9086 name: &onetaskgraph_plugin_api::AssetName,
9087 ) -> Result<Option<Vec<u8>>, SourceError> {
9088 self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
9089 .await
9090 }
9091 async fn set_task_rendering_with_assets(
9092 &self,
9093 id: &NativeId,
9094 content: &str,
9095 provenance: &Value,
9096 _answers: &BTreeMap<String, Value>,
9097 assets: &onetaskgraph_plugin_api::AssetWrite,
9098 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9099 self.replace_rendering(
9100 id,
9101 BoardKind::Work(ItemKind::Task),
9102 content,
9103 provenance,
9104 Some(assets),
9105 )
9106 .await
9107 }
9108 async fn document_assets(
9109 &self,
9110 id: &NativeId,
9111 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
9112 self.held_assets(id, BoardKind::Document).await
9113 }
9114 async fn document_asset(
9115 &self,
9116 id: &NativeId,
9117 name: &onetaskgraph_plugin_api::AssetName,
9118 ) -> Result<Option<Vec<u8>>, SourceError> {
9119 self.held_asset(id, BoardKind::Document, name).await
9120 }
9121 async fn set_document_rendering_with_assets(
9122 &self,
9123 id: &NativeId,
9124 content: &str,
9125 provenance: &Value,
9126 _answers: &BTreeMap<String, Value>,
9127 assets: &onetaskgraph_plugin_api::AssetWrite,
9128 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9129 self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
9130 .await
9131 }
9132 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
9133 Ok(self
9134 .item_by_id(id)
9135 .await?
9136 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9137 .map(|item| item.project()))
9138 }
9139 async fn query_tasks(
9140 &self,
9141 query: &TaskQuery,
9142 page: &PageRequest,
9143 ) -> Result<Page<Task>, SourceError> {
9144 validate_page(page)?;
9145 refuse_unsearchable(query)?;
9146 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
9147 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
9148 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
9149 (Some(also), None) => Some(also),
9150 (None, Some(since)) => Some(updated_qualifier(since)),
9151 (None, None) => None,
9152 };
9153 if let Some(also) = qualifiers {
9154 return self.search_tasks(query, page, &also).await;
9155 }
9156 }
9157
9158 // A read narrowed to one project asks that project for its own tasks, so nothing
9159 // about it costs what the rest of the board holds. A read carrying a text, metadata
9160 // or origin predicate asks GitHub the narrower question those predicates are, and a
9161 // read narrowed to comment activity alone asks the board's own issue search for the
9162 // issues updated since, which is every issue a comment could have been written or
9163 // edited on since. Every other task read is a question about the whole board and is
9164 // answered by reading it.
9165 let (held, membership) = match (&query.project, query.commented_since) {
9166 (ProjectFilter::Is(project), _) => (
9167 self.project_children(project).await?,
9168 // Answered by where these items came from; see `task_matches`.
9169 &ProjectFilter::Any,
9170 ),
9171 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
9172 match (self.narrowed(query).await?, since) {
9173 (Some(narrowed), _) => (narrowed, &query.project),
9174 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
9175 (None, None) => (self.board().await?.items, &query.project),
9176 }
9177 }
9178 };
9179 // Filtered before paged: a page of a filtered result is a page of the survivors,
9180 // never the survivors of a page.
9181 let mut tasks = Vec::new();
9182 for item in held
9183 .iter()
9184 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9185 {
9186 let task = item.task()?;
9187 if task_matches(&task, query, membership)
9188 && self.commented_since(item, query.commented_since).await?
9189 {
9190 tasks.push(task);
9191 }
9192 }
9193 Ok(offset_page(
9194 tasks,
9195 numeric_cursor(page.cursor.as_ref())?,
9196 page.limit.min(MAX_PAGE_SIZE) as usize,
9197 ))
9198 }
9199 async fn query_projects(
9200 &self,
9201 query: &ProjectQuery,
9202 page: &PageRequest,
9203 ) -> Result<Page<Project>, SourceError> {
9204 validate_page(page)?;
9205 refuse_unsearchable_text(query.text.as_ref())?;
9206 // The projects a board holds are found by an issue search scoped to that board,
9207 // never by walking the board's own item connection: what tells a project from a
9208 // task is the `parent` each issue carries, which costs nothing to read. A query
9209 // carrying a text asks that search for the text too, so it reads the issues that
9210 // hold it rather than every issue of the board.
9211 let held = match self.text_searched(query.text.as_ref()).await? {
9212 Some(searched) => searched,
9213 None => self.board_issues().await?,
9214 };
9215 let projects = held
9216 .iter()
9217 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9218 .map(Resolved::project)
9219 .filter(|project| project_matches(project, query))
9220 .collect();
9221 Ok(offset_page(
9222 projects,
9223 numeric_cursor(page.cursor.as_ref())?,
9224 page.limit.min(MAX_PAGE_SIZE) as usize,
9225 ))
9226 }
9227 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
9228 Ok(self
9229 .item_by_id(id)
9230 .await?
9231 .filter(|item| item.kind == BoardKind::Document)
9232 .map(|item| item.document()))
9233 }
9234 async fn query_documents(
9235 &self,
9236 query: &DocumentQuery,
9237 page: &PageRequest,
9238 ) -> Result<Page<Document>, SourceError> {
9239 validate_page(page)?;
9240 // Narrowed to one project, this is the same sub-issue read a task list scoped to
9241 // that project makes — a document filed under a project is a sub-issue of it too,
9242 // and which of them come back is the kind this caller asked for. Unscoped, a query
9243 // carrying a text asks the board-scoped issue search for it, as a task query does,
9244 // and only one carrying none reads the board.
9245 let (held, membership) = match &query.project {
9246 ProjectFilter::Is(project) => (
9247 self.project_children(project).await?,
9248 // Answered by where these items came from; see `task_matches`.
9249 &ProjectFilter::Any,
9250 ),
9251 ProjectFilter::Any | ProjectFilter::Orphans => {
9252 refuse_unsearchable_text(query.text.as_ref())?;
9253 match self.text_searched(query.text.as_ref()).await? {
9254 Some(searched) => (searched, &query.project),
9255 None => (self.board().await?.items, &query.project),
9256 }
9257 }
9258 };
9259 // Filtered before paged, exactly as a task read is: a page of a filtered result is
9260 // a page of the survivors, never the survivors of a page.
9261 let documents = held
9262 .iter()
9263 .filter(|item| item.kind == BoardKind::Document)
9264 .map(Resolved::document)
9265 .filter(|document| document_matches(document, query, membership))
9266 .collect();
9267 Ok(offset_page(
9268 documents,
9269 numeric_cursor(page.cursor.as_ref())?,
9270 page.limit.min(MAX_PAGE_SIZE) as usize,
9271 ))
9272 }
9273 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
9274 validate_page(page)?;
9275 let offset = numeric_cursor(page.cursor.as_ref())?;
9276 let mut labels = self
9277 .board()
9278 .await?
9279 .items
9280 .into_iter()
9281 .flat_map(|item| item.labels)
9282 .fold(Vec::new(), |mut all, label| {
9283 if !all.iter().any(|x: &Label| x.id == label.id) {
9284 all.push(label);
9285 }
9286 all
9287 });
9288 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
9289 Ok(offset_page(
9290 labels,
9291 offset,
9292 page.limit.min(MAX_PAGE_SIZE) as usize,
9293 ))
9294 }
9295 async fn task_dependencies(
9296 &self,
9297 id: &NativeId,
9298 direction: Direction,
9299 page: &PageRequest,
9300 ) -> Result<Page<DependencyEdge>, SourceError> {
9301 self.dependencies(id, ItemKind::Task, direction, page).await
9302 }
9303 async fn project_dependencies(
9304 &self,
9305 id: &NativeId,
9306 direction: Direction,
9307 page: &PageRequest,
9308 ) -> Result<Page<DependencyEdge>, SourceError> {
9309 self.dependencies(id, ItemKind::Project, direction, page)
9310 .await
9311 }
9312
9313 fn writes(&self) -> WriteSupport {
9314 WriteSupport::Supported
9315 }
9316
9317 /// Create or update one task.
9318 ///
9319 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9320 /// neither may name the task itself or name one task twice — and land in the body's
9321 /// metadata slot under their reserved keys, in place of any caller metadata of those
9322 /// names.
9323 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9324 self.write_task_assets(write, None)
9325 .await
9326 .map(|written| written.id)
9327 }
9328
9329 async fn write_task_with_assets(
9330 &self,
9331 write: &ItemWrite<Task>,
9332 _answers: Option<&BTreeMap<String, Value>>,
9333 assets: &onetaskgraph_plugin_api::AssetWrite,
9334 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9335 self.write_task_assets(write, Some(assets)).await
9336 }
9337
9338 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9339 self.write_item(
9340 &Incoming {
9341 written: Written::Work(ItemKind::Project, &write.item.status),
9342 title: &write.item.title,
9343 content: write.item.content.as_deref(),
9344 assets: None,
9345 labels: &write.item.labels,
9346 metadata: &write.item.metadata,
9347 repositories: &write.item.repositories,
9348 classification: write.item.classification,
9349 parent: None,
9350 delivers: &[],
9351 delivered_by: &[],
9352 priority: None,
9353 },
9354 write.target.as_ref(),
9355 &write.depends_on,
9356 )
9357 .await
9358 .map(|written| written.id)
9359 }
9360
9361 /// Create or update one document, which is one issue titled the way this board spells
9362 /// a document.
9363 ///
9364 /// Everything else is exactly a task write: caller metadata goes to the same canonical
9365 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9366 /// or a field this board cannot carry is refused by name rather than dropped, a target
9367 /// naming an issue this board does not hold is refused rather than created, and an
9368 /// issue this call created is taken back when the rest of the write fails.
9369 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9370 self.write_document_assets(write, None)
9371 .await
9372 .map(|written| written.id)
9373 }
9374
9375 async fn write_document_with_assets(
9376 &self,
9377 write: &ItemWrite<Document>,
9378 _answers: Option<&BTreeMap<String, Value>>,
9379 assets: &onetaskgraph_plugin_api::AssetWrite,
9380 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9381 self.write_document_assets(write, Some(assets)).await
9382 }
9383
9384 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9385 /// which reads nothing; then the board's `Status` option. Over an existing item that is
9386 /// read off the item, as the write reads it, and the item is held among this command's
9387 /// resolved records so the write that follows reuses that read rather than repeating it;
9388 /// an item that does not carry the field takes the board's fields, which are held once
9389 /// read. A create is checked against the board's fields only when this command already
9390 /// holds them, because a create reads them together with its repository, in one request,
9391 /// and refuses a missing option before it writes anything.
9392 async fn check_status_write(
9393 &self,
9394 kind: ItemKind,
9395 category: StatusCategory,
9396 target: Option<&NativeId>,
9397 ) -> Result<(), SourceError> {
9398 let status = self.resolved_target(kind, category)?;
9399 if status.option().is_none() {
9400 return Ok(());
9401 }
9402 let fields = match target {
9403 Some(target) => {
9404 // A target this board does not hold is the write's own refusal to make.
9405 let Some(item) = self.bound_item(target).await? else {
9406 return Ok(());
9407 };
9408 self.resolved_cache()?.insert(target.clone(), item.clone());
9409 self.fields_for(Some(&item), true, false).await?.fields
9410 }
9411 None => {
9412 let held = self
9413 .board_cache()?
9414 .as_ref()
9415 .map(|board| board.fields.clone());
9416 match held.or_else(|| {
9417 self.fields_cache()
9418 .ok()
9419 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9420 }) {
9421 Some(fields) => fields,
9422 None => return Ok(()),
9423 }
9424 }
9425 };
9426 self.column_for(&fields, kind, category, &status)
9427 .map(|_| ())
9428 }
9429
9430 /// Set one task's status alone.
9431 ///
9432 /// An open target reopens a closed issue with an `updateIssue` carrying only its
9433 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9434 /// terminal target selects its mapped option, then closes with its fixed reason. No
9435 /// request carries a title, a body or a label. The status
9436 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9437 /// what a re-read reports.
9438 async fn set_task_status(
9439 &self,
9440 id: &NativeId,
9441 category: StatusCategory,
9442 ) -> Result<Option<Status>, SourceError> {
9443 self.set_status(id, category).await
9444 }
9445
9446 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9447 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9448 /// for `none`. Refused by an instance with no `priority_mapping`.
9449 async fn set_task_priority(
9450 &self,
9451 id: &NativeId,
9452 priority: Priority,
9453 ) -> Result<Option<Priority>, SourceError> {
9454 self.set_priority(id, priority).await
9455 }
9456
9457 /// Replace one task's content with a single body update that keeps the metadata slot
9458 /// byte for byte.
9459 async fn set_task_content(
9460 &self,
9461 id: &NativeId,
9462 content: &str,
9463 ) -> Result<Option<()>, SourceError> {
9464 self.replace_content(id, content).await
9465 }
9466
9467 /// Replace one task issue's content and its provenance slot entry with a single body
9468 /// update. The answers are not kept: see `replace_rendering`.
9469 async fn set_task_rendering(
9470 &self,
9471 id: &NativeId,
9472 content: &str,
9473 provenance: &Value,
9474 _answers: &BTreeMap<String, Value>,
9475 ) -> Result<Option<()>, SourceError> {
9476 self.replace_rendering(
9477 id,
9478 BoardKind::Work(ItemKind::Task),
9479 content,
9480 provenance,
9481 None,
9482 )
9483 .await
9484 .map(|written| written.map(|_| ()))
9485 }
9486
9487 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9488 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9489 async fn set_document_rendering(
9490 &self,
9491 id: &NativeId,
9492 content: &str,
9493 provenance: &Value,
9494 _answers: &BTreeMap<String, Value>,
9495 ) -> Result<Option<()>, SourceError> {
9496 self.replace_rendering(id, BoardKind::Document, content, provenance, None)
9497 .await
9498 .map(|written| written.map(|_| ()))
9499 }
9500
9501 /// Replace one project issue's content and its provenance slot entry, on exactly the
9502 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9503 async fn set_project_rendering(
9504 &self,
9505 id: &NativeId,
9506 content: &str,
9507 provenance: &Value,
9508 _answers: &BTreeMap<String, Value>,
9509 ) -> Result<Option<()>, SourceError> {
9510 self.replace_rendering(
9511 id,
9512 BoardKind::Work(ItemKind::Project),
9513 content,
9514 provenance,
9515 None,
9516 )
9517 .await
9518 .map(|written| written.map(|_| ()))
9519 }
9520
9521 /// Apply a targeted update with one read of the item and a write only for what differs:
9522 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9523 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9524 async fn update_task(
9525 &self,
9526 id: &NativeId,
9527 update: &TaskUpdate,
9528 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9529 self.targeted_update(id, update).await
9530 }
9531
9532 /// Replace one task's `delivered_by` with a single body update that changes the
9533 /// metadata slot and nothing outside it.
9534 async fn set_delivered_by(
9535 &self,
9536 id: &NativeId,
9537 delivered_by: &[TaskRef],
9538 ) -> Result<Option<()>, SourceError> {
9539 self.replace_delivered_by(id, delivered_by).await
9540 }
9541
9542 /// Set one key of one task issue's metadata with a single body update that changes the
9543 /// metadata slot and nothing outside it — no title, label, state or board field request —
9544 /// and sends nothing when the task already holds that value under the key.
9545 async fn set_task_metadata(
9546 &self,
9547 id: &NativeId,
9548 key: &MetadataKey,
9549 value: &Value,
9550 ) -> Result<Option<Task>, SourceError> {
9551 Ok(self
9552 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9553 .await?
9554 .map(|item| item.task())
9555 .transpose()?)
9556 }
9557
9558 /// Set one key of one project issue's metadata, on exactly the terms of
9559 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9560 async fn set_project_metadata(
9561 &self,
9562 id: &NativeId,
9563 key: &MetadataKey,
9564 value: &Value,
9565 ) -> Result<Option<Project>, SourceError> {
9566 Ok(self
9567 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9568 .await?
9569 .map(|item| item.project()))
9570 }
9571
9572 /// Set one key of one design-document issue's metadata, on exactly the terms of
9573 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9574 async fn set_document_metadata(
9575 &self,
9576 id: &NativeId,
9577 key: &MetadataKey,
9578 value: &Value,
9579 ) -> Result<Option<Document>, SourceError> {
9580 Ok(self
9581 .set_slot_key(id, BoardKind::Document, key, value)
9582 .await?
9583 .map(|item| item.document()))
9584 }
9585
9586 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9587 self.delete_item(id).await
9588 }
9589
9590 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9591 self.delete_item(id).await
9592 }
9593
9594 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9595 self.delete_item(id).await
9596 }
9597
9598 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9599 ///
9600 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9601 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9602 ///
9603 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9604 /// board is the read of its comments. A draft this process already resolved is refused
9605 /// without one.
9606 async fn task_comments(
9607 &self,
9608 task: &NativeId,
9609 page: &PageRequest,
9610 ) -> Result<Option<Page<Comment>>, SourceError> {
9611 validate_page(page)?;
9612 let cached = self.resolved_cache()?.get(task).cloned();
9613 if let Some(item) = cached {
9614 if item.kind != BoardKind::Work(ItemKind::Task) {
9615 return Ok(None);
9616 }
9617 if item.content_kind == ContentKind::DraftIssue {
9618 return Err(self.draft_has_no_comments(task));
9619 }
9620 }
9621 match self.issue_detail(task, page).await? {
9622 Some(TaskDetailRead {
9623 comments: Some(comments),
9624 ..
9625 }) => comments,
9626 _ => Ok(None),
9627 }
9628 }
9629
9630 /// Every id's task, with the first page of its comments when `comments` names it:
9631 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9632 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9633 async fn get_task_details(
9634 &self,
9635 ids: &[NativeId],
9636 comments: Option<&PageRequest>,
9637 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9638 if let Some(page) = comments
9639 && let Err(error) = validate_page(page)
9640 {
9641 return ids.iter().map(|_| Err(error.clone())).collect();
9642 }
9643 match (ids, comments) {
9644 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9645 ([id], None) => vec![self.task_read(id).await],
9646 _ => self.issue_details(ids, comments).await,
9647 }
9648 }
9649
9650 /// Add one comment to the task's issue, as the account the token belongs to.
9651 ///
9652 /// The author is refused before anything is sent — not even the task is read — because
9653 /// no answer GitHub could give would make posting under another name than the one asked
9654 /// for the right outcome.
9655 async fn add_comment(
9656 &self,
9657 task: &NativeId,
9658 comment: &NewComment,
9659 ) -> Result<Option<Comment>, SourceError> {
9660 if let Some(author) = &comment.author {
9661 return Err(SourceError::Refused {
9662 message: format!(
9663 "source {} cannot post a comment as {author:?}: GitHub records the account \
9664 the token signs in as the author of every comment; next: leave --author \
9665 out, and the comment is posted as that account",
9666 self.name
9667 ),
9668 });
9669 }
9670 let Some(issue) = self.commented_issue(task).await? else {
9671 return Ok(None);
9672 };
9673 let data = self
9674 .graphql(
9675 graphql::ADD_COMMENT,
9676 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9677 )
9678 .await?;
9679 let subject = data
9680 .pointer("/addComment/subject")
9681 .filter(|value| !value.is_null())
9682 .ok_or_else(|| SourceError::Malformed {
9683 message: "GitHub comment addition returned no subject".into(),
9684 })?;
9685 if required_str(subject, "id")? != issue.0 {
9686 return Err(SourceError::Malformed {
9687 message: "GitHub comment addition answered about another issue".into(),
9688 });
9689 }
9690 let added = data
9691 .pointer("/addComment/commentEdge/node")
9692 .filter(|value| !value.is_null())
9693 .ok_or_else(|| SourceError::Malformed {
9694 message: "GitHub comment addition returned no comment".into(),
9695 })?;
9696 let added = comment_from(added)?;
9697 self.remember_commented(&issue)?;
9698 Ok(Some(added))
9699 }
9700
9701 async fn edit_comment(
9702 &self,
9703 task: &NativeId,
9704 comment: &NativeId,
9705 body: &CommentBody,
9706 ) -> Result<Option<Comment>, SourceError> {
9707 let Some(issue) = self.commented_issue(task).await? else {
9708 return Ok(None);
9709 };
9710 if !self.comment_is_on(&issue, comment).await? {
9711 return Ok(None);
9712 }
9713 let data = self
9714 .graphql(
9715 graphql::UPDATE_COMMENT,
9716 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9717 )
9718 .await?;
9719 let edited = data
9720 .pointer("/updateIssueComment/issueComment")
9721 .filter(|value| !value.is_null())
9722 .ok_or_else(|| SourceError::Malformed {
9723 message: "GitHub comment update returned no comment".into(),
9724 })?;
9725 let edited = comment_from(edited)?;
9726 if edited.id != *comment {
9727 return Err(SourceError::Malformed {
9728 message: "GitHub comment update returned the wrong comment".into(),
9729 });
9730 }
9731 self.remember_commented(&issue)?;
9732 Ok(Some(edited))
9733 }
9734
9735 async fn delete_comment(
9736 &self,
9737 task: &NativeId,
9738 comment: &NativeId,
9739 ) -> Result<Option<NativeId>, SourceError> {
9740 let Some(issue) = self.commented_issue(task).await? else {
9741 return Ok(None);
9742 };
9743 if !self.comment_is_on(&issue, comment).await? {
9744 return Ok(None);
9745 }
9746 let data = self
9747 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9748 .await?;
9749 // The payload says nothing about the comment it removed, so what is checked is that
9750 // GitHub answered the mutation at all rather than leaving it unanswered.
9751 data.get("deleteIssueComment")
9752 .filter(|value| !value.is_null())
9753 .ok_or_else(|| SourceError::Malformed {
9754 message: "GitHub comment deletion returned no payload".into(),
9755 })?;
9756 Ok(Some(comment.clone()))
9757 }
9758
9759 /// Every request this source has recorded, and what each of GitHub's two budgets was
9760 /// attributed — read off the same accounting the session report is rendered from, so
9761 /// the two cannot count one request two ways.
9762 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9763 Ok(Some(self.ledger.snapshot().metering()))
9764 }
9765
9766 /// Drop every item, search answer and board read this source holds, so the next command
9767 /// reads the board as a person has since left it.
9768 ///
9769 /// Every one of those is held on the assumption that nothing but this source writes the
9770 /// board while a command runs, which stops being true the moment the command is over: a
9771 /// body a person edited would be overwritten from the record held here, and a card they
9772 /// moved would be read as still where this source left it. The board's own field
9773 /// definitions go too, because a person can add or delete a `Status` option and a write
9774 /// resolved against the held list would not re-read on a miss. What stays is what stays
9775 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9776 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9777 /// accounting [`metering`](TaskSource::metering) answers from.
9778 ///
9779 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9780 /// refused, because clearing it is what puts it right.
9781 async fn end_command(&self) -> Result<(), SourceError> {
9782 fn clear<T: Default>(held: &Mutex<T>) {
9783 *held
9784 .lock()
9785 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9786 held.clear_poison();
9787 }
9788 clear(&self.created);
9789 clear(&self.updated);
9790 clear(&self.commented);
9791 clear(&self.board_cache);
9792 clear(&self.search_cache);
9793 clear(&self.narrowed_cache);
9794 clear(&self.search_next);
9795 clear(&self.resolved_cache);
9796 clear(&self.children_cache);
9797 clear(&self.fields_cache);
9798 Ok(())
9799 }
9800}
9801
9802/// Each project's sub-issues as one command read them, keyed by the selector they were asked
9803/// for under, beside the project that selector named; see `children_cache`.
9804type ProjectChildren = BTreeMap<NativeId, (NativeId, Vec<Resolved>)>;
9805
9806/// One issue comment as the contract carries it.
9807///
9808/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9809/// and when it answers an actor with no login, because either way the source did not say who
9810/// wrote it — which is what an absent author means, rather than an author called nothing.
9811fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9812 Ok(Comment {
9813 id: NativeId(required_str(value, "id")?.to_owned()),
9814 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9815 .map(str::to_owned),
9816 created_at: optional_time(value, "createdAt")?,
9817 updated_at: optional_time(value, "updatedAt")?,
9818 body: required_str(value, "body")?.to_owned(),
9819 url: optional_str(value, "url")?.map(str::to_owned),
9820 })
9821}
9822
9823/// The page of comments one issue node carries, resumed from `after`.
9824fn comment_page(
9825 node: &Value,
9826 issue: &str,
9827 after: Option<&str>,
9828) -> Result<Page<Comment>, SourceError> {
9829 let connection = node
9830 .get("comments")
9831 .filter(|value| !value.is_null())
9832 .ok_or_else(|| SourceError::Malformed {
9833 message: format!("GitHub issue {issue} answered with no comments connection"),
9834 })?;
9835 let items = optional_nodes(Some(connection), "issue comments")?
9836 .into_iter()
9837 .flatten()
9838 .map(comment_from)
9839 .collect::<Result<Vec<_>, _>>()?;
9840 let next = next_cursor(connection)?;
9841 if let Some(next) = &next {
9842 validate_cursor_progress(after, &next.0)?;
9843 }
9844 Ok(Page { items, next })
9845}
9846
9847/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9848/// end — `None` when it carried none, or a page with more past it.
9849fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9850 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9851 return Ok(None);
9852 };
9853 if next_cursor(connection)?.is_some() {
9854 return Ok(None);
9855 }
9856 Ok(Some(
9857 optional_nodes(Some(connection), "blocked-by issues")?
9858 .into_iter()
9859 .flatten()
9860 .cloned()
9861 .collect(),
9862 ))
9863}
9864
9865/// Where the recorded tail of a dependency walk resumes; see
9866/// [`GitHubProjectsSource::recorded_edges`].
9867const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9868
9869/// The board text field this source keeps a copy's origin in.
9870///
9871/// Named after the key it holds, and held to that name by the guard below rather than by
9872/// a reader noticing.
9873const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9874
9875/// The metadata key that field holds.
9876///
9877/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9878/// constructs or interprets the qualified id it carries. This source names it only to
9879/// route it — a short, typed value belongs in a typed field rather than in the body slot
9880/// a caller's own prose shares.
9881///
9882/// Restated rather than imported, because no plugin crate may depend on the engine. What
9883/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9884/// target in `check`: it reads the engine's own literal and fails naming the file and the
9885/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9886/// that creates a second item every run instead of finding the one it wrote — and that is
9887/// too late to learn it.
9888const ORIGIN_KEY: &str = "onetaskgraph.origin";
9889
9890/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9891///
9892/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9893/// is derived from the far end, never written down on the near item — so only a forward
9894/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9895/// it did not come from, and it is told so rather than answered with an empty page that
9896/// reads as a walk which ended.
9897fn recorded_offset(
9898 cursor: Option<&str>,
9899 direction: Direction,
9900) -> Result<Option<usize>, SourceError> {
9901 cursor
9902 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9903 .map(|offset| {
9904 if direction != Direction::DependsOn {
9905 return Err(SourceError::Config {
9906 message: format!(
9907 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9908 reverse dependency read never issues; resume it in the direction \
9909 that reported it"
9910 ),
9911 });
9912 }
9913 offset.parse().map_err(|_| SourceError::Config {
9914 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9915 })
9916 })
9917 .transpose()
9918}
9919
9920fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9921 let mut page = offset_page(edges, offset, limit.max(1));
9922 page.next = page
9923 .next
9924 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9925 page
9926}
9927
9928/// The kind of one issue reached through a dependency connection.
9929///
9930/// The same questions the board scan asks, over the fields the dependency document
9931/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9932/// then anything with sub-issues or the marker is a project.
9933///
9934/// # Errors
9935///
9936/// A far end this board holds as a document is refused rather than reported. The two
9937/// answers that are not refusals would both be wrong: reporting it as a task names an id
9938/// no task read of this source can find, and reporting it as a project names one no
9939/// project read can. There is no third value to return — `ItemKind` has no document
9940/// variant, because nothing may point at a document — so the relationship itself is what
9941/// the person is told about.
9942fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9943 let id = required_str(value, "id")?;
9944 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9945 return Err(SourceError::Refused {
9946 message: format!(
9947 "GitHub issue {id} is a document of this board — its title begins \
9948 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9949 on by one; next: remove that issue's blocking relationship on this board"
9950 ),
9951 });
9952 }
9953 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9954 if parent.is_some() {
9955 return Ok(ItemKind::Task);
9956 }
9957 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9958 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9959 message: format!("GitHub issue {id}: {message}"),
9960 })?;
9961 let sub_issues = sub_issue_total(value)?;
9962 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9963 ItemKind::Project
9964 } else {
9965 ItemKind::Task
9966 })
9967}
9968
9969/// The `IssueStateUpdateInput` one status target asks for.
9970///
9971/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9972/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9973/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9974/// would report a change forever. A document has no status at all, and asks for neither.
9975fn state_input(target: Option<&StatusTarget>) -> Value {
9976 match target {
9977 Some(StatusTarget::Terminal(_, reason)) => {
9978 json!({"value":"CLOSED","stateReason":reason.reason()})
9979 }
9980 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9981 // A document has no status, so a write of one says nothing about the issue's open
9982 // or closed state rather than forcing it open: `stateInput` is what carries that
9983 // instruction, and an explicit null asks for no change to it.
9984 None => Value::Null,
9985 }
9986}
9987
9988/// The metadata one write stores in the item's body slot.
9989///
9990/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9991/// rather than carried: the kind marker so an empty project stays readable, the
9992/// repository list only when it is not exactly the issue's own repository, and the far
9993/// ends no relationship here can name.
9994///
9995/// The copy origin is the one typed field that is also mirrored here, and only as a
9996/// mirror: it lands in the board's origin field as well, which stays the one every reader
9997/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9998/// and catches up with a write in seconds rather than minutes — can find the item by it.
9999/// A reader of the release before this one drops the slot's copy and reads the field, so an
10000/// item written here still reads with exactly one origin there.
10001fn slot_metadata(
10002 incoming: &Incoming<'_>,
10003 own_repository: Option<&Repository>,
10004 fallback: &[DependencyEdge],
10005) -> BTreeMap<String, Value> {
10006 let mut metadata = incoming.metadata.clone();
10007 match metadata.remove(ORIGIN_KEY) {
10008 Some(Value::String(origin)) if !origin.is_empty() => {
10009 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
10010 }
10011 _ => {}
10012 }
10013 match incoming.written.kind() {
10014 BoardKind::Work(kind) => metadata.insert(
10015 ItemKind::METADATA_KEY.to_owned(),
10016 Value::String(kind.marker().to_owned()),
10017 ),
10018 // A document is told by its title, so it carries no kind marker: that key names
10019 // what a dependency endpoint points at, and nothing may point at a document.
10020 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
10021 };
10022 incoming.classification.record(&mut metadata);
10023 let derivable = own_repository
10024 .map(|own| incoming.repositories == [own.clone()])
10025 .unwrap_or(incoming.repositories.is_empty());
10026 if derivable {
10027 metadata.remove(Repository::METADATA_KEY);
10028 } else {
10029 metadata.insert(
10030 Repository::METADATA_KEY.to_owned(),
10031 Value::Array(
10032 incoming
10033 .repositories
10034 .iter()
10035 .map(|repository| Value::String(repository.as_str().to_owned()))
10036 .collect(),
10037 ),
10038 );
10039 }
10040 // The typed lists are what land, whatever the caller's own metadata held under their
10041 // keys: a key of either name travelling beside the field would otherwise be a second
10042 // answer to the same question, and the field is the one the contract names.
10043 for (key, entries) in [
10044 (TaskRef::DELIVERS_KEY, incoming.delivers),
10045 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
10046 ] {
10047 set_task_list(&mut metadata, key, entries);
10048 }
10049 record_edges(&mut metadata, fallback);
10050 metadata
10051}
10052
10053/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
10054/// one slot's metadata, or no such key when there are none.
10055fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
10056 if fallback.is_empty() {
10057 metadata.remove(DependencyEdge::RECORDED_KEY);
10058 } else {
10059 metadata.insert(
10060 DependencyEdge::RECORDED_KEY.to_owned(),
10061 Value::Array(
10062 fallback
10063 .iter()
10064 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
10065 .collect(),
10066 ),
10067 );
10068 }
10069}
10070
10071/// Every label one item carries, from its content's own connection and nowhere else.
10072///
10073/// There is no second place to read one from: no document this source sends selects the
10074/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
10075/// cannot carry one at all. The module documentation records the three schema facts that
10076/// settle it.
10077fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
10078 optional_nodes(content.get("labels"), "content labels")?
10079 .into_iter()
10080 .flatten()
10081 .map(|v| {
10082 Ok(Label {
10083 id: NativeId(required_str(v, "id")?.to_owned()),
10084 name: required_str(v, "name")?.to_owned(),
10085 color: optional_str(v, "color")?.map(str::to_owned),
10086 })
10087 })
10088 .collect()
10089}
10090
10091/// The definition of each board field one item's values are values of, in the shape a read
10092/// of the board's own `fields` gives one.
10093///
10094/// A value names its field through a fragment on that field's own type, so the type is
10095/// known from which kind of value it is: a single-select value's field is a
10096/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
10097/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
10098fn field_definitions(field_values: &[Value]) -> Vec<Value> {
10099 field_values
10100 .iter()
10101 .filter_map(|value| {
10102 let field = value.get("field")?.as_object()?;
10103 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
10104 let typename = if value.get("text").is_some() {
10105 "ProjectV2Field"
10106 } else if value.get("name").is_some() {
10107 "ProjectV2SingleSelectField"
10108 } else {
10109 return None;
10110 };
10111 let mut defined = field.clone();
10112 defined.insert("__typename".to_owned(), json!(typename));
10113 Some(Value::Object(defined))
10114 })
10115 .collect()
10116}
10117
10118fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
10119 let Some(node) = field_values
10120 .iter()
10121 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
10122 else {
10123 return Ok(None);
10124 };
10125 Ok(optional_str(node, "text")?.map(str::to_owned))
10126}
10127
10128fn valid_github_owner(owner: &str) -> bool {
10129 !owner.is_empty()
10130 && owner.len() <= 39
10131 && !owner.starts_with('-')
10132 && !owner.ends_with('-')
10133 && !owner.contains("--")
10134 && owner
10135 .bytes()
10136 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
10137}
10138
10139/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
10140/// neither of the two names a path segment already means.
10141fn valid_github_repository_name(name: &str) -> bool {
10142 !name.is_empty()
10143 && name.len() <= 100
10144 && name != "."
10145 && name != ".."
10146 && name
10147 .bytes()
10148 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
10149}
10150
10151fn valid_environment_name(name: &str) -> bool {
10152 let mut bytes = name.bytes();
10153 bytes
10154 .next()
10155 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
10156 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
10157}
10158
10159/// How many sub-issues one issue has.
10160///
10161/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
10162/// absent or non-integer one is a response this source cannot read — and reading it as
10163/// zero would classify a project as a task, which is exactly the mistake the marker
10164/// exists to keep from happening quietly.
10165fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
10166 let summary = issue
10167 .get("subIssuesSummary")
10168 .ok_or_else(|| SourceError::Malformed {
10169 message: "GitHub issue is missing subIssuesSummary".into(),
10170 })?;
10171 summary
10172 .get("total")
10173 .and_then(Value::as_u64)
10174 .ok_or_else(|| SourceError::Malformed {
10175 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
10176 })
10177}
10178
10179/// One issue's own `number`.
10180///
10181/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
10182/// an issue in this module asks for it. So a read of one that comes back without it, or
10183/// with something that is not an unsigned integer, is a response this source cannot read —
10184/// absence here is **not** "this issue has no number". A draft is the content that has
10185/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
10186/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
10187fn issue_number(issue: &Value) -> Result<u64, SourceError> {
10188 issue
10189 .get("number")
10190 .and_then(Value::as_u64)
10191 .ok_or_else(|| SourceError::Malformed {
10192 message: "GitHub issue number is missing or is not an unsigned integer".into(),
10193 })
10194}
10195
10196/// The `number` a creating mutation answered with, and `None` when it answered without one;
10197/// why a missing one is tolerated is at the call in `create_and_file_issue`.
10198fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
10199 match created.get("number") {
10200 None | Some(Value::Null) => Ok(None),
10201 Some(value) => value
10202 .as_u64()
10203 .map(Some)
10204 .ok_or_else(|| SourceError::Malformed {
10205 message: "GitHub created issue number is not an unsigned integer".into(),
10206 }),
10207 }
10208}
10209
10210fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10211 value
10212 .get(field)
10213 .and_then(Value::as_str)
10214 .ok_or_else(|| SourceError::Malformed {
10215 message: format!("GitHub response is missing string field {field}"),
10216 })
10217}
10218
10219fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10220 let found = required_str(value, field)?;
10221 if found.trim().is_empty() {
10222 return Err(SourceError::Malformed {
10223 message: format!("GitHub response has blank string field {field}"),
10224 });
10225 }
10226 Ok(found)
10227}
10228
10229/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
10230/// needs one — Linear spells them too, in its own description field.
10231///
10232/// Restated rather than shared, because a plugin crate depends on the contract crate and
10233/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
10234/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
10235/// source round-trips its own writes perfectly well under its own spelling.
10236const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
10237const METADATA_CLOSE: &str = "\n-->";
10238
10239/// What the composer puts between a non-empty visible body and the slot, and the one thing
10240/// the parser takes off the visible body when it takes the slot off — exactly once, so every
10241/// other trailing byte of the body comes back as it was written.
10242// 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.
10243const METADATA_SEPARATOR: &str = "\n\n";
10244
10245/// The visible body and the metadata slot at the end of it.
10246///
10247/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
10248/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
10249/// own content and is left alone. The visible body is everything before the slot less the
10250/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
10251fn metadata_body(
10252 body: Option<String>,
10253) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
10254 let Some(body) = body else {
10255 return Ok((None, BTreeMap::new()));
10256 };
10257 let Some(slot) = slot_span(&body)? else {
10258 return Ok((Some(body), BTreeMap::new()));
10259 };
10260 let metadata =
10261 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
10262 SourceError::Malformed {
10263 message: format!(
10264 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
10265 ),
10266 }
10267 })?;
10268 let before = &body[..slot.start];
10269 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
10270 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
10271}
10272
10273/// Where the metadata slot sits in one body, as byte offsets into it.
10274struct SlotSpan {
10275 /// Where [`METADATA_OPEN`] begins.
10276 start: usize,
10277 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
10278 encoded_start: usize,
10279 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
10280 encoded_end: usize,
10281 /// Just past [`METADATA_CLOSE`].
10282 end: usize,
10283}
10284
10285/// The slot at the very end of `body`, or `None` when it has none.
10286///
10287/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
10288/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
10289/// slot.
10290fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
10291 let Some(start) = body.rfind(METADATA_OPEN) else {
10292 return Ok(None);
10293 };
10294 let encoded_start = start + METADATA_OPEN.len();
10295 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
10296 return Err(SourceError::Malformed {
10297 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
10298 });
10299 };
10300 let encoded_end = encoded_start + relative_end;
10301 let end = encoded_end + METADATA_CLOSE.len();
10302 if !body[end..].trim().is_empty() {
10303 return Ok(None);
10304 }
10305 Ok(Some(SlotSpan {
10306 start,
10307 encoded_start,
10308 encoded_end,
10309 end,
10310 }))
10311}
10312
10313/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10314/// slot as it was.
10315///
10316/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10317/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10318/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10319/// or alone in an empty body — and a body with no slot that is given no metadata is
10320/// returned as it is.
10321fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10322 let encoded = if metadata.is_empty() {
10323 None
10324 } else {
10325 Some(
10326 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10327 message: error.to_string(),
10328 })?,
10329 )
10330 };
10331 Ok(match (slot_span(body)?, encoded) {
10332 (Some(slot), Some(encoded)) => format!(
10333 "{}{encoded}{}",
10334 &body[..slot.encoded_start],
10335 &body[slot.encoded_end..]
10336 ),
10337 (Some(slot), None) => {
10338 let before = &body[..slot.start];
10339 format!(
10340 "{}{}",
10341 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10342 &body[slot.end..]
10343 )
10344 }
10345 (None, None) => body.to_owned(),
10346 (None, Some(encoded)) if body.is_empty() => {
10347 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10348 }
10349 (None, Some(encoded)) => {
10350 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10351 }
10352 })
10353}
10354
10355/// `body` with everything before its metadata slot replaced by `content`, and the slot
10356/// itself kept byte for byte.
10357///
10358/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10359/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10360/// `content` is empty — so a read of the result reports `content` as the visible body and
10361/// the slot's metadata exactly as it was.
10362fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10363 let Some(slot) = slot_span(body)? else {
10364 return Ok(content.to_owned());
10365 };
10366 let kept = &body[slot.start..];
10367 Ok(if content.is_empty() {
10368 kept.to_owned()
10369 } else {
10370 format!("{content}{METADATA_SEPARATOR}{kept}")
10371 })
10372}
10373
10374/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10375fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10376 if entries.is_empty() {
10377 metadata.remove(key);
10378 } else {
10379 metadata.insert(
10380 key.to_owned(),
10381 Value::Array(
10382 entries
10383 .iter()
10384 .map(|entry| Value::String(entry.as_str().to_owned()))
10385 .collect(),
10386 ),
10387 );
10388 }
10389}
10390
10391fn compose_body(
10392 content: Option<&str>,
10393 metadata: &BTreeMap<String, Value>,
10394) -> Result<Option<String>, SourceError> {
10395 let visible = content.unwrap_or_default();
10396 if metadata.is_empty() {
10397 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10398 }
10399 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10400 message: error.to_string(),
10401 })?;
10402 Ok(Some(if visible.is_empty() {
10403 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10404 } else {
10405 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10406 }))
10407}
10408
10409fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10410 value
10411 .get(field)
10412 .and_then(Value::as_bool)
10413 .ok_or_else(|| SourceError::Malformed {
10414 message: format!("GitHub response is missing boolean field {field}"),
10415 })
10416}
10417fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10418 match value.get(field) {
10419 None | Some(Value::Null) => Ok(None),
10420 Some(value) => value
10421 .as_str()
10422 .map(Some)
10423 .ok_or_else(|| SourceError::Malformed {
10424 message: format!("GitHub response field {field} is not a string or null"),
10425 }),
10426 }
10427}
10428fn optional_nodes<'a>(
10429 connection: Option<&'a Value>,
10430 name: &str,
10431) -> Result<Option<&'a Vec<Value>>, SourceError> {
10432 match connection {
10433 None | Some(Value::Null) => Ok(None),
10434 Some(value) => value
10435 .get("nodes")
10436 .and_then(Value::as_array)
10437 .map(Some)
10438 .ok_or_else(|| SourceError::Malformed {
10439 message: format!("GitHub {name}.nodes is not an array"),
10440 }),
10441 }
10442}
10443fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10444 let page_info = connection
10445 .get("pageInfo")
10446 .ok_or_else(|| SourceError::Malformed {
10447 message: format!("GitHub {name} has no pageInfo"),
10448 })?;
10449 if required_bool(page_info, "hasNextPage")? {
10450 return Err(SourceError::Malformed {
10451 message: format!(
10452 "GitHub {name} exceeds the supported nested connection size of {size}"
10453 ),
10454 });
10455 }
10456 Ok(())
10457}
10458fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10459 optional_str(value, field)?
10460 .map(|timestamp| {
10461 timestamp.parse().map_err(|error| SourceError::Malformed {
10462 message: format!("GitHub response field {field} is not a timestamp: {error}"),
10463 })
10464 })
10465 .transpose()
10466}
10467fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10468 if page.limit == 0 {
10469 Err(SourceError::Config {
10470 message: "page limit must be at least 1".into(),
10471 })
10472 } else {
10473 Ok(())
10474 }
10475}
10476fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10477 let page = connection
10478 .get("pageInfo")
10479 .filter(|value| value.is_object())
10480 .ok_or_else(|| SourceError::Malformed {
10481 message: "GitHub connection is missing pageInfo".into(),
10482 })?;
10483 if required_bool(page, "hasNextPage")? {
10484 let cursor = required_str(page, "endCursor")?;
10485 validate_cursor_progress(None, cursor)?;
10486 Ok(Some(Cursor(cursor.into())))
10487 } else {
10488 Ok(None)
10489 }
10490}
10491fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10492 if next.is_empty() || previous == Some(next) {
10493 Err(SourceError::Malformed {
10494 message: "GitHub pagination cursor is empty or did not advance".into(),
10495 })
10496 } else {
10497 Ok(())
10498 }
10499}
10500/// The version of this plugin's opaque narrowing-search cursor.
10501pub const SEARCH_CURSOR_VERSION: u32 = 4;
10502
10503#[derive(Serialize, Deserialize)]
10504#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10505enum SearchConnection {
10506 Initial {},
10507 Continuing { after: Cursor },
10508 Exhausted {},
10509}
10510impl SearchConnection {
10511 fn after(&self) -> Option<&str> {
10512 match self {
10513 Self::Continuing { after } => Some(&after.0),
10514 _ => None,
10515 }
10516 }
10517 fn exhausted(&self) -> bool {
10518 matches!(self, Self::Exhausted { .. })
10519 }
10520 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10521 /// plugin could have handed out: a page is resumed only part of the way through it — an
10522 /// offset of a whole page or more would skip rows nobody was given — an initial page
10523 /// only once some of it was handed out, and an exhausted connection has no page to be
10524 /// part of the way through.
10525 fn valid_resume(&self, offset: usize) -> bool {
10526 let within = offset < SEARCH_PAGE_SIZE as usize;
10527 match self {
10528 Self::Initial { .. } => offset > 0 && within,
10529 Self::Continuing { after } => !after.0.is_empty() && within,
10530 Self::Exhausted { .. } => offset == 0,
10531 }
10532 }
10533}
10534
10535/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10536#[derive(Serialize, Deserialize)]
10537#[serde(deny_unknown_fields)]
10538struct SearchPosition {
10539 version: u32,
10540 connection: SearchConnection,
10541 /// How many rows of the page `connection` starts were already handed out.
10542 #[serde(default, skip_serializing_if = "is_zero")]
10543 offset: usize,
10544 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10545 seen: Vec<NativeId>,
10546 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10547 own: Vec<NativeId>,
10548}
10549impl Default for SearchPosition {
10550 fn default() -> Self {
10551 Self {
10552 version: SEARCH_CURSOR_VERSION,
10553 connection: SearchConnection::Initial {},
10554 offset: 0,
10555 seen: Vec::new(),
10556 own: Vec::new(),
10557 }
10558 }
10559}
10560
10561fn is_zero(offset: &usize) -> bool {
10562 *offset == 0
10563}
10564
10565fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10566 cursor.map_or(Ok(0), |c| {
10567 c.0.parse().map_err(|_| SourceError::Config {
10568 message: "page cursor is invalid".into(),
10569 })
10570 })
10571}
10572fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10573 if offset > items.len() {
10574 return Page::last(vec![]);
10575 }
10576 let tail = items.split_off(offset);
10577 let mut selected = tail;
10578 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10579 selected.truncate(limit);
10580 Page {
10581 items: selected,
10582 next,
10583 }
10584}
10585
10586impl GitHubProjectsSource {
10587 async fn write_task_assets(
10588 &self,
10589 write: &ItemWrite<Task>,
10590 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10591 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10592 let near = write.target.as_ref().unwrap_or(&write.item.id);
10593 for (key, entries) in [
10594 (TaskRef::DELIVERS_KEY, &write.item.delivers),
10595 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
10596 ] {
10597 TaskRef::listed(key, near, Some(&self.name), entries.clone())
10598 .map_err(|message| SourceError::Refused { message })?;
10599 }
10600 if self.priorities.is_none() && write.item.priority != Priority::None {
10601 return Err(self.holds_no_priority());
10602 }
10603 self.write_item(
10604 &Incoming {
10605 written: Written::Work(ItemKind::Task, &write.item.status),
10606 title: &write.item.title,
10607 content: write.item.content.as_deref(),
10608 assets,
10609 labels: &write.item.labels,
10610 metadata: &write.item.metadata,
10611 repositories: &write.item.repositories,
10612 classification: write.item.classification,
10613 parent: write.item.project.as_ref(),
10614 delivers: &write.item.delivers,
10615 delivered_by: &write.item.delivered_by,
10616 priority: self.priorities.as_ref().map(|_| write.item.priority),
10617 },
10618 write.target.as_ref(),
10619 &write.depends_on,
10620 )
10621 .await
10622 }
10623 async fn write_document_assets(
10624 &self,
10625 write: &ItemWrite<Document>,
10626 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10627 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10628 // A document takes part in no dependency graph, so there is no far end to write
10629 // natively and none to record: a caller naming one is told so rather than having it
10630 // stored under the reserved key, where a later read would report an edge the
10631 // contract says cannot exist.
10632 if !write.depends_on.is_empty() {
10633 return Err(SourceError::Refused {
10634 message: format!(
10635 "this write names {} dependencies for a document, and a document takes \
10636 part in no dependency graph; next: put the dependency on the task or \
10637 project the document is about",
10638 write.depends_on.len()
10639 ),
10640 });
10641 }
10642 self.write_item(
10643 &Incoming {
10644 written: Written::Document,
10645 title: &write.item.title,
10646 content: write.item.content.as_deref(),
10647 assets,
10648 labels: &write.item.labels,
10649 metadata: &write.item.metadata,
10650 repositories: &write.item.repositories,
10651 classification: write.item.classification,
10652 parent: write.item.project.as_ref(),
10653 delivers: &[],
10654 delivered_by: &[],
10655 priority: None,
10656 },
10657 write.target.as_ref(),
10658 &[],
10659 )
10660 .await
10661 }
10662}
10663
10664/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10665/// itself, for the two things no journey can observe.
10666///
10667/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
10668/// with and without the call, that a settlement, a board listing and a metadata search each
10669/// read afresh after it — the resolved records, the written-item overlay, the board and its
10670/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10671/// because a status write naming an option a person deleted is refused the same whether or
10672/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10673/// while one of its locks is held. So these assert those directly, and every other holder
10674/// beside them so a holder added later without a clear in the call fails here.
10675#[cfg(test)]
10676mod end_command_tests {
10677 use super::*;
10678
10679 struct Token;
10680
10681 impl SecretResolver for Token {
10682 fn get(&self, var: &str) -> Option<SecretString> {
10683 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10684 }
10685 }
10686
10687 fn source() -> GitHubProjectsSource {
10688 let config = serde_json::from_value(json!({
10689 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10690 // Nothing here is sent: the source is only built and its state inspected.
10691 "endpoint": "http://127.0.0.1:9/graphql",
10692 }))
10693 .expect("a usable configuration");
10694 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10695 .expect("the source builds")
10696 }
10697
10698 /// One issue as a board read answers it.
10699 fn resolved(source: &GitHubProjectsSource) -> Resolved {
10700 source
10701 .resolve(&json!({
10702 "id": "ITEM-1",
10703 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10704 "body": "what a person may since have edited", "state": "OPEN",
10705 "stateReason": null, "url": null, "number": 1,
10706 "subIssuesSummary": {"total": 0},
10707 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10708 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10709 }))
10710 .expect("the item reads")
10711 .expect("an issue")
10712 }
10713
10714 /// Hold something in every holder the call clears, and the repository id it keeps.
10715 fn fill(source: &GitHubProjectsSource) {
10716 let item = resolved(source);
10717 source.created.lock().unwrap().push(item.clone());
10718 source.updated.lock().unwrap().push(item.clone());
10719 *source.board_cache.lock().unwrap() = Some(Board {
10720 id: "PVT-board".into(),
10721 fields: json!({"nodes": []}),
10722 items: vec![item.clone()],
10723 });
10724 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10725 source
10726 .narrowed_cache
10727 .lock()
10728 .unwrap()
10729 .insert("status:todo".into(), vec![item.clone()]);
10730 source.children_cache.lock().unwrap().insert(
10731 NativeId("P-1".into()),
10732 (NativeId("P-1".into()), vec![item.clone()]),
10733 );
10734 source
10735 .search_next
10736 .lock()
10737 .unwrap()
10738 .insert("status:todo".into(), Some("cursor".into()));
10739 source
10740 .resolved_cache
10741 .lock()
10742 .unwrap()
10743 .insert(item.id.clone(), item);
10744 *source.fields_cache.lock().unwrap() = Some(BoardFields {
10745 id: BoardId::parse("PVT-board").unwrap(),
10746 fields: json!({"nodes": []}),
10747 });
10748 source
10749 .repository_cache
10750 .lock()
10751 .unwrap()
10752 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10753 }
10754
10755 fn assert_dropped(source: &GitHubProjectsSource) {
10756 assert!(source.created().unwrap().is_empty(), "created");
10757 assert!(source.updated().unwrap().is_empty(), "updated");
10758 assert!(source.board_cache().unwrap().is_none(), "board");
10759 assert!(source.search_cache.lock().unwrap().is_none(), "search");
10760 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10761 assert!(
10762 source.children_cache.lock().unwrap().is_empty(),
10763 "project children"
10764 );
10765 assert!(
10766 source.search_next.lock().unwrap().is_empty(),
10767 "search paging"
10768 );
10769 assert!(
10770 source.resolved_cache().unwrap().is_empty(),
10771 "resolved records"
10772 );
10773 assert!(source.fields_cache().unwrap().is_none(), "board fields");
10774 assert_eq!(
10775 source.repository_cache().unwrap().len(),
10776 1,
10777 "a repository's node id stays valid and is kept"
10778 );
10779 }
10780
10781 fn end(source: &GitHubProjectsSource) {
10782 tokio::runtime::Builder::new_current_thread()
10783 .build()
10784 .unwrap()
10785 .block_on(source.end_command())
10786 .expect("the command ends");
10787 }
10788
10789 #[test]
10790 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10791 let source = source();
10792 fill(&source);
10793 end(&source);
10794 assert_dropped(&source);
10795 }
10796
10797 #[test]
10798 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10799 fn poison<T: Send>(held: &Mutex<T>) {
10800 std::thread::scope(|scope| {
10801 let _ = scope
10802 .spawn(|| {
10803 let _guard = held.lock().unwrap();
10804 panic!("a failure while the lock is held");
10805 })
10806 .join();
10807 });
10808 assert!(held.is_poisoned());
10809 }
10810 let source = source();
10811 fill(&source);
10812 poison(&source.created);
10813 poison(&source.updated);
10814 poison(&source.board_cache);
10815 poison(&source.search_cache);
10816 poison(&source.narrowed_cache);
10817 poison(&source.search_next);
10818 poison(&source.resolved_cache);
10819 poison(&source.fields_cache);
10820 assert!(
10821 source.resolved_cache().is_err(),
10822 "a poisoned lock is refused before the call"
10823 );
10824 end(&source);
10825 assert_dropped(&source);
10826 }
10827}