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, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
617 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
618 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
619 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SharedClock,
620 SourceError, SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task,
621 TaskDetailRead, TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields,
622 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;
632
633use accounting::Accounting;
634
635/// The registry name for this plugin.
636pub const KIND: &str = "github-projects";
637/// GitHub's maximum connection page size.
638pub const MAX_PAGE_SIZE: u32 = 100;
639/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
640/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
641/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
642pub const SEARCH_PAGE_SIZE: u32 = 20;
643/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
644/// its comments: the largest batch the node-count model prices at one point.
645///
646/// Each aliased item is resolved once, and what GitHub charges for it is the connections
647/// under it — its labels, its page of board memberships, the field values of each of those
648/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
649/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
650/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
651/// one point and fails if one item more would still be priced at one.
652pub const DETAIL_BATCH: usize = 24;
653
654/// The most nodes any one document this source sends may be asked to return.
655///
656/// GitHub's own published per-query ceiling, taken from
657/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
658/// workspace cannot hold a stale copy of somebody else's number. A query above it is
659/// **refused before it is executed**, whoever is asking and whatever board they are
660/// asking about — so this is a bound on the documents rather than a budget that runs out.
661///
662/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
663/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
664/// everything the credential does — two numbers against two limits, and this constant
665/// bounds only the first. The second is computed offline too, per document:
666/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
667/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
668/// lane. There is no constant like this one to hold a price under, because points are an
669/// hourly allowance rather than a per-call bound.
670///
671/// Neither is a session's price. What `session-cost.md` records of a whole session is its
672/// **requests** and its **worst-case nodes**; what a whole session spends in points is
673/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
674/// [`accounting`]. The module section on the three ways this source reaches an item says how
675/// the count is arrived at, and which of the page sizes below decide it.
676pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
677
678/// Nested connection size for the connections that hang off one item.
679///
680/// It multiplies through every document that reaches an item under a page — the count
681/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
682/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
683/// every document under these constants and fails naming any that reaches the limit, so
684/// raising this is caught there rather than by GitHub.
685const NESTED_PAGE_SIZE: u32 = 50;
686/// How many of one issue's board memberships are read when an issue is reached directly.
687///
688/// An issue reached through a search or through its own node id carries its board half in
689/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
690/// under a page of issues, so every point of it multiplies through the whole document and
691/// is paid for whether or not any issue is on a second board — which is why it is
692/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
693///
694/// **Three, because what a page misses is now recovered rather than refused**, and the
695/// recovery is what the value is chosen against. An issue whose entry for this board sits
696/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
697/// that page's own cursor — so the value trades a bound every read pays for a request only
698/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
699/// boards would pay that request *per issue*, which is order N against the one page per
700/// hundred issues a read costs today. At three it is only reached by an issue on four or
701/// more boards at once, which keeps the recovery path exceptional rather than routine for
702/// a plausible deployment.
703const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
704/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
705/// its two connections for.
706///
707/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
708/// second is a duplicate a copy already takes the first of. Both connections are walked to
709/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
710/// never what the answer is. It is small because every point of it is paid on every lookup,
711/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
712/// worst-case nodes than the one whole-board read they replaced.
713const ORIGIN_PAGE_SIZE: u32 = 3;
714
715pub use github_graphql_node_count::{NodeCountError, Variables};
716
717/// The largest value this source can bind to each page-size variable its documents name.
718///
719/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
720/// constant above it wherever a caller's own limit could reach it — `$first` at
721/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
722/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
723/// not one configuration of it, which is what makes a bound computed under it a bound on
724/// every read.
725pub fn largest_page_sizes() -> Variables {
726 Variables::from([
727 ("first".to_owned(), MAX_PAGE_SIZE),
728 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
729 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
730 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
731 ])
732}
733
734/// The most nodes `document` could be asked to return, by GitHub's published rules.
735///
736/// Computed offline from the document's own text under [`largest_page_sizes`] — no
737/// network, no credential and no schema — by
738/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
739/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
740/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
741///
742/// # Errors
743///
744/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
745/// no single operation, or binds a page size this source does not name — each of which is
746/// a defect in the document rather than a number.
747pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
748 node_count(document, &largest_page_sizes())
749}
750
751/// The most rate-limit points one call of `document` could spend, by GitHub's published
752/// rules.
753///
754/// Computed offline from the document's own text under [`largest_page_sizes`] — no
755/// network, no credential and no schema — by
756/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
757/// This is `cost`, metered **per hour** against the allowance one credential shares across
758/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
759/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
760/// under, so what `tests/point_cost.rs` does with it is pin every document in
761/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
762/// figures against GitHub's own reported `cost`.
763///
764/// # Errors
765///
766/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
767/// no single operation, or binds a page size this source does not name — each of which is
768/// a defect in the document rather than a number.
769pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
770 github_graphql_node_count::point_cost(document, &largest_page_sizes())
771}
772
773/// The most nodes `document` could be asked to return under `variables`.
774///
775/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
776/// [`accounting`] is this under the bindings one request really sent — one spelling of the
777/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
778/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
779///
780/// # Errors
781///
782/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
783/// no single operation, or binds a page size `variables` does not name.
784pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
785 github_graphql_node_count::node_count(document, variables)
786}
787
788/// The issue-title prefix that makes a board issue a document.
789///
790/// A GitHub Projects board has no document type — it holds issues — so the discriminator
791/// is the title, and this is the whole of it: an issue whose title begins with these bytes
792/// is a document and every other issue is the task or project the sub-issue rule makes it.
793///
794/// It is spelled **once**, here, and read rather than restated everywhere else — including
795/// by the shared journeys, which take it from this constant so a board fixture cannot
796/// drift from what this source reads. `docs/metadata.md` records the two consequences that
797/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
798/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
799/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
800pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
801
802/// Exact GraphQL query documents issued by this plugin.
803///
804/// Keeping the production documents here lets the pinned-schema test validate the same
805/// bytes that are sent to GitHub, rather than a test-only copy which could drift
806/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
807/// field, and its guarded caller always supplies the complete existing option set with ids.
808pub mod graphql {
809 /// The board half of one item: the field values every document here reads it from.
810 ///
811 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
812 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
813 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
814 /// *the same value*, because
815 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
816 /// one path. Three spellings of it is what would drift, so there is one.
817 ///
818 /// The `Status` option and this source's own origin text field are the whole of it. It
819 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
820 /// content, so it holds nothing the content's own `labels` do not already say, and it
821 /// would sit a label connection two page sizes deep.
822 macro_rules! board_item_values {
823 () => {
824 r#"fieldValues(first:$nestedFirst){nodes{
825 ... on ProjectV2ItemFieldSingleSelectValue{name field{
826 ... on ProjectV2SingleSelectField{id name options{id name}}
827 }}
828 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
829 }pageInfo{hasNextPage}}"#
830 };
831 }
832
833 /// Everything this source reads about one issue, wherever it reaches that issue.
834 ///
835 /// A macro rather than a constant so the three documents below can `concat!` it: one
836 /// spelling of these fields is what makes an issue read through the board-scoped
837 /// search, through its own node id, and through its project's sub-issue relationship
838 /// resolve to *the same* item, which is the whole of what
839 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
840 ///
841 /// `projectItems` is what carries the board half of an issue: the board item's own id
842 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
843 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
844 /// issue rather than on the board, which is what makes the cost of a read proportional
845 /// to what was asked for instead of to the board's size.
846 ///
847 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
848 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
849 /// not on that page: a page here is where the search for the entry starts rather than
850 /// where it ends.
851 ///
852 /// It does **not** select the board's `Labels` field value, and that is the whole of
853 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
854 /// a label connection there sits under `fieldValues` under `projectItems` under a page
855 /// of issues, spending `$nestedFirst` twice down one path, and took
856 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
857 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
858 /// above, and that connection is where every label this source reports comes from. No
859 /// document in this module selects the board field any longer, [`BOARD`] included; the
860 /// module documentation records why nothing it could have held is lost.
861 macro_rules! board_issue {
862 () => {
863 concat!(
864 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
865 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
866 projectItems(first:$boardItems){nodes{id project{id number}
867 "#,
868 board_item_values!(),
869 r#"}pageInfo{hasNextPage endCursor}}}"#
870 )
871 };
872 }
873
874 /// Every issue of one board, found by a search scoped to that board.
875 ///
876 /// This is how the projects a board holds are listed, and it selects no `items`
877 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
878 /// container walked page by page, so nothing nested inside a board item is paid for.
879 /// Which of the issues it returns is a project is then read off `parent` — GitHub
880 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
881 /// discriminator has to be applied to the field, which is a scalar on the issue and
882 /// costs nothing.
883 pub const SEARCH_ISSUES: &str = concat!(
884 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
885 search(query:$search,type:$type,first:$first,after:$after){
886 pageInfo{hasNextPage endCursor}
887 nodes{__typename ...BoardIssue}
888 }
889 }"#,
890 board_issue!()
891 );
892
893 /// What a dependency read selects of each far end: enough to say which kind of item it
894 /// is, its body included for the kind marker.
895 macro_rules! related_issue {
896 () => {
897 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
898 };
899 }
900
901 /// One issue by its own node id, which is what a qualified id names here — with what a
902 /// write of it needs and the issue does not carry in `board_issue!`: the field
903 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
904 ///
905 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
906 /// answers a write made moments ago with the value from before it, and resolving a node
907 /// id does not.
908 ///
909 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
910 /// reads it by its own id, and with them that one read answers everything the write
911 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
912 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
913 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
914 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
915 /// they sit under one item, and this read is still one point.
916 pub const ISSUE: &str = concat!(
917 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
918 node(id:$id){__typename ...BoardIssue ... on Issue{
919 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
920 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
921 ... on ProjectV2Field{__typename id name}
922 }pageInfo{hasNextPage}}}}}
923 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
924 }}
925 }"#,
926 board_issue!(),
927 related_issue!()
928 );
929
930 /// One project's tasks: the sub-issues of the issue that project is.
931 ///
932 /// The work this costs is the project's own size. Nothing about it grows as the board
933 /// gains projects, or as those projects gain tasks.
934 pub const SUB_ISSUES: &str = concat!(
935 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
936 node(id:$id){__typename
937 ... on Issue{subIssues(first:$first,after:$after){
938 pageInfo{hasNextPage endCursor}
939 nodes{__typename ...BoardIssue}
940 }}}
941 }"#,
942 board_issue!()
943 );
944
945 /// What a read of the board's own `items` selects of each item's content.
946 ///
947 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
948 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
949 /// content by one spelling.
950 macro_rules! board_item_content {
951 () => {
952 r#" content{
953 ... 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}}}
954 ... on PullRequest{__typename id}
955 ... on DraftIssue{__typename id title body createdAt updatedAt}
956 }"#
957 };
958 }
959
960 /// Reads the board's fields and one page of its items.
961 pub const BOARD: &str = concat!(
962 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
963 owner:repositoryOwner(login:$owner){
964 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
965 }
966 } fragment Board on ProjectV2 { id title
967 fields(first:$nestedFirst){nodes{
968 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
969 ... on ProjectV2Field{__typename id name}
970 }pageInfo{hasNextPage}}
971 items(first:$first,after:$after){nodes{id "#,
972 board_item_values!(),
973 board_item_content!(),
974 r#"} pageInfo{hasNextPage endCursor}}
975 }"#
976 );
977
978 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
979 /// the board.
980 ///
981 /// **`originItems`** is the board's own items narrowed by its own field filter —
982 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
983 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
984 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
985 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
986 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
987 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
988 /// fresh `addProjectV2ItemById` the way that connection does.
989 ///
990 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
991 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
992 /// indexes that comment, and the index catches up with a write in a second or two rather
993 /// than in minutes, so it finds a carrier another process wrote that the first read is
994 /// still behind on.
995 ///
996 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
997 /// — and resumes from its own cursor; a connection already walked to its end is resumed
998 /// from its last cursor, which answers an empty page. Every candidate either read returns
999 /// is confirmed against its own origin field before it is reported, so a token match of
1000 /// the search or anything else the filter admits never is.
1001 ///
1002 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
1003 /// own whole reads counts this one among them.
1004 pub const ORIGIN_LOOKUP: &str = concat!(
1005 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1006 originItems:repositoryOwner(login:$owner){
1007 ... on ProjectV2Owner{projectV2(number:$number){
1008 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
1009 board_item_values!(),
1010 board_item_content!(),
1011 r#"} pageInfo{hasNextPage endCursor}}
1012 }}
1013 }
1014 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
1015 pageInfo{hasNextPage endCursor}
1016 nodes{__typename ...BoardIssue}
1017 }
1018 }"#,
1019 board_issue!()
1020 );
1021
1022 /// The board's own id and field definitions, and not one of its items.
1023 ///
1024 /// What a write needs of the board when the item it writes does not say: the id a field
1025 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
1026 /// origin fields. It selects no `items`, so what it costs is the board's field list
1027 /// however many items the board holds — and it decides nothing about which items those
1028 /// are, which is the question a read of one item by its own id answers instead.
1029 ///
1030 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1031 /// board's item reads by their root counts this one among them.
1032 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1033 boardFields:repositoryOwner(login:$owner){
1034 ... on ProjectV2Owner{projectV2(number:$number){id
1035 fields(first:$nestedFirst){nodes{
1036 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1037 ... on ProjectV2Field{__typename id name}
1038 }pageInfo{hasNextPage}}
1039 }}
1040 }
1041 }"#;
1042
1043 /// One board draft by its own node id, with the board item it sits in.
1044 ///
1045 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1046 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1047 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1048 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1049 /// board listing hands it to, and nothing has to list the board to find one.
1050 pub const DRAFT: &str = concat!(
1051 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1052 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1053 projectV2Items(first:$boardItems){nodes{id project{id number}
1054 "#,
1055 board_item_values!(),
1056 r#"}pageInfo{hasNextPage endCursor}}}}
1057 }"#
1058 );
1059
1060 /// One issue's board memberships alone, walked past the page a read of it carried.
1061 ///
1062 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1063 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1064 /// boards than that page holds may have this board's entry past its end. This asks that
1065 /// one issue for its memberships and nothing else — the caller already holds the issue —
1066 /// so an answer of "this board does not hold it" is only ever given about a connection
1067 /// read to exhaustion.
1068 ///
1069 /// It selects the board item's id, its project number and the same
1070 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1071 /// very same resolver: an issue recovered this way reports the same title, the same
1072 /// status, the same labels and the same qualified id as one whose entry was on the
1073 /// page.
1074 ///
1075 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1076 /// multiplies through it and the membership connection can be walked at
1077 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1078 /// further request for any issue a person really keeps.
1079 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1080 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1081 node(id:$id){
1082 ... on Issue{projectItems(first:$first,after:$after){
1083 nodes{id project{id number}
1084 "#,
1085 board_item_values!(),
1086 r#"}
1087 pageInfo{hasNextPage endCursor}}}
1088 }
1089 }"#
1090 );
1091 /// Resolves the configured repository's node id, which creating an issue requires.
1092 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1093 /// What creating an issue needs and has not read yet: the board's own id and field
1094 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1095 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1096 ///
1097 /// Sent at the point a create knows which repository it is for, when neither half is
1098 /// already known to this process; a create needing only one of them sends that one's own
1099 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1100 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1101 /// status.
1102 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1103 boardFields:repositoryOwner(login:$owner){
1104 ... on ProjectV2Owner{projectV2(number:$number){id
1105 fields(first:$nestedFirst){nodes{
1106 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1107 ... on ProjectV2Field{__typename id name}
1108 }pageInfo{hasNextPage}}
1109 }}
1110 }
1111 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1112 }"#;
1113 /// Reads both dependency directions for one issue, with each far end's own kind — and
1114 /// the issue's own body, which is where an edge to another source is recorded, so that
1115 /// half of a dependency read needs no second read of the issue or of the board.
1116 pub const ISSUE_DEPENDENCIES: &str = concat!(
1117 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1118 ... on Issue{body
1119 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1120 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1121 }}}"#,
1122 related_issue!()
1123 );
1124 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1125 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1126 /// answered when it was.
1127 pub const CREATE_ISSUE: &str =
1128 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1129 /// Puts an existing issue on the configured board.
1130 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1131 /// Updates an issue's visible fields and its open or closed state in one call.
1132 pub const UPDATE_ISSUE: &str =
1133 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1134 /// Updates an existing draft's user-visible fields.
1135 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1136 /// Updates a text or single-select value on one project item.
1137 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}}}}}}}}"#;
1138 /// Writes up to three board fields and an optional clear in one ordered mutation.
1139 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}}}"#;
1140 /// Clears one project item's value of one field, which is what a `none` priority is.
1141 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}}}}}}}}"#;
1142 /// Creates one single-select field with its options. Only the guarded field setup may use
1143 /// this document, and only for a field the board lacks.
1144 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1145 /// Replaces a single-select field's options. Only the guarded field setup — the
1146 /// `status-options` and `fields` operations — may use this document, because GitHub
1147 /// treats the input as the complete option list.
1148 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1149 /// A fresh snapshot of the Status field and every board item's assignment.
1150 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}}}}}}"#;
1151 /// Files one issue under another as a sub-issue, which is what project membership is.
1152 pub const ADD_SUB_ISSUE: &str =
1153 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1154 /// Takes one issue back out of its parent.
1155 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1156 /// Adds GitHub's native issue blocked-by relationship.
1157 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1158 /// Removes one native issue blocked-by relationship.
1159 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1160 /// Deletes one issue, which takes its board item with it.
1161 ///
1162 /// The engine sends this in one situation only: undoing a copy that could not finish,
1163 /// over the items that same copy created. Deleting the issue removes the board item
1164 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1165 pub const DELETE_ISSUE: &str =
1166 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1167
1168 /// Everything this source reads about one issue comment, wherever it reaches one.
1169 ///
1170 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1171 /// and a comment just edited are handed to one mapper, so they are selected by one
1172 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1173 /// longer exists, and `login` is the one member every kind of actor carries.
1174 macro_rules! issue_comment {
1175 () => {
1176 "id author{login} createdAt updatedAt body url"
1177 };
1178 }
1179
1180 /// One task's comments: a page of its issue's own `comments` connection.
1181 ///
1182 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1183 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1184 /// list every time somebody edited it; left unordered the connection answers in the order
1185 /// the comments were written, which is the order GitHub documents for the same collection
1186 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1187 /// node count and the caller's own page size is pushed straight down.
1188 pub const ISSUE_COMMENTS: &str = concat!(
1189 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1190 issue_comment!(),
1191 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1192 );
1193 /// One issue by its own node id, with a page of its comments: what `task show` and a
1194 /// comment listing read, in one request.
1195 ///
1196 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1197 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1198 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1199 /// connection there would multiply through both of those documents' price, and neither
1200 /// needs one.
1201 pub const ISSUE_DETAIL: &str = concat!(
1202 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1203 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1204 issue_comment!(),
1205 r#"}pageInfo{hasNextPage endCursor}}}}
1206 }"#,
1207 board_issue!()
1208 );
1209
1210 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1211 /// page of its comments when `$comments` asks for them.
1212 macro_rules! issue_details_alias {
1213 ($n:literal) => {
1214 concat!(
1215 "\n i",
1216 stringify!($n),
1217 ":node(id:$id",
1218 stringify!($n),
1219 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1220 issue_comment!(),
1221 "}pageInfo{hasNextPage endCursor}}}}"
1222 )
1223 };
1224 }
1225
1226 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1227 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1228 ///
1229 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1230 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1231 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1232 /// neither — so every connection under it would be priced at nothing and the pin in
1233 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1234 /// one-item read the model already prices, so the batch costs what its aliases cost.
1235 ///
1236 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1237 /// slots it has no item for to the last item it does, and reads that item again; the
1238 /// price is the document's, whatever its variables, so a short batch costs what a full
1239 /// one does and nothing more.
1240 pub const ISSUE_DETAILS: &str = concat!(
1241 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!){"#,
1242 issue_details_alias!(0),
1243 issue_details_alias!(1),
1244 issue_details_alias!(2),
1245 issue_details_alias!(3),
1246 issue_details_alias!(4),
1247 issue_details_alias!(5),
1248 issue_details_alias!(6),
1249 issue_details_alias!(7),
1250 issue_details_alias!(8),
1251 issue_details_alias!(9),
1252 issue_details_alias!(10),
1253 issue_details_alias!(11),
1254 issue_details_alias!(12),
1255 issue_details_alias!(13),
1256 issue_details_alias!(14),
1257 issue_details_alias!(15),
1258 issue_details_alias!(16),
1259 issue_details_alias!(17),
1260 issue_details_alias!(18),
1261 issue_details_alias!(19),
1262 issue_details_alias!(20),
1263 issue_details_alias!(21),
1264 issue_details_alias!(22),
1265 issue_details_alias!(23),
1266 "\n }",
1267 board_issue!()
1268 );
1269
1270 /// Which issue one comment is on, read before that comment is edited or removed.
1271 ///
1272 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1273 /// comment id given against the wrong task would change a comment on another issue.
1274 pub const COMMENT_ISSUE: &str =
1275 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1276 /// Adds one comment to an issue, signed as the account the token belongs to.
1277 pub const ADD_COMMENT: &str = concat!(
1278 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1279 issue_comment!(),
1280 r#"}}}}"#
1281 );
1282 /// Replaces the body of one issue comment.
1283 pub const UPDATE_COMMENT: &str = concat!(
1284 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1285 issue_comment!(),
1286 r#"}}}"#
1287 );
1288 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1289 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1290
1291 /// Every document above, with what this source is doing when it sends one.
1292 ///
1293 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1294 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1295 /// document added later with "talking to GitHub" and never say so.
1296 ///
1297 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1298 /// const` here that this list omits, so the two cannot part — which is the same guard
1299 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1300 pub const DOCUMENTS: [(&str, &str); 33] = [
1301 (SEARCH_ISSUES, "searching this board's issues"),
1302 (ISSUE, "reading one issue"),
1303 (
1304 ISSUE_BOARD_ITEMS,
1305 "reading one issue's board memberships past the page it came with",
1306 ),
1307 (SUB_ISSUES, "reading a project's tasks"),
1308 (BOARD, "reading the board"),
1309 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1310 (BOARD_FIELDS, "reading the board's fields"),
1311 (DRAFT, "reading one draft"),
1312 (REPOSITORY, "reading the destination repository"),
1313 (
1314 CREATION_CONTEXT,
1315 "reading the board's fields and the destination repository",
1316 ),
1317 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1318 (CREATE_ISSUE, "creating an issue"),
1319 (ADD_TO_BOARD, "adding an issue to the board"),
1320 (UPDATE_ISSUE, "updating an issue"),
1321 (UPDATE_DRAFT, "updating a draft item"),
1322 (UPDATE_FIELD, "writing a board field"),
1323 (UPDATE_FIELDS, "writing board fields together"),
1324 (CLEAR_FIELD, "clearing a board field"),
1325 (
1326 CREATE_FIELD,
1327 "creating a board single-select field with its options",
1328 ),
1329 (
1330 STATUS_OPTIONS_SNAPSHOT,
1331 "snapshotting board Status options and assignments",
1332 ),
1333 (
1334 STATUS_OPTIONS_UPDATE,
1335 "safely replacing the board Status option list",
1336 ),
1337 (ADD_SUB_ISSUE, "filing an issue under its project"),
1338 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1339 (ADD_BLOCKED_BY, "recording a dependency"),
1340 (REMOVE_BLOCKED_BY, "removing a dependency"),
1341 (DELETE_ISSUE, "deleting an issue"),
1342 (ISSUE_COMMENTS, "reading a task's comments"),
1343 (ISSUE_DETAIL, "reading one issue with its comments"),
1344 (
1345 ISSUE_DETAILS,
1346 "reading a batch of issues with their comments",
1347 ),
1348 (COMMENT_ISSUE, "reading which issue a comment is on"),
1349 (ADD_COMMENT, "adding a comment"),
1350 (UPDATE_COMMENT, "editing a comment"),
1351 (DELETE_COMMENT, "deleting a comment"),
1352 ];
1353}
1354
1355/// Which of GitHub's two rate limiters refused a request.
1356///
1357/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1358/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1359/// the whole reason this is carried rather than collapsed into "rate limited".
1360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361enum Limiter {
1362 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1363 Primary,
1364 /// The burst limiter over content-generating requests, which nothing reports.
1365 Secondary,
1366}
1367
1368/// The wordings GitHub answers a secondary rate limit with.
1369///
1370/// It sends them under a forbidden status, under a too-many-requests status, and inside
1371/// the `errors` of a *successful* response, which is why the text is what this matches on
1372/// rather than the status. `abuse detection` is the wording GitHub used before the
1373/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1374/// what a burst of content creation is refused with.
1375///
1376/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1377/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1378/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1379/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1380pub const SECONDARY_WORDINGS: [&str; 5] = [
1381 "secondary rate limit",
1382 "temporarily blocked from content creation",
1383 "abuse detection",
1384 "submitted too quickly",
1385 "exceeded a secondary",
1386];
1387
1388/// The wordings GitHub answers an exhausted primary budget with.
1389///
1390/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1391/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1392/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1393/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1394/// two phrases is a substring of it, so without it that answer read as a refusal that will
1395/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1396/// one reason.
1397pub const PRIMARY_WORDINGS: [&str; 4] = [
1398 "api rate limit exceeded",
1399 "api rate limit already exceeded",
1400 "rate limit exceeded",
1401 "rate_limited",
1402];
1403
1404/// What a response *says about itself*, which is the only place a refusal can be read.
1405///
1406/// Deliberately not the whole response body. A board is a place people write about their
1407/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1408/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1409/// reported. So the item data is never read: what is read is GitHub's own REST-style
1410/// `message` envelope, which is what a forbidden status carries, and the `message` and
1411/// `type` of each GraphQL error, which is where a *successful* response says it.
1412///
1413/// A body that is not JSON at all has nothing structured to read, so only a failing
1414/// response's own text is taken — a successful response that is not JSON is malformed
1415/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1416fn refusal_wording(status: StatusCode, body: &str) -> String {
1417 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1418 return if status.is_success() {
1419 String::new()
1420 } else {
1421 body.to_owned()
1422 };
1423 };
1424 let mut said: Vec<&str> = parsed
1425 .get("message")
1426 .and_then(Value::as_str)
1427 .into_iter()
1428 .collect();
1429 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1430 for error in errors {
1431 said.extend(
1432 ["message", "type"]
1433 .into_iter()
1434 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1435 );
1436 }
1437 }
1438 said.join("; ")
1439}
1440
1441impl Limiter {
1442 /// Which limiter refused this response, or `None` when none of them did.
1443 ///
1444 /// The wording is read first and the status only decides what carries none of it,
1445 /// because GitHub answers a secondary limit with a forbidden status far more often
1446 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1447 /// really is a credential this token lacks.
1448 ///
1449 /// A response is a refusal because of its status or its own wording. A spent budget
1450 /// only ever explains one; it never turns an answer into a refusal.
1451 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1452 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1453 if SECONDARY_WORDINGS
1454 .iter()
1455 .any(|wording| normalized.contains(wording))
1456 {
1457 return Some(Self::Secondary);
1458 }
1459 if status == StatusCode::TOO_MANY_REQUESTS {
1460 return Some(Self::Primary);
1461 }
1462 // An exhausted budget *explains* a response that failed; it does not make one that
1463 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1464 // request the budget allowed as well as on the ones it then refuses, so reading
1465 // the header alone threw away a good answer — and, once refusals were retried,
1466 // replayed a request that had already taken effect.
1467 if !status.is_success() && budget_exhausted {
1468 return Some(Self::Primary);
1469 }
1470 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1471 // `errors` of an HTTP 200, where nothing about the status says so at all.
1472 if status.is_success()
1473 && PRIMARY_WORDINGS
1474 .iter()
1475 .any(|wording| normalized.contains(wording))
1476 {
1477 return Some(Self::Primary);
1478 }
1479 None
1480 }
1481
1482 /// What this limiter is called where an operator can look it up.
1483 const fn name(self) -> &'static str {
1484 match self {
1485 Self::Primary => "GitHub's primary API rate limit",
1486 Self::Secondary => "GitHub's secondary rate limit",
1487 }
1488 }
1489
1490 /// What the endpoint an operator would go and check says about this limiter.
1491 const fn where_to_look(self) -> &'static str {
1492 match self {
1493 Self::Primary => {
1494 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1495 comes back."
1496 }
1497 Self::Secondary => {
1498 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1499 primary budget and does not report this one, so budget showing there says \
1500 nothing about this refusal, and every further attempt extends it."
1501 }
1502 }
1503 }
1504
1505 /// The next step this limiter actually calls for.
1506 const fn what_to_do(self) -> &'static str {
1507 match self {
1508 Self::Primary => {
1509 "wait for the reset `gh api rate_limit` reports, then run the command again."
1510 }
1511 Self::Secondary => {
1512 "leave this board alone for a few minutes, then run the command again — or \
1513 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1514 }
1515 }
1516 }
1517}
1518
1519/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1520#[derive(Debug, Clone, Copy)]
1521struct Limited {
1522 limiter: Limiter,
1523 hint: Option<u64>,
1524}
1525
1526impl Limited {
1527 /// What the caller is told once this source has waited as long as it may.
1528 ///
1529 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1530 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1531 /// about *which* limiter it was makes it a different kind of failure. What differs is
1532 /// the operator's next step, and that is what the message carries — a secondary
1533 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1534 /// budget looks fine, and then back to retry the very burst that was refused.
1535 fn exhausted(
1536 self,
1537 doing: &str,
1538 waits: u32,
1539 waited: Duration,
1540 needed: Duration,
1541 budget: Duration,
1542 ) -> SourceError {
1543 SourceError::RateLimited {
1544 retry_after_seconds: self.hint,
1545 message: Some(format!(
1546 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1547 again, and the next wait of {} would take it past the {} one call may spend \
1548 waiting. {} next: {}",
1549 self.limiter.name(),
1550 plural(waits, "refusal"),
1551 seconds(waited),
1552 seconds(needed),
1553 seconds(budget),
1554 self.limiter.where_to_look(),
1555 self.limiter.what_to_do(),
1556 )),
1557 }
1558 }
1559}
1560
1561/// One HTTP attempt's result, with what its response said about the rate limit.
1562///
1563/// The two travel together so the record and the outcome are written from the same place:
1564/// what a response said about the budget is only readable while that response is in hand,
1565/// and what the attempt *meant* is only decidable once its body has been read.
1566struct Attempted {
1567 result: Result<Value, Attempt>,
1568 limits: accounting::RateLimit,
1569 /// GitHub's own reported cost for this call, for a document that asked for it.
1570 reported_cost: Option<u64>,
1571}
1572
1573/// One attempt's outcome: an error to report, or a rate limit to wait out.
1574enum Attempt {
1575 Failed(SourceError),
1576 Limited(Limited),
1577}
1578
1579fn plural(count: u32, thing: &str) -> String {
1580 if count == 1 {
1581 format!("{count} {thing}")
1582 } else {
1583 format!("{count} {thing}s")
1584 }
1585}
1586
1587fn seconds(duration: Duration) -> String {
1588 format!("{:.1}s", duration.as_secs_f64())
1589}
1590
1591/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1592///
1593/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1594/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1595/// header, and neither is what makes a response a refusal — so the whole cost of one this
1596/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1597/// instead. Refusing the response over the header would turn a readable refusal into an
1598/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1599fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1600 value
1601 .and_then(|value| value.to_str().ok())
1602 .and_then(|value| value.trim().parse::<u64>().ok())
1603}
1604
1605/// Every mutation this source sends creates content — an issue, a board item, a field of
1606/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1607/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1608/// and what the keyword says are the same set. That is what makes the keyword a sound test
1609/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1610/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1611fn is_mutation(query: &str) -> bool {
1612 query.trim_start().starts_with("mutation")
1613}
1614
1615/// What this source was doing, for a diagnostic that has to say so.
1616///
1617/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1618/// a document added without a description is caught by that list's own gate instead of
1619/// falling through to the vague arm below.
1620fn operation_description(query: &str) -> &'static str {
1621 graphql::DOCUMENTS
1622 .iter()
1623 .find(|(document, _)| *document == query)
1624 .map_or("talking to GitHub", |(_, doing)| *doing)
1625}
1626
1627/// GitHub's published ceiling on content-generating requests, per minute.
1628///
1629/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1630/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1631/// from it, so a pacing value checked only against itself cannot go stale here.
1632pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1633/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1634/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1635/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1636pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1637/// Shortest interval between two content-creating mutations, in milliseconds.
1638///
1639/// GitHub documents two secondary limits on content-generating requests:
1640/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1641/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1642/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1643/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1644/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1645/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1646/// deliberately *not* what this paces at. An installation that wants the hourly bound
1647/// honoured for a long sequence of copies says so through
1648/// `pacing.min_mutation_interval_ms`.
1649pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1650/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1651///
1652/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1653/// own advice for a secondary limit — wait, and wait longer each time — without spending
1654/// the first minute of a transient refusal doing nothing.
1655pub const RETRY_BACKOFF_MS: u64 = 1_000;
1656/// Total time one call may spend waiting out rate limits before it reports a failure.
1657///
1658/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1659/// short enough that a command an operator is watching returns. The bound is what makes
1660/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1661/// the limiter, not in a process nobody can tell from a wedged one.
1662pub const RETRY_BUDGET_MS: u64 = 120_000;
1663
1664fn default_token_env() -> String {
1665 "GH_PROJECTS_TOKEN".to_owned()
1666}
1667fn default_endpoint() -> String {
1668 "https://api.github.com/graphql".to_owned()
1669}
1670
1671/// The name of a `Status` single-select option on the board.
1672///
1673/// Validated on the way in rather than checked later, so a blank option name — which
1674/// nothing on a board can be — is a state this type cannot hold.
1675#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1676#[serde(try_from = "String")]
1677#[schemars(extend("minLength" = 1))]
1678pub struct ColumnName(String);
1679
1680impl ColumnName {
1681 /// The option name, as the board spells it.
1682 fn as_str(&self) -> &str {
1683 &self.0
1684 }
1685}
1686
1687impl TryFrom<String> for ColumnName {
1688 type Error = String;
1689
1690 fn try_from(name: String) -> Result<Self, Self::Error> {
1691 if name.trim().is_empty() {
1692 return Err("a status_mapping option name cannot be blank".to_owned());
1693 }
1694 Ok(Self(name))
1695 }
1696}
1697
1698/// The two closed states this product can mean.
1699///
1700/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1701/// work nor abandoned work, so nothing here ever writes it.
1702#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1703#[serde(rename_all = "kebab-case")]
1704pub enum ClosedState {
1705 /// `COMPLETED` — precisely done.
1706 Completed,
1707 /// `NOT_PLANNED` — precisely cancelled.
1708 NotPlanned,
1709}
1710
1711impl ClosedState {
1712 const fn reason(self) -> &'static str {
1713 match self {
1714 Self::Completed => "COMPLETED",
1715 Self::NotPlanned => "NOT_PLANNED",
1716 }
1717 }
1718}
1719
1720/// Configuration for one GitHub Projects v2 board.
1721#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1722#[serde(default, deny_unknown_fields)]
1723pub struct GitHubProjectsConfig {
1724 /// Login of the user or organization which owns the board.
1725 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1726 /// The project number shown in the board's GitHub URL.
1727 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1728 // 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.
1729 /// `owner/name` of the repository this source creates an issue in when the item's own
1730 /// `repositories` field does not decide it.
1731 ///
1732 /// An item naming exactly one repository is created there; a task or a document naming
1733 /// none or several is created in its parent project's repository; and a project, or a
1734 /// task or document with no parent, naming none or several is created here. A board
1735 /// has no repository of its own and `createIssue` requires one, so a write without
1736 /// this is refused naming the field. Reads never need it.
1737 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1738 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1739 /// Environment variable containing a fine-grained token with Projects and Issues
1740 /// read/write plus Pull requests read-only access for every repository represented on
1741 /// the board.
1742 #[serde(default = "default_token_env")]
1743 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1744 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1745 #[serde(default = "default_endpoint")]
1746 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1747 /// Per-instance mapping from a status category to the option of the board's one
1748 /// `Status` field it lands on, for a task and for a project.
1749 ///
1750 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1751 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1752 /// where a kind left out leaves the category unmapped for that kind. A category this
1753 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1754 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1755 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1756 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1757 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1758 /// either kind. No two categories may name one option for the same kind, ignoring case.
1759 /// `unknown` may name one existing option; every unknown word then lands on it and
1760 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1761 /// each unknown word because it never creates board options.
1762 #[serde(default)]
1763 pub status_mapping: StatusMapping,
1764 /// Per-instance mapping from a task's priority to an option of this board's
1765 /// single-select field named `Priority`.
1766 ///
1767 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1768 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1769 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1770 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1771 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1772 /// no two levels may name one option. Reads and writes never create the field or an
1773 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1774 /// the board lacks is refused pointing there.
1775 #[serde(default)]
1776 pub priority_mapping: Option<PriorityMappingConfig>,
1777 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1778 ///
1779 /// Every field keeps its shipped default when it is absent, and the defaults are
1780 /// GitHub's own published limits rather than taste. See [`Pacing`].
1781 #[serde(default)]
1782 pub pacing: PacingConfig,
1783}
1784
1785/// Which option of the board's `Priority` field each priority lands on.
1786///
1787/// One member per level rather than a map, so a key that is not a level is refused where
1788/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1789/// value in the field, not an option of it.
1790#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1791#[serde(default, deny_unknown_fields)]
1792pub struct PriorityMappingConfig {
1793 /// The option `urgent` lands on; `Urgent` when absent.
1794 pub urgent: Option<PriorityOptionName>,
1795 /// The option `high` lands on; `High` when absent.
1796 pub high: Option<PriorityOptionName>,
1797 /// The option `medium` lands on; `Medium` when absent.
1798 pub medium: Option<PriorityOptionName>,
1799 /// The option `low` lands on; `Low` when absent.
1800 pub low: Option<PriorityOptionName>,
1801}
1802
1803/// The name of an option of the board's `Priority` single-select field.
1804///
1805/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1806/// blank name.
1807#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1808#[serde(try_from = "String")]
1809#[schemars(extend("minLength" = 1))]
1810pub struct PriorityOptionName(String);
1811
1812impl PriorityOptionName {
1813 /// The option name, as the board spells it.
1814 fn as_str(&self) -> &str {
1815 &self.0
1816 }
1817}
1818
1819impl TryFrom<String> for PriorityOptionName {
1820 type Error = String;
1821
1822 fn try_from(name: String) -> Result<Self, Self::Error> {
1823 if name.trim().is_empty() {
1824 return Err("a priority_mapping option name cannot be blank".to_owned());
1825 }
1826 Ok(Self(name))
1827 }
1828}
1829
1830/// The name of the board field a priority is held in.
1831pub const PRIORITY_FIELD: &str = "Priority";
1832
1833/// The four priorities a board option can hold, in the order a new `Priority` field lists
1834/// them. `none` is not among them: it is the field holding no value.
1835///
1836/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1837/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1838/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1839/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1840pub const PRIORITY_LEVELS: [Priority; 4] = [
1841 Priority::Urgent,
1842 Priority::High,
1843 Priority::Medium,
1844 Priority::Low,
1845];
1846
1847/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1848/// see that list for what this pins.
1849#[must_use]
1850pub const fn level_position(priority: Priority) -> Option<usize> {
1851 match priority {
1852 Priority::None => None,
1853 Priority::Urgent => Some(0),
1854 Priority::High => Some(1),
1855 Priority::Medium => Some(2),
1856 Priority::Low => Some(3),
1857 }
1858}
1859
1860/// This instance's complete priority-to-option mapping, read in both directions.
1861///
1862/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1863/// two levels name one option.
1864#[derive(Debug, Clone)]
1865struct PriorityMapping {
1866 options: [PriorityOptionName; 4],
1867}
1868
1869impl PriorityMapping {
1870 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1871 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1872 let mapping = Self {
1873 options: [
1874 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1875 config.high.unwrap_or_else(|| shipped("High")),
1876 config.medium.unwrap_or_else(|| shipped("Medium")),
1877 config.low.unwrap_or_else(|| shipped("Low")),
1878 ],
1879 };
1880 for (index, option) in mapping.options.iter().enumerate() {
1881 if let Some(earlier) = mapping.options[..index]
1882 .iter()
1883 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1884 {
1885 return Err(SourceError::Config {
1886 message: format!(
1887 "priority_mapping of source {instance} sends both {} and {} to the board \
1888 option {:?}; one option cannot read back as two priorities",
1889 PRIORITY_LEVELS[earlier],
1890 PRIORITY_LEVELS[index],
1891 option.as_str()
1892 ),
1893 });
1894 }
1895 }
1896 Ok(mapping)
1897 }
1898
1899 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1900 fn option(&self, priority: Priority) -> Option<&str> {
1901 level_position(priority).map(|index| self.options[index].as_str())
1902 }
1903
1904 /// The priority a board option name reports, or `None` when nothing maps to it.
1905 fn priority_of(&self, option: &str) -> Option<Priority> {
1906 self.options
1907 .iter()
1908 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1909 .map(|index| PRIORITY_LEVELS[index])
1910 }
1911
1912 /// Every mapped option name, in the order a new `Priority` field lists them.
1913 fn names(&self) -> impl Iterator<Item = &str> {
1914 self.options.iter().map(PriorityOptionName::as_str)
1915 }
1916}
1917
1918/// What one item's `Priority` field says, read through this instance's mapping.
1919#[derive(Debug, Clone, PartialEq, Eq)]
1920enum HeldPriority {
1921 /// A priority this source reports: an option the mapping names, or no value (`none`).
1922 Read(Priority),
1923 /// An option the mapping does not name, which is never read as a level or as `none`.
1924 Unmapped(String),
1925}
1926
1927/// How fast this source writes, and how long it waits out a rate-limit refusal.
1928///
1929/// Configurable because a GitHub Enterprise installation sets its own limits and an
1930/// operator who has already been refused may want to go slower still — not because the
1931/// defaults are guesses.
1932#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1933#[serde(default, deny_unknown_fields)]
1934pub struct PacingConfig {
1935 /// Shortest interval between two content-creating mutations, in milliseconds.
1936 ///
1937 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1938 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1939 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.
1940 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1941 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1942 /// zero while there is a budget to spend, because a schedule of zero-length waits
1943 /// consumes none of it and so never ends.
1944 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.
1945 /// Total time one call may spend waiting out rate limits, in milliseconds.
1946 ///
1947 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1948 /// the bound is what makes this a wait rather than a hang.
1949 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.
1950}
1951
1952/// The largest any pacing setting may be, in milliseconds.
1953///
1954/// One hour. GitHub's own harshest published bound on content-generating requests works
1955/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1956/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1957/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1958/// and an interval beyond it is a command that never sends its second mutation. It also
1959/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1960/// what a `Duration` can hold on every platform.
1961pub const MAX_PACING_MS: u64 = 3_600_000;
1962
1963/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1964/// source holds.
1965#[derive(Debug, Clone, Copy)]
1966struct Pacing {
1967 min_mutation_interval: Duration,
1968 retry_backoff: Duration,
1969 retry_budget: Duration,
1970}
1971
1972impl Pacing {
1973 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1974 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1975 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1976 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1977 message: format!(
1978 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1979 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1980 GitHub's own harshest published limit"
1981 ),
1982 }),
1983 Some(value) => Ok(Duration::from_millis(value)),
1984 None => Ok(Duration::from_millis(default)),
1985 };
1986 let retry_backoff = bounded(
1987 config.retry_backoff_ms,
1988 RETRY_BACKOFF_MS,
1989 "retry_backoff_ms",
1990 )?;
1991 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1992 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1993 return Err(SourceError::Config {
1994 message: format!(
1995 "pacing.retry_backoff_ms of source {instance} is 0 while \
1996 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1997 none of that budget, so it would retry a refusal forever. Set a backoff of \
1998 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1999 waiting at all",
2000 retry_budget.as_millis()
2001 ),
2002 });
2003 }
2004 Ok(Self {
2005 min_mutation_interval: bounded(
2006 config.min_mutation_interval_ms,
2007 MIN_MUTATION_INTERVAL_MS,
2008 "min_mutation_interval_ms",
2009 )?,
2010 retry_backoff,
2011 retry_budget,
2012 })
2013 }
2014}
2015
2016/// Factory for [`GitHubProjectsSource`].
2017#[derive(Debug, Clone, Copy, Default)]
2018pub struct Plugin;
2019
2020impl SourcePlugin for Plugin {
2021 fn kind(&self) -> &'static str {
2022 KIND
2023 }
2024 fn config_schema(&self) -> Schema {
2025 schema_for!(GitHubProjectsConfig)
2026 }
2027 fn build(
2028 &self,
2029 name: &SourceName,
2030 config: &Value,
2031 secrets: &dyn SecretResolver,
2032 ) -> Result<Box<dyn TaskSource>, SourceError> {
2033 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2034 }
2035
2036 fn build_with_clock(
2037 &self,
2038 name: &SourceName,
2039 config: &Value,
2040 secrets: &dyn SecretResolver,
2041 clock: SharedClock,
2042 ) -> Result<Box<dyn TaskSource>, SourceError> {
2043 self.build_recording_with_clock(name, config, secrets, Arc::new(Accounting::new()), clock)
2044 }
2045}
2046
2047impl Plugin {
2048 /// Build a source recording every request it sends into an accounting the caller holds.
2049 ///
2050 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2051 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2052 /// session total rather than two — see [`accounting`] and
2053 /// [`GitHubProjectsSource::recording_into`].
2054 ///
2055 /// # Errors
2056 ///
2057 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2058 /// [`SourceError::Config`] for configuration this plugin cannot use and
2059 /// [`SourceError::Auth`] for a credential it cannot find.
2060 pub fn build_recording_into(
2061 &self,
2062 name: &SourceName,
2063 config: &Value,
2064 secrets: &dyn SecretResolver,
2065 ledger: Arc<Accounting>,
2066 ) -> Result<Box<dyn TaskSource>, SourceError> {
2067 self.build_recording_with_clock(name, config, secrets, ledger, system_clock())
2068 }
2069
2070 fn build_recording_with_clock(
2071 &self,
2072 name: &SourceName,
2073 config: &Value,
2074 secrets: &dyn SecretResolver,
2075 ledger: Arc<Accounting>,
2076 clock: SharedClock,
2077 ) -> Result<Box<dyn TaskSource>, SourceError> {
2078 let config: GitHubProjectsConfig =
2079 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2080 message: format!("source {name}: {e}"),
2081 })?;
2082 let prefix = format!("source {name}: ");
2083 let mut source = GitHubProjectsSource::recording_into(name, config, secrets, ledger)
2084 .map_err(|error| match error {
2085 // The shared `StatusMapping::distinct` names the source itself.
2086 SourceError::Config { message } if message.starts_with(&prefix) => {
2087 SourceError::Config { message }
2088 }
2089 SourceError::Config { message } => SourceError::Config {
2090 message: format!("{prefix}{message}"),
2091 },
2092 SourceError::Auth { message } => SourceError::Auth {
2093 message: format!("source {name}: {message}"),
2094 },
2095 other => other,
2096 })?;
2097 source.clock = clock;
2098 Ok(Box::new(source))
2099 }
2100}
2101
2102/// Where a status category lands on this board, once configuration is resolved.
2103#[derive(Debug, Clone, PartialEq, Eq)]
2104enum StatusTarget {
2105 /// Not usable against this instance for this kind, and why.
2106 Disabled(UnmappedStatus),
2107 /// The board's `Status` option of this name.
2108 Column(ColumnName),
2109 /// A closed issue, with both its board option and the reason that says which closed it means.
2110 // 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.
2111 Terminal(ColumnName, ClosedState),
2112}
2113
2114/// Every status category, in the order the vocabulary declares them.
2115///
2116/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2117/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2118/// added to the shared vocabulary fails to compile until it is named there, and this
2119/// crate's suite reconciles this list against that enum's own derived schema, which is
2120/// generated from the variants rather than written beside them. The schema is what
2121/// catches a list left one short — a list checking only the positions it already holds
2122/// would pass while every mapping indexed by the new position panicked.
2123pub const CATEGORIES: [StatusCategory; 8] = [
2124 StatusCategory::Draft,
2125 StatusCategory::Backlog,
2126 StatusCategory::Todo,
2127 StatusCategory::Queued,
2128 StatusCategory::InProgress,
2129 StatusCategory::Done,
2130 StatusCategory::Cancelled,
2131 StatusCategory::Unknown,
2132];
2133
2134/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2135#[must_use]
2136pub const fn category_position(category: StatusCategory) -> usize {
2137 match category {
2138 StatusCategory::Draft => 0,
2139 StatusCategory::Backlog => 1,
2140 StatusCategory::Todo => 2,
2141 StatusCategory::Queued => 3,
2142 StatusCategory::InProgress => 4,
2143 StatusCategory::Done => 5,
2144 StatusCategory::Cancelled => 6,
2145 StatusCategory::Unknown => 7,
2146 }
2147}
2148
2149/// The spelling a status category is configured and reported under.
2150fn category_name(category: StatusCategory) -> &'static str {
2151 match category {
2152 StatusCategory::Draft => "draft",
2153 StatusCategory::Backlog => "backlog",
2154 StatusCategory::Todo => "todo",
2155 StatusCategory::Queued => "queued",
2156 StatusCategory::InProgress => "in-progress",
2157 StatusCategory::Done => "done",
2158 StatusCategory::Cancelled => "cancelled",
2159 StatusCategory::Unknown => "unknown",
2160 }
2161}
2162
2163/// A shipped default's option name.
2164///
2165/// The literals below are this file's own and non-blank, and they are validated by the
2166/// one constructor a configured name goes through rather than beside it.
2167fn shipped_column(name: &'static str) -> ColumnName {
2168 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2169}
2170
2171/// The shipped default for one category this instance's `status_mapping` does not mention,
2172/// for either kind.
2173fn shipped_default(category: StatusCategory) -> StatusTarget {
2174 match category {
2175 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2176 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2177 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2178 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2179 StatusCategory::Done => {
2180 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2181 }
2182 StatusCategory::Cancelled => {
2183 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2184 }
2185 StatusCategory::Draft | StatusCategory::Unknown => {
2186 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2187 }
2188 }
2189}
2190
2191/// The two kinds a status is written and read for, each with its own half of the mapping.
2192const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2193
2194/// This instance's complete category-to-target mapping for each kind, read in both
2195/// directions.
2196///
2197/// One target per category per kind, held at that category's own [`category_position`], so
2198/// a category missing from the mapping, named twice in it, or filed out of order is a state
2199/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2200/// targets are options of the board's one `Status` field.
2201#[derive(Debug, Clone)]
2202struct BoardStatuses {
2203 tasks: [StatusTarget; CATEGORIES.len()],
2204 projects: [StatusTarget; CATEGORIES.len()],
2205}
2206
2207impl BoardStatuses {
2208 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2209 /// would read back from one option.
2210 ///
2211 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2212 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2213 /// omits unmapped rather than defaulted.
2214 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2215 let resolve_kind =
2216 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2217 // `CATEGORIES[position] == category` for every category — the crate's suite
2218 // asserts it — so mapping the list in order fills each category's own slot.
2219 let mut targets = CATEGORIES.map(shipped_default);
2220 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2221 if !configured.mentions(category) {
2222 continue;
2223 }
2224 *slot = match configured.name_for(category, kind) {
2225 Err(why) => StatusTarget::Disabled(why),
2226 Ok(name) => {
2227 let option = ColumnName::try_from(name.as_str().to_owned())
2228 .map_err(|message| SourceError::Config { message })?;
2229 match category {
2230 StatusCategory::Done => {
2231 StatusTarget::Terminal(option, ClosedState::Completed)
2232 }
2233 StatusCategory::Cancelled => {
2234 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2235 }
2236 _ => StatusTarget::Column(option),
2237 }
2238 }
2239 };
2240 }
2241 StatusMapping::distinct(
2242 instance,
2243 kind,
2244 CATEGORIES
2245 .iter()
2246 .zip(&targets)
2247 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2248 )?;
2249 Ok(targets)
2250 };
2251 Ok(Self {
2252 tasks: resolve_kind(ItemKind::Task)?,
2253 projects: resolve_kind(ItemKind::Project)?,
2254 })
2255 }
2256
2257 /// Every category's target for `kind`, in category order.
2258 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2259 match kind {
2260 ItemKind::Task => &self.tasks,
2261 ItemKind::Project => &self.projects,
2262 }
2263 }
2264
2265 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2266 &self.targets(kind)[category_position(category)]
2267 }
2268
2269 /// The category a board option name reports for `kind`, or `None` when nothing of that
2270 /// kind maps to it.
2271 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2272 CATEGORIES.into_iter().find(|category| {
2273 self.target(kind, *category)
2274 .option()
2275 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2276 })
2277 }
2278
2279 /// Every option name either kind maps a category to, each once ignoring case, in
2280 /// category order with a task's name before a project's — what the guarded setup asks
2281 /// the `Status` field to hold.
2282 fn wanted(&self) -> Vec<String> {
2283 let mut wanted: Vec<String> = Vec::new();
2284 for category in CATEGORIES {
2285 for kind in STATUS_KINDS {
2286 if let Some(name) = self.target(kind, category).option()
2287 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2288 {
2289 wanted.push(name.to_owned());
2290 }
2291 }
2292 }
2293 wanted
2294 }
2295
2296 /// The status an item of `kind` reports, from the three things a read of it says: its
2297 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2298 ///
2299 /// The closed state decides the category and the `Status` option decides the name, so
2300 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2301 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2302 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2303 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2304 /// it is read permissively rather than refused — reads are faithful, and refusals
2305 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2306 /// option that mapping does not name reads as `Unknown` under its own name.
2307 ///
2308 /// One function of those three rather than of a response, so a narrow status write can
2309 /// answer what a re-read would report by applying it to the state it has just written.
2310 fn status(
2311 &self,
2312 kind: ItemKind,
2313 option: Option<&str>,
2314 closed: bool,
2315 reason: Option<&str>,
2316 ) -> Status {
2317 if closed {
2318 let category = match reason {
2319 None | Some("COMPLETED") => StatusCategory::Done,
2320 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2321 Some(_) => StatusCategory::Unknown,
2322 };
2323 let fallback = match category {
2324 StatusCategory::Done => "Done",
2325 StatusCategory::Cancelled => "Cancelled",
2326 _ => "Closed",
2327 };
2328 return Status {
2329 category,
2330 name: option.unwrap_or(fallback).to_owned(),
2331 };
2332 }
2333 let name = option.unwrap_or("Open").to_owned();
2334 Status {
2335 category: self
2336 .category_of(kind, &name)
2337 .unwrap_or(StatusCategory::Unknown),
2338 name,
2339 }
2340 }
2341}
2342
2343impl BoardStatuses {
2344 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2345 /// case; a kind lacking none is left out.
2346 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2347 STATUS_KINDS
2348 .into_iter()
2349 .filter_map(|kind| {
2350 let missing: Vec<String> = self
2351 .targets(kind)
2352 .iter()
2353 .filter_map(StatusTarget::option)
2354 .filter(|wanted| {
2355 !existing
2356 .iter()
2357 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2358 })
2359 .map(str::to_owned)
2360 .collect();
2361 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2362 })
2363 .collect()
2364 }
2365}
2366
2367impl StatusTarget {
2368 /// The board option this target selects, or `None` for an unmapped one.
2369 fn option(&self) -> Option<&str> {
2370 match self {
2371 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2372 Self::Disabled(_) => None,
2373 }
2374 }
2375}
2376
2377// 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.
2378/// One repository this source can create an issue in, as `owner/name`.
2379///
2380/// Every `createIssue` this source sends names one of these: the item's own single
2381/// `repositories` entry, else its parent project issue's repository, else the configured
2382/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2383/// that choice and says what it refuses before `createIssue`.
2384// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2385#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2386struct RepositoryTarget {
2387 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2388 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2389}
2390
2391impl RepositoryTarget {
2392 fn parse(value: &str) -> Result<Self, SourceError> {
2393 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2394 message: format!(
2395 "repository must be spelled owner/name; {value:?} names no repository"
2396 ),
2397 })?;
2398 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2399 return Err(SourceError::Config {
2400 message: format!(
2401 "repository must be spelled owner/name with a GitHub login and one \
2402 repository name; {value:?} is not"
2403 ),
2404 });
2405 }
2406 Ok(Self {
2407 owner: owner.to_owned(),
2408 name: name.to_owned(),
2409 })
2410 }
2411
2412 /// The one host whose repositories this source creates issues in, spelled once: it is
2413 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2414 const HOST: &str = "github.com";
2415
2416 fn origin(&self) -> String {
2417 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2418 }
2419
2420 /// The repository a normalized origin names, or why it is none this source can create
2421 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2422 fn from_origin(origin: &Repository) -> Result<Self, String> {
2423 let not_here = || {
2424 format!(
2425 "{} is not a {}/owner/name repository",
2426 origin.as_str(),
2427 Self::HOST
2428 )
2429 };
2430 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2431 if host != Self::HOST {
2432 return Err(not_here());
2433 }
2434 Self::parse(rest).map_err(|_| not_here())
2435 }
2436
2437 fn slug(&self) -> String {
2438 format!("{}/{}", self.owner, self.name)
2439 }
2440}
2441
2442/// A source which reads GitHub afresh for every operation.
2443pub struct GitHubProjectsSource {
2444 /// This source's configured name, used both to tell a far end naming this source
2445 /// from one naming a system it knows nothing about, and to name the instance a
2446 /// status refusal is about.
2447 name: SourceName,
2448 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2449 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2450 repository: Option<RepositoryTarget>,
2451 endpoint: Url,
2452 token: SecretString,
2453 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2454 statuses: BoardStatuses,
2455 /// Where each priority lands on this board, or `None` when this instance holds none.
2456 priorities: Option<PriorityMapping>,
2457 client: Client,
2458 asset_client: Client,
2459 /// Every item this source has created in this command, in the order it created them —
2460 /// dropped by [`TaskSource::end_command`].
2461 ///
2462 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2463 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2464 /// a copy resolving a dependency on an item it had just created refused it as not
2465 /// found. A board read is completed from this — an item remembered here and absent from
2466 /// the read is added back, because the board really does hold it and only the read is
2467 /// behind.
2468 ///
2469 /// It is not a cache of a user's work: nothing is remembered that this process did not
2470 /// itself just write, it lives and dies with the process, and it is never consulted for
2471 /// an item this source did not create.
2472 created: Mutex<Vec<Resolved>>,
2473 /// Every item that already existed and that this source has written in this command, as
2474 /// it wrote it — dropped by [`TaskSource::end_command`].
2475 ///
2476 /// The other half of [`Self::created`], held on the same terms and for the reason a
2477 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2478 /// filter is an index behind a write this process made moments ago, so a query matching
2479 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2480 /// is remembered that this process did not itself just write.
2481 updated: Mutex<Vec<Resolved>>,
2482 /// Every issue this source has added a comment to or edited a comment of in this command
2483 /// — dropped by [`TaskSource::end_command`].
2484 ///
2485 /// A comment-activity read is narrowed by GitHub's issue search, whose `updated:` index
2486 /// lags the write that moved an issue's `updatedAt`, and neither [`Self::created`] nor
2487 /// [`Self::updated`] is moved by a comment, so an issue this process had just commented
2488 /// on was missing from such a read — or ruled out by the `updatedAt` its own record held
2489 /// from before — until the index caught up. Each id here is a candidate of every such
2490 /// search-narrowed read, and wherever it is a candidate its comments are read rather than
2491 /// it being ruled out by a stale `updatedAt`; that read is of the issue's own node, so it
2492 /// is current. It holds ids alone: nothing of a comment is remembered. A comment another
2493 /// process wrote is still found only once the index has it.
2494 commented: Mutex<Vec<NativeId>>,
2495 /// How fast this source writes, and how long it waits out a refusal.
2496 pacing: Pacing,
2497 /// When the last content-creating mutation finished, or the moment the furthest-out
2498 /// reserved slot releases the next one, whichever is later — so the one after it can be
2499 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2500 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2501 /// what it is measured from.
2502 last_mutation: Mutex<Option<Duration>>,
2503 clock: SharedClock,
2504 numeric_repositories: tokio::sync::Mutex<BTreeMap<RepositoryTarget, std::num::NonZeroU64>>,
2505 /// The board as this process last read it, for the length of one command — dropped by
2506 /// [`TaskSource::end_command`].
2507 ///
2508 /// A copy of a project used to re-read the whole board, paged, before writing each of
2509 /// its items, which is by far the largest part of a copy's request count and none of
2510 /// its work. Nothing else changes this board while a command runs — this source's own
2511 /// writes are the only writer — so one read answers them all.
2512 ///
2513 /// It is not a store of a user's work and it is not the cache the no-persistence
2514 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2515 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2516 /// an item this command created and then depends on resolves whether or not GitHub's
2517 /// own eventually-consistent read has caught up. A write to an item already on the
2518 /// board updates the entry here too, so what this holds is the last read plus this
2519 /// process's own writes rather than a snapshot taken before them.
2520 board_cache: Mutex<Option<Board>>,
2521 /// Every issue this board's own search reported, for the length of one command — dropped
2522 /// by [`TaskSource::end_command`].
2523 ///
2524 /// The second half of a board read, and cached for the same reason and on the same
2525 /// terms as the first: it lives and dies with the process, nothing is written down, and
2526 /// a write this process makes updates the entry here exactly as it updates the one in
2527 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2528 /// that lists this board's projects and its tasks pays for one search rather than two.
2529 search_cache: Mutex<Option<Vec<Resolved>>>,
2530 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2531 /// the length of one command — dropped by [`TaskSource::end_command`].
2532 ///
2533 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2534 /// and dies with the process, nothing is written down, a write this process makes
2535 /// updates the entry here as it updates the other two, and every answer is completed
2536 /// with this process's own writes each time it is given. A command that asks the same
2537 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2538 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2539 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2540 search_next: Mutex<BTreeMap<String, Option<String>>>,
2541 /// Records already resolved in this command, reused by writes and for comment identity.
2542 /// Explicit item reads still reach GitHub. Nothing is persisted, and
2543 /// [`TaskSource::end_command`] drops every record, so a write in the next command reads
2544 /// its item as a person has since left it.
2545 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2546 /// The board's own id and field definitions as this process last read them on their
2547 /// own, for the length of one command — dropped by [`TaskSource::end_command`].
2548 ///
2549 /// What a write needs of the board and its item does not say, read once per command
2550 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2551 /// lives and dies with the process and nothing is written down. It holds no item and so
2552 /// can answer no question about one — see [`Self::board_fields`].
2553 fields_cache: Mutex<Option<BoardFields>>,
2554 /// Each destination repository's node id, resolved once per repository
2555 /// rather than per issue created.
2556 ///
2557 /// A repository's node id does not change, and re-reading it for every issue of a copy
2558 /// spent one request per item on an answer this source already had. It is a map rather
2559 /// than one entry because a copy files each item in the repository its own
2560 /// `repositories` field names, so a plan across five repositories asks GitHub five
2561 /// times and not once per item.
2562 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2563 /// What every request this source sends is recorded into.
2564 ///
2565 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2566 /// a request leaves this crate, so nothing has to be switched on for a session to be
2567 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2568 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2569 /// source's reads and writes — adds up one accounting instead of two. See
2570 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2571 ledger: Arc<Accounting>,
2572}
2573
2574/// GitHub's closed single-select color vocabulary.
2575#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2576#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2577pub enum StatusOptionColor {
2578 /// Gray.
2579 Gray,
2580 /// Blue.
2581 Blue,
2582 /// Green.
2583 Green,
2584 /// Yellow.
2585 Yellow,
2586 /// Purple.
2587 Purple,
2588 /// Red.
2589 Red,
2590 /// Orange.
2591 Orange,
2592 /// Pink.
2593 Pink,
2594}
2595
2596/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2597/// applies its additions.
2598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2599pub enum SetupMode {
2600 /// Read without mutation.
2601 Plan,
2602 /// Apply and verify.
2603 Apply,
2604}
2605
2606/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2607/// against it goes on compiling.
2608pub type StatusOptionsMode = SetupMode;
2609
2610/// The explicit result of the requested operation.
2611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2612#[serde(rename_all = "kebab-case")]
2613pub enum StatusOptionsOutcome {
2614 /// A read-only plan.
2615 Planned,
2616 /// Apply found nothing missing.
2617 Unchanged,
2618 /// Additions were applied and verified.
2619 Applied,
2620}
2621
2622/// A GitHub single-select option's opaque GraphQL node identifier.
2623#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2624#[serde(transparent)]
2625pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2626
2627impl TryFrom<String> for StatusOptionId {
2628 type Error = String;
2629
2630 fn try_from(id: String) -> Result<Self, Self::Error> {
2631 if id.trim().is_empty() {
2632 return Err("a GitHub Status option id cannot be blank".to_owned());
2633 }
2634 Ok(Self(id))
2635 }
2636}
2637
2638/// One existing or proposed option in a guarded Status-field update.
2639#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2640pub struct StatusOption {
2641 /// GitHub's stable id.
2642 pub id: StatusOptionId,
2643 /// The visible option name.
2644 pub name: ColumnName,
2645 /// GitHub's single-select color token.
2646 pub color: StatusOptionColor,
2647 /// The option description, including an empty one.
2648 pub description: String,
2649}
2650
2651/// One board item's Status assignment, retained as recovery data.
2652#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2653pub struct StatusAssignment {
2654 /// The project item id whose assignment this is.
2655 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2656 // carried verbatim as operator recovery data; introducing a semantic type would claim
2657 // validation rules GitHub does not publish and no operation here interprets.
2658 pub item_id: String,
2659 /// The selected option, absent when the item has no status.
2660 #[serde(skip_serializing_if = "Option::is_none")]
2661 pub option: Option<AssignedStatusOption>,
2662}
2663
2664/// The inseparable id and name of an assigned option.
2665#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2666pub struct AssignedStatusOption {
2667 /// GitHub's stable id.
2668 pub id: StatusOptionId,
2669 /// The visible name.
2670 pub name: ColumnName,
2671}
2672
2673/// The plan and verified outcome of reconciling configured Status options.
2674#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2675pub struct StatusOptionsReport {
2676 /// The configured source name.
2677 pub source: SourceName,
2678 /// Configured option names absent before the operation.
2679 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2680 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2681 // serialized string here preserves the report's intentionally simple public contract.
2682 pub missing: Vec<String>,
2683 /// What the requested operation did.
2684 pub outcome: StatusOptionsOutcome,
2685 /// The complete option list observed before any mutation.
2686 pub existing: Vec<StatusOption>,
2687}
2688
2689#[derive(Debug, Clone, PartialEq, Eq)]
2690struct StatusSnapshot {
2691 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2692 // passed back as the mutation's project identity; a newtype could enforce no stronger
2693 // invariant because GitHub publishes no grammar for it.
2694 board_id: String,
2695 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2696 // passed back as the mutation's field identity; a newtype could enforce no stronger
2697 // invariant because GitHub publishes no grammar for it.
2698 field_id: String,
2699 options: Vec<StatusOption>,
2700 assignments: Vec<StatusAssignment>,
2701}
2702
2703/// The name of the board field a status is held in.
2704const STATUS_FIELD: &str = "Status";
2705
2706/// Every item's value of each field `report` names, as it stood before the setup wrote
2707/// anything — what a person puts back when the setup is refused part way.
2708fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2709 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2710 .fields
2711 .iter()
2712 .map(|field| (field.field.name(), before.assignments(field.field)))
2713 .collect();
2714 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2715 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2716 })
2717}
2718
2719/// One board field the guarded setup reads and writes — every one it reads, and the only
2720/// ones it writes.
2721#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2722pub enum BoardField {
2723 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2724 Status,
2725 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2726 Priority,
2727}
2728
2729impl BoardField {
2730 /// The field's name on the board.
2731 #[must_use]
2732 pub const fn name(self) -> &'static str {
2733 match self {
2734 Self::Status => STATUS_FIELD,
2735 Self::Priority => PRIORITY_FIELD,
2736 }
2737 }
2738
2739 /// The field a board calls `name`, or `None` for one this setup does not own.
2740 fn named(name: &str) -> Option<Self> {
2741 [Self::Status, Self::Priority]
2742 .into_iter()
2743 .find(|field| field.name() == name)
2744 }
2745}
2746
2747/// What the guarded setup did to one field.
2748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2749#[serde(rename_all = "kebab-case")]
2750pub enum FieldOutcome {
2751 /// A read-only plan.
2752 Planned,
2753 /// Apply found the field there with every configured option.
2754 Unchanged,
2755 /// Missing options were added to the field that was there, and verified.
2756 Applied,
2757 /// The field was not there; it was created holding the configured options, and verified.
2758 Created,
2759}
2760
2761/// One field's plan, or its verified outcome.
2762#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2763pub struct FieldReport {
2764 /// Which field.
2765 pub field: BoardField,
2766 /// Whether the board had the field before the operation.
2767 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2768 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2769 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2770 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2771 // one constructor, and it derives `outcome` from `exists` in one match.
2772 pub exists: bool,
2773 /// Configured option names the field lacked before the operation — every one of them,
2774 /// in the order a new field lists them, when the field was not there at all.
2775 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2776 // mapping name and has therefore already passed its nonblank validation; the serialized
2777 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2778 pub missing: Vec<String>,
2779 /// For the `Status` field, which item kind each missing name is configured for: one
2780 /// entry per kind `status_mapping` names a missing option for, task before project, each
2781 /// listing that kind's missing names in category order. A name both kinds use is in
2782 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2783 /// `Priority`, which only a task holds.
2784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2785 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2786 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2787 #[schemars(!skip_serializing_if)]
2788 pub kinds: Vec<KindMissing>,
2789 /// What the requested operation did.
2790 pub outcome: FieldOutcome,
2791 /// The field's complete option list observed before any mutation; empty when the field
2792 /// was not there.
2793 pub existing: Vec<StatusOption>,
2794}
2795
2796/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2797#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2798pub struct KindMissing {
2799 /// The kind these names are configured for.
2800 pub kind: ItemKind,
2801 /// The names that kind maps a category to and the field lacked, in category order.
2802 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2803 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2804 // intentionally simple public contract.
2805 pub missing: Vec<String>,
2806}
2807
2808/// The plan and verified outcome of setting up every field a source's configuration names.
2809#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2810pub struct FieldsReport {
2811 /// The configured source name.
2812 pub source: SourceName,
2813 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2814 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2815 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2816 // per field would change a published JSON shape. The states the list could hold and the
2817 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2818 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2819 pub fields: Vec<FieldReport>,
2820}
2821
2822/// Which options one field is configured with, in the order a new field would list them.
2823struct FieldPlan {
2824 field: BoardField,
2825 wanted: Vec<String>,
2826}
2827
2828/// One single-select field as the guarded setup snapshots it.
2829#[derive(Debug, Clone, PartialEq, Eq)]
2830struct SnapshotField {
2831 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2832 // passed back as the mutation's field identity; a newtype could enforce no stronger
2833 // invariant because GitHub publishes no grammar for it.
2834 field_id: String,
2835 options: Vec<StatusOption>,
2836}
2837
2838/// Every single-select field of a board and every item's value of each.
2839#[derive(Debug, Clone, PartialEq, Eq)]
2840struct BoardSnapshot {
2841 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2842 // passed back as the mutation's project identity; a newtype could enforce no stronger
2843 // invariant because GitHub publishes no grammar for it.
2844 board_id: String,
2845 fields: BTreeMap<BoardField, SnapshotField>,
2846 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2847 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2848}
2849
2850impl BoardSnapshot {
2851 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2852 /// carries.
2853 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2854 self.items
2855 .iter()
2856 .map(|(item_id, values)| StatusAssignment {
2857 item_id: item_id.clone(),
2858 option: values.get(&field).cloned(),
2859 })
2860 .collect()
2861 }
2862}
2863
2864impl GitHubProjectsSource {
2865 /// Report missing configured Status options and, when `apply` is true, add them with
2866 /// a whole-list mutation that preserves every existing id and verifies the result.
2867 ///
2868 /// # Errors
2869 ///
2870 /// Refuses a board without a single-select `Status` field. A post-write difference in
2871 /// any pre-existing option id or item assignment is refused with the complete pre-write
2872 /// assignment snapshot in the diagnostic for recovery.
2873 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2874 // successful mutation, both drift refusals, source selection, missing Status, casing,
2875 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2876 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2877 // responses from entering the defensive malformed-response branches below.
2878 pub async fn status_options(
2879 &self,
2880 mode: StatusOptionsMode,
2881 ) -> Result<StatusOptionsReport, SourceError> {
2882 let before = self.status_snapshot().await?;
2883 // A terminal category's option is as configured as an open one's: a terminal
2884 // write validates it before closing and refuses when the board lacks it. Both
2885 // kinds' names are options of the one field, so both are asked for.
2886 let missing = self
2887 .statuses
2888 .wanted()
2889 .into_iter()
2890 .filter(|wanted| {
2891 !before
2892 .options
2893 .iter()
2894 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2895 })
2896 .collect::<Vec<_>>();
2897 let report = StatusOptionsReport {
2898 source: self.name.clone(),
2899 missing: missing.clone(),
2900 outcome: match (mode, missing.is_empty()) {
2901 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2902 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2903 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2904 },
2905 existing: before.options.clone(),
2906 };
2907 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2908 return Ok(report);
2909 }
2910 let mut options = before
2911 .options
2912 .iter()
2913 .map(|option| {
2914 json!({
2915 "id": option.id, "name": option.name, "color": option.color,
2916 "description": option.description,
2917 })
2918 })
2919 .collect::<Vec<_>>();
2920 options.extend(missing.iter().map(|name| {
2921 json!({
2922 "name": name, "color": "GRAY", "description": ""
2923 })
2924 }));
2925 self.graphql(
2926 graphql::STATUS_OPTIONS_UPDATE,
2927 json!({"input": {
2928 "projectId": before.board_id, "fieldId": before.field_id,
2929 "singleSelectOptions": options,
2930 }}),
2931 )
2932 .await?;
2933 let after = self.status_snapshot().await?;
2934 let options_preserved = before
2935 .options
2936 .iter()
2937 .all(|old| after.options.iter().any(|new| new == old));
2938 let additions_present = missing.iter().all(|wanted| {
2939 after
2940 .options
2941 .iter()
2942 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2943 });
2944 if !options_preserved || !additions_present || after.assignments != before.assignments {
2945 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2946 SourceError::Malformed {
2947 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2948 }
2949 })?;
2950 return Err(SourceError::Refused {
2951 message: format!(
2952 "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}"
2953 ),
2954 });
2955 }
2956 Ok(report)
2957 }
2958
2959 /// A fresh snapshot of the Status field and every board item's assignment of it.
2960 ///
2961 /// # Errors
2962 ///
2963 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2964 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2965 // Status alone, as this operation has always read it: a `Priority` field is another
2966 // operation's, so nothing about it can refuse this one.
2967 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2968 let field = board
2969 .fields
2970 .remove(&BoardField::Status)
2971 .ok_or_else(|| self.no_status_field())?;
2972 Ok(StatusSnapshot {
2973 assignments: board.assignments(BoardField::Status),
2974 board_id: board.board_id,
2975 field_id: field.field_id,
2976 options: field.options,
2977 })
2978 }
2979
2980 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2981 fn no_status_field(&self) -> SourceError {
2982 SourceError::Refused {
2983 message: format!("source {} board has no Status field", self.name),
2984 }
2985 }
2986
2987 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2988 // the real CLI loopback journey, including pagination. The individual malformed guards
2989 // are defensive validation of a schema-pinned third-party response, not separate user
2990 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2991 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2992 /// every board item's value of each, walked to the end of the board's items. A field not
2993 /// in `owned` is read past whatever it holds.
2994 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2995 let mut after: Option<String> = None;
2996 let mut snapshot: Option<BoardSnapshot> = None;
2997 loop {
2998 let data = self
2999 .graphql(
3000 graphql::STATUS_OPTIONS_SNAPSHOT,
3001 json!({
3002 "owner": self.owner, "number": self.project_number,
3003 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
3004 }),
3005 )
3006 .await?;
3007 let board = data
3008 .pointer("/owner/projectV2")
3009 .filter(|board| board.is_object())
3010 .ok_or_else(|| SourceError::Refused {
3011 message: format!(
3012 "source {} has no accessible GitHub Projects board",
3013 self.name
3014 ),
3015 })?;
3016 if board
3017 .pointer("/fields/pageInfo/hasNextPage")
3018 .and_then(Value::as_bool)
3019 != Some(false)
3020 {
3021 return Err(SourceError::Malformed {
3022 message:
3023 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
3024 .into(),
3025 });
3026 }
3027 let mut fields = BTreeMap::new();
3028 // Only the fields this setup owns, by name: a node the single-select fragment did not
3029 // match carries no name, and a person's own single-select field — a `Size`, a
3030 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
3031 // `Status` or `Priority` field without its options is malformed, not absent.
3032 // 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.
3033 for (owned, field) in board
3034 .pointer("/fields/nodes")
3035 .and_then(Value::as_array)
3036 .ok_or_else(|| SourceError::Malformed {
3037 message: "GitHub project fields.nodes is not an array".into(),
3038 })?
3039 .iter()
3040 .filter_map(|field| {
3041 let named = BoardField::named(field.get("name")?.as_str()?)?;
3042 owned.contains(&named).then_some((named, field))
3043 })
3044 {
3045 let options = field
3046 .get("options")
3047 .and_then(Value::as_array)
3048 .ok_or_else(|| SourceError::Malformed {
3049 message: "GitHub single-select field options is not an array".into(),
3050 })?
3051 .iter()
3052 .map(|option| {
3053 Ok(StatusOption {
3054 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
3055 .map_err(|message| SourceError::Malformed { message })?,
3056 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
3057 .map_err(|message| SourceError::Malformed {
3058 message: format!(
3059 "GitHub single-select option name is invalid: {message}"
3060 ),
3061 })?,
3062 color: serde_json::from_value(
3063 option.get("color").cloned().unwrap_or(Value::Null),
3064 )
3065 .map_err(|error| {
3066 SourceError::Malformed {
3067 message: format!(
3068 "GitHub single-select option color is invalid: {error}"
3069 ),
3070 }
3071 })?,
3072 description: optional_str(option, "description")?
3073 .unwrap_or_default()
3074 .to_owned(),
3075 })
3076 })
3077 .collect::<Result<Vec<_>, SourceError>>()?;
3078 let snapshot = SnapshotField {
3079 field_id: required_nonblank_str(field, "id")?.to_owned(),
3080 options,
3081 };
3082 // A board's field names are unique, so a second one is an answer that cannot
3083 // say which field the setup would act on — refused rather than one chosen.
3084 if fields.insert(owned, snapshot).is_some() {
3085 return Err(SourceError::Malformed {
3086 message: format!(
3087 "GitHub answered two {} fields for this board",
3088 owned.name()
3089 ),
3090 });
3091 }
3092 }
3093 let board_id = required_nonblank_str(board, "id")?.to_owned();
3094 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3095 board_id,
3096 fields,
3097 items: Vec::new(),
3098 });
3099 let items = board
3100 .pointer("/items/nodes")
3101 .and_then(Value::as_array)
3102 .ok_or_else(|| SourceError::Malformed {
3103 message: "GitHub project items.nodes is not an array".into(),
3104 })?;
3105 for item in items {
3106 let field_values =
3107 item.get("fieldValues")
3108 .ok_or_else(|| SourceError::Malformed {
3109 message: "GitHub project item is missing fieldValues".into(),
3110 })?;
3111 if field_values
3112 .pointer("/pageInfo/hasNextPage")
3113 .and_then(Value::as_bool)
3114 != Some(false)
3115 {
3116 return Err(SourceError::Malformed {
3117 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3118 });
3119 }
3120 let values = item
3121 .pointer("/fieldValues/nodes")
3122 .and_then(Value::as_array)
3123 .ok_or_else(|| SourceError::Malformed {
3124 message: "GitHub project item fieldValues.nodes is not an array".into(),
3125 })?;
3126 let item_id = required_nonblank_str(item, "id")?;
3127 let mut assigned = BTreeMap::new();
3128 for value in values {
3129 let Some(field) = value
3130 .pointer("/field/name")
3131 .and_then(Value::as_str)
3132 .and_then(BoardField::named)
3133 .filter(|field| owned.contains(field))
3134 else {
3135 continue;
3136 };
3137 let held = assigned.insert(
3138 field,
3139 AssignedStatusOption {
3140 id: StatusOptionId::try_from(
3141 required_str(value, "optionId")?.to_owned(),
3142 )
3143 .map_err(|message| SourceError::Malformed { message })?,
3144 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3145 .map_err(|message| SourceError::Malformed {
3146 message: format!(
3147 "GitHub assigned {} name is invalid: {message}",
3148 field.name()
3149 ),
3150 })?,
3151 },
3152 );
3153 // An item holds one value of a field, so a second one leaves no way to
3154 // tell which it holds — and a verification or recovery built on either
3155 // could restore the wrong one.
3156 if held.is_some() {
3157 return Err(SourceError::Malformed {
3158 message: format!(
3159 "GitHub answered two {} values for board item {item_id}",
3160 field.name()
3161 ),
3162 });
3163 }
3164 }
3165 current.items.push((item_id.to_owned(), assigned));
3166 }
3167 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3168 message: "GitHub project is missing items".into(),
3169 })?;
3170 let has_next = page
3171 .pointer("/pageInfo/hasNextPage")
3172 .and_then(Value::as_bool)
3173 .ok_or_else(|| SourceError::Malformed {
3174 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3175 })?;
3176 if !has_next {
3177 break;
3178 }
3179 let next =
3180 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3181 validate_cursor_progress(after.as_deref(), next)?;
3182 after = Some(next.to_owned());
3183 }
3184 snapshot.ok_or_else(|| SourceError::Malformed {
3185 message: "GitHub returned no board field snapshot".into(),
3186 })
3187 }
3188 // llmlint: ignore-end[changed_behavior_has_e2e]
3189
3190 /// Report every board field this source's configuration names and, with
3191 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3192 /// the `Priority` field when the board has none.
3193 ///
3194 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3195 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3196 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3197 /// color and description: the whole option list goes back with every existing id, because
3198 /// a re-minted id clears every item's value.
3199 ///
3200 /// # Errors
3201 ///
3202 /// Refuses a board without a single-select `Status` field. After an apply the board is
3203 /// read again, and a pre-existing option or any item's value of either field that moved is
3204 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3205 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3206 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3207 // with no Status field and a non-github-projects source through the compiled CLI against
3208 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3209 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3210 let owned: Vec<BoardField> = if self.priorities.is_some() {
3211 vec![BoardField::Status, BoardField::Priority]
3212 } else {
3213 vec![BoardField::Status]
3214 };
3215 let before = self.board_snapshot(&owned).await?;
3216 let mut plans = vec![FieldPlan {
3217 field: BoardField::Status,
3218 wanted: self.statuses.wanted(),
3219 }];
3220 if !before.fields.contains_key(&BoardField::Status) {
3221 return Err(self.no_status_field());
3222 }
3223 if let Some(mapping) = &self.priorities {
3224 plans.push(FieldPlan {
3225 field: BoardField::Priority,
3226 wanted: mapping.names().map(str::to_owned).collect(),
3227 });
3228 }
3229 // The snapshot reads single-select fields alone, so a field it did not find may still
3230 // be on the board under the name, of another type: creating one beside it would fail
3231 // part way, or leave two fields of one name. Asked of the board's own field list, and
3232 // only when a field is missing.
3233 if plans
3234 .iter()
3235 .any(|plan| !before.fields.contains_key(&plan.field))
3236 {
3237 let board = self.board_fields().await?;
3238 for plan in plans
3239 .iter()
3240 .filter(|plan| !before.fields.contains_key(&plan.field))
3241 {
3242 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3243 return Err(SourceError::Refused {
3244 message: format!(
3245 "source {}'s board has a {} field that is not a single-select field \
3246 (it is a {}), so it cannot hold this source's options; next: rename \
3247 or remove that field, then run this again",
3248 self.name,
3249 plan.field.name(),
3250 optional_str(field, "__typename")?.unwrap_or("field of another type")
3251 ),
3252 });
3253 }
3254 }
3255 }
3256 let mut reports = Vec::new();
3257 for plan in &plans {
3258 let held = before.fields.get(&plan.field);
3259 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3260 let mut missing: Vec<String> = Vec::new();
3261 for wanted in &plan.wanted {
3262 let present = existing
3263 .iter()
3264 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3265 || missing
3266 .iter()
3267 .any(|named| named.eq_ignore_ascii_case(wanted));
3268 if !present {
3269 missing.push(wanted.clone());
3270 }
3271 }
3272 let kinds = match plan.field {
3273 BoardField::Status => self.statuses.missing_by_kind(&existing),
3274 BoardField::Priority => Vec::new(),
3275 };
3276 reports.push(FieldReport {
3277 field: plan.field,
3278 exists: held.is_some(),
3279 kinds,
3280 outcome: match (mode, held.is_some(), missing.is_empty()) {
3281 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3282 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3283 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3284 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3285 },
3286 missing,
3287 existing,
3288 });
3289 }
3290 let report = FieldsReport {
3291 source: self.name.clone(),
3292 fields: reports,
3293 };
3294 let writes: Vec<&FieldReport> = report
3295 .fields
3296 .iter()
3297 .filter(|field| !field.missing.is_empty() || !field.exists)
3298 .collect();
3299 if mode == SetupMode::Plan || writes.is_empty() {
3300 return Ok(report);
3301 }
3302 let mut landed: Vec<&str> = Vec::new();
3303 for field in &writes {
3304 let added = field
3305 .missing
3306 .iter()
3307 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3308 let sent = match before.fields.get(&field.field) {
3309 Some(held) => {
3310 let mut options = held
3311 .options
3312 .iter()
3313 .map(|option| {
3314 json!({
3315 "id": option.id, "name": option.name, "color": option.color,
3316 "description": option.description,
3317 })
3318 })
3319 .collect::<Vec<_>>();
3320 options.extend(added);
3321 self.graphql(
3322 graphql::STATUS_OPTIONS_UPDATE,
3323 json!({"input": {
3324 "projectId": before.board_id, "fieldId": held.field_id,
3325 "singleSelectOptions": options,
3326 }}),
3327 )
3328 .await
3329 }
3330 None => {
3331 self.graphql(
3332 graphql::CREATE_FIELD,
3333 json!({"input": {
3334 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3335 "name": field.field.name(),
3336 "singleSelectOptions": added.collect::<Vec<_>>(),
3337 }}),
3338 )
3339 .await
3340 }
3341 };
3342 // A mutation that failed does not establish that GitHub left its field as it was,
3343 // so every failure from here on carries the recovery data a drift refusal does.
3344 match sent {
3345 Ok(_) => landed.push(field.field.name()),
3346 Err(error) => {
3347 let changed = if landed.is_empty() {
3348 String::new()
3349 } else {
3350 format!("changed the {} field and then ", landed.join(" and "))
3351 };
3352 return Err(SourceError::Refused {
3353 message: format!(
3354 "the guarded field setup {changed}failed on the {} field, which it may \
3355 have changed part way: {error}; the pre-write item assignments \
3356 are:\n{}",
3357 field.field.name(),
3358 recovery(&report, &before)?
3359 ),
3360 });
3361 }
3362 }
3363 }
3364 // The board has been written, so a verification read that fails leaves it unverified
3365 // rather than unchanged, and says what to put back.
3366 let after = match self.board_snapshot(&owned).await {
3367 Ok(after) => after,
3368 Err(error) => {
3369 return Err(SourceError::Refused {
3370 message: format!(
3371 "the guarded field setup changed the {} field and then could not read the \
3372 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3373 landed.join(" and "),
3374 recovery(&report, &before)?
3375 ),
3376 });
3377 }
3378 };
3379 let mut moved = Vec::new();
3380 for field in &report.fields {
3381 let name = field.field.name();
3382 let now = after
3383 .fields
3384 .get(&field.field)
3385 .map(|held| held.options.as_slice())
3386 .unwrap_or_default();
3387 if !field.existing.iter().all(|old| now.contains(old)) {
3388 moved.push(format!(
3389 "a pre-existing {name} option id, name, color or description"
3390 ));
3391 }
3392 if !field.missing.iter().all(|wanted| {
3393 now.iter()
3394 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3395 }) {
3396 moved.push(format!("an added {name} option"));
3397 }
3398 if after.assignments(field.field) != before.assignments(field.field) {
3399 moved.push(format!("an item's {name} value"));
3400 }
3401 }
3402 if !moved.is_empty() {
3403 return Err(SourceError::Refused {
3404 message: format!(
3405 "GitHub changed {} after the guarded field setup; the pre-write item \
3406 assignments are:\n{}",
3407 moved.join(", "),
3408 recovery(&report, &before)?
3409 ),
3410 });
3411 }
3412 Ok(report)
3413 }
3414
3415 /// Validate configuration and capture the named credential without exposing it.
3416 ///
3417 /// # Errors
3418 ///
3419 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3420 /// [`SourceError::Auth`] when the named credential is missing or empty.
3421 pub fn new(
3422 name: &SourceName,
3423 config: GitHubProjectsConfig,
3424 secrets: &dyn SecretResolver,
3425 ) -> Result<Self, SourceError> {
3426 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3427 }
3428
3429 /// The same, recording every request it sends into an accounting the caller holds too.
3430 ///
3431 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3432 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3433 /// up — passes the one it records those into, so the session total accounts for the
3434 /// whole session rather than for this source's share of it.
3435 ///
3436 /// # Errors
3437 ///
3438 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3439 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3440 pub fn recording_into(
3441 name: &SourceName,
3442 config: GitHubProjectsConfig,
3443 secrets: &dyn SecretResolver,
3444 ledger: Arc<Accounting>,
3445 ) -> Result<Self, SourceError> {
3446 if !valid_github_owner(&config.owner) {
3447 return Err(SourceError::Config {
3448 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3449 });
3450 }
3451 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3452 return Err(SourceError::Config {
3453 message: format!("project_number must be between 1 and {}", i32::MAX),
3454 });
3455 }
3456 if !valid_environment_name(&config.token_env) {
3457 return Err(SourceError::Config {
3458 message: "token_env must be a valid environment-variable name".into(),
3459 });
3460 }
3461 let repository = config
3462 .repository
3463 .as_deref()
3464 .map(RepositoryTarget::parse)
3465 .transpose()?;
3466 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3467 message: format!("endpoint is not a valid URL: {e}"),
3468 })?;
3469 if endpoint.scheme() != "https"
3470 && !(endpoint.scheme() == "http"
3471 && endpoint
3472 .host_str()
3473 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3474 {
3475 return Err(SourceError::Config {
3476 message:
3477 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3478 .into(),
3479 });
3480 }
3481 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3482 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),
3483 })?;
3484 Ok(Self {
3485 name: name.clone(),
3486 owner: config.owner,
3487 project_number: config.project_number,
3488 repository,
3489 asset_client: assets::client(&endpoint)?,
3490 endpoint,
3491 token,
3492 credential_name: config.token_env,
3493 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3494 priorities: config
3495 .priority_mapping
3496 .map(|mapping| PriorityMapping::resolve(mapping, name))
3497 .transpose()?,
3498 client: Client::builder()
3499 .user_agent("onetaskgraph")
3500 .build()
3501 .map_err(|e| SourceError::Config {
3502 message: format!("cannot build HTTP client: {e}"),
3503 })?,
3504 created: Mutex::new(Vec::new()),
3505 updated: Mutex::new(Vec::new()),
3506 commented: Mutex::new(Vec::new()),
3507 pacing: Pacing::resolve(config.pacing, name)?,
3508 last_mutation: Mutex::new(None),
3509 clock: system_clock(),
3510 numeric_repositories: tokio::sync::Mutex::new(BTreeMap::new()),
3511 board_cache: Mutex::new(None),
3512 search_cache: Mutex::new(None),
3513 narrowed_cache: Mutex::new(BTreeMap::new()),
3514 resolved_cache: Mutex::new(BTreeMap::new()),
3515 search_next: Mutex::new(BTreeMap::new()),
3516 fields_cache: Mutex::new(None),
3517 repository_cache: Mutex::new(BTreeMap::new()),
3518 ledger,
3519 })
3520 }
3521
3522 /// A snapshot of every request this source has sent, and what each cost.
3523 ///
3524 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3525 /// of them can sit side by side. When this source was built with
3526 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3527 /// point of building it that way.
3528 #[must_use]
3529 pub fn accounting(&self) -> accounting::Session {
3530 self.ledger.snapshot()
3531 }
3532
3533 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3534 /// rate limit rather than handing it straight back as an error.
3535 ///
3536 /// Retrying is safe for every document here, including the mutations, and the reason
3537 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3538 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3539 /// this replays has already taken effect. An outcome this source cannot know — the
3540 /// send failed, or the body could not be read, so the mutation may well have landed —
3541 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3542 /// attempt. A duplicate write would come from replaying one of those, and none is
3543 /// replayed.
3544 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3545 if is_mutation(query)
3546 && ![
3547 graphql::ADD_COMMENT,
3548 graphql::UPDATE_COMMENT,
3549 graphql::DELETE_COMMENT,
3550 ]
3551 .contains(&query)
3552 {
3553 let mut cache = self.resolved_cache()?;
3554 for argument in ["input", "second", "third", "clear"] {
3555 if let Some(input) = variables.get(argument) {
3556 cache.retain(|id, item| {
3557 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3558 input
3559 .get(key)
3560 .and_then(Value::as_str)
3561 .is_some_and(|value| value == id.0 || value == item.item_id)
3562 })
3563 });
3564 }
3565 }
3566 }
3567 let doing = operation_description(query);
3568 let mut waited = Duration::ZERO;
3569 let mut waits = 0_u32;
3570 let mut backoff = self.pacing.retry_backoff;
3571 loop {
3572 if is_mutation(query) {
3573 let spacing = self.reserve_mutation_slot();
3574 if !spacing.is_zero() {
3575 self.clock.sleep(spacing).await;
3576 }
3577 }
3578 let attempt = self.send_once(query, &variables).await;
3579 if is_mutation(query) {
3580 self.finish_mutation();
3581 }
3582 let limited = match attempt {
3583 Ok(data) => return Ok(data),
3584 Err(Attempt::Failed(error)) => return Err(error),
3585 Err(Attempt::Limited(limited)) => limited,
3586 };
3587 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3588 // move that extends a secondary limit, so a hint below the schedule's own next
3589 // wait is raised to it.
3590 let wait = match limited.hint {
3591 Some(hint) => Duration::from_secs(hint).max(backoff),
3592 None => backoff,
3593 };
3594 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3595 // A wait of nothing spends none of the budget, so it is exhaustion rather
3596 // than a retry. `Pacing::resolve` rules out every way of configuring one
3597 // except a budget of zero, where reporting the first refusal is the ask.
3598 if wait.is_zero() || wait > remaining {
3599 return Err(limited.exhausted(
3600 doing,
3601 waits,
3602 waited,
3603 wait,
3604 self.pacing.retry_budget,
3605 ));
3606 }
3607 self.clock.sleep(wait).await;
3608 waited += wait;
3609 waits += 1;
3610 backoff = backoff.saturating_mul(2);
3611 }
3612 }
3613
3614 /// The next moment a content-creating mutation may leave this source, as a wait from
3615 /// now.
3616 ///
3617 /// The slot is reserved under the lock and the waiting happens outside it, so two
3618 /// callers take two slots rather than the same one — and no lock is held across an
3619 /// await.
3620 ///
3621 /// The moment it is spaced from is the previous mutation's *completion*, which
3622 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3623 /// own is the wrong thing to measure from.
3624 fn reserve_mutation_slot(&self) -> Duration {
3625 if self.pacing.min_mutation_interval.is_zero() {
3626 return Duration::ZERO;
3627 }
3628 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3629 // it would turn an earlier failure into a second one for no gain.
3630 let mut last = self
3631 .last_mutation
3632 .lock()
3633 .unwrap_or_else(std::sync::PoisonError::into_inner);
3634 let now = self.clock.now();
3635 // `checked_add` rather than `+`: adding durations can panic on overflow, and
3636 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3637 let at = last.map_or(now, |previous| {
3638 previous
3639 .checked_add(self.pacing.min_mutation_interval)
3640 .map_or(now, |earliest| earliest.max(now))
3641 });
3642 *last = Some(at);
3643 at.saturating_sub(now)
3644 }
3645
3646 /// Record that a content-creating mutation has finished, so the next one is spaced
3647 /// from here rather than from the moment this one was released.
3648 ///
3649 /// This source can only choose when a request *departs*; the limiter counts when it
3650 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3651 /// departure from the last therefore hands the limiter a gap of the interval less that
3652 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3653 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3654 /// slower machine while passing on a quick one.
3655 ///
3656 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3657 /// previous request had already arrived before its response came back, so its arrival
3658 /// is no later than this moment, and the next mutation is released at least the
3659 /// interval after this moment and arrives no earlier than it is released: the gap the
3660 /// limiter measures is therefore at least the interval, whatever transit costs and on
3661 /// whatever platform. The price is that a mutation's own round trip no longer counts
3662 /// towards its spacing, which makes this source slightly slower than the configured
3663 /// rate rather than slightly faster — the safe side of a limit that punishes being
3664 /// wrong by refusing reads for the next fifty minutes.
3665 ///
3666 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3667 /// and one that never left costs only a wait nobody needed.
3668 fn finish_mutation(&self) {
3669 if self.pacing.min_mutation_interval.is_zero() {
3670 return;
3671 }
3672 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3673 let mut last = self
3674 .last_mutation
3675 .lock()
3676 .unwrap_or_else(std::sync::PoisonError::into_inner);
3677 let now = self.clock.now();
3678 // `max` rather than an assignment: a concurrent caller may already have reserved a
3679 // slot further out, and completing this request must never pull that slot back in.
3680 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3681 }
3682
3683 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3684 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3685 ///
3686 /// This is the one place a request leaves this crate, which is why the accounting is
3687 /// here rather than at each of the callers: a read path added later is counted without
3688 /// anybody remembering to count it, and
3689 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3690 /// when one is not.
3691 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3692 let Attempted {
3693 result,
3694 limits,
3695 reported_cost,
3696 } = self.attempt(query, variables).await;
3697 // No `otherwise` name: every document this source sends is one of its own, and the
3698 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3699 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3700 let outcome = match &result {
3701 Ok(_) => accounting::Outcome::Answered,
3702 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3703 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3704 };
3705 self.ledger.record(sending.finished(outcome, limits));
3706 result
3707 }
3708
3709 /// The attempt itself, with what its response said about the rate limit alongside.
3710 ///
3711 /// The two are returned together rather than recorded here because every one of the
3712 /// early exits below is a different outcome, and a record written at each of them is a
3713 /// record one of them can be added without.
3714 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3715 let mut limits = accounting::RateLimit::default();
3716 let mut reported_cost = None;
3717 let result = self
3718 .attempted(query, variables, &mut limits, &mut reported_cost)
3719 .await;
3720 Attempted {
3721 result,
3722 limits,
3723 reported_cost,
3724 }
3725 }
3726
3727 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3728 async fn attempted(
3729 &self,
3730 query: &str,
3731 variables: &Value,
3732 limits: &mut accounting::RateLimit,
3733 reported_cost: &mut Option<u64>,
3734 ) -> Result<Value, Attempt> {
3735 let response = self
3736 .client
3737 .post(self.endpoint.clone())
3738 .bearer_auth(self.token.expose_secret())
3739 .json(&json!({"query": query, "variables": variables}))
3740 .send()
3741 .await
3742 .map_err(|e| {
3743 Attempt::Failed(SourceError::Unavailable {
3744 message: format!("GitHub GraphQL request failed: {e}"),
3745 })
3746 })?;
3747 let status = response.status();
3748 let header = |name: &str| whole_seconds(response.headers().get(name));
3749 *limits = accounting::RateLimit::read(|name| {
3750 response
3751 .headers()
3752 .get(name)
3753 .and_then(|value| value.to_str().ok())
3754 .map(str::to_owned)
3755 });
3756 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3757 // that are not text at all — is "not known to be exhausted". This never makes a
3758 // response a refusal on its own: it says which limiter a refusal is attributed to
3759 // and where its hint comes from, so a value this cannot read costs a hint rather
3760 // than an answer.
3761 let exhausted = response
3762 .headers()
3763 .get("x-ratelimit-remaining")
3764 .and_then(|value| value.to_str().ok())
3765 == Some("0");
3766 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3767 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3768 // which is the same question answered as an absolute time. Nothing else here is a
3769 // hint, and a schedule is what answers a refusal that carries none.
3770 let hint = header("retry-after").or_else(|| {
3771 exhausted
3772 .then(|| header("x-ratelimit-reset"))
3773 .flatten()
3774 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3775 });
3776 // Read before it is parsed, because the evidence which tells a secondary rate
3777 // limit from a rejected credential is in the body of a response whose status says
3778 // only "forbidden" — and a non-success response was never parsed at all.
3779 let body = response.text().await.map_err(|e| {
3780 Attempt::Failed(SourceError::Unavailable {
3781 message: format!("GitHub GraphQL response could not be read: {e}"),
3782 })
3783 })?;
3784 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3785 return Err(Attempt::Limited(Limited { limiter, hint }));
3786 }
3787 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3788 return Err(Attempt::Failed(SourceError::Auth {
3789 message: format!(
3790 "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"
3791 ),
3792 }));
3793 }
3794 if !status.is_success() {
3795 return Err(Attempt::Failed(SourceError::Unavailable {
3796 message: format!("GitHub GraphQL returned HTTP {status}"),
3797 }));
3798 }
3799 // GitHub reports what a call cost only when the document asked it to, and no
3800 // document this source sends does — so this is `None` here and carries the figure
3801 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3802 // pick up is a `dryRun` probe's cost, which is some other document's.
3803 *reported_cost = serde_json::from_str::<Value>(&body)
3804 .ok()
3805 .as_ref()
3806 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3807 .and_then(Value::as_u64);
3808 self.answer(&body).map_err(Attempt::Failed)
3809 }
3810
3811 /// What one successful HTTP response says, once its GraphQL errors are read.
3812 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3813 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3814 message: format!("GitHub returned invalid JSON: {e}"),
3815 })?;
3816 let errors = body
3817 .get("errors")
3818 .map(|value| {
3819 value.as_array().ok_or_else(|| SourceError::Malformed {
3820 message: "GitHub response errors is not an array".into(),
3821 })
3822 })
3823 .transpose()?;
3824 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3825 let messages = errors
3826 .iter()
3827 .filter_map(|e| e.get("message").and_then(Value::as_str))
3828 .collect::<Vec<_>>()
3829 .join("; ");
3830 let message = if messages.is_empty() {
3831 "GitHub returned GraphQL errors".into()
3832 } else {
3833 messages
3834 };
3835 let normalized = message.to_ascii_lowercase();
3836 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3837 return Err(SourceError::Auth {
3838 message: format!(
3839 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3840 self.credential_name
3841 ),
3842 });
3843 }
3844 return Err(SourceError::Refused { message });
3845 }
3846 body.get("data")
3847 .filter(|data| data.is_object())
3848 .cloned()
3849 .ok_or_else(|| SourceError::Malformed {
3850 message: "GitHub response has no data object".into(),
3851 })
3852 }
3853
3854 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3855 // GraphQL cannot independently page them inside the outer item page. This source page is
3856 // deliberately bounded at that published maximum; the live drift journey exercises it.
3857 async fn board_page(
3858 &self,
3859 items_after: Option<&str>,
3860 items_first: u32,
3861 ) -> Result<Value, SourceError> {
3862 let data = self
3863 .graphql(
3864 graphql::BOARD,
3865 json!({"owner":self.owner,"number":self.project_number,
3866 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3867 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3868 )
3869 .await?;
3870 data.pointer("/owner/projectV2")
3871 .filter(|v| !v.is_null())
3872 .cloned()
3873 .ok_or_else(|| SourceError::Refused {
3874 message: format!(
3875 "GitHub project {}/{} was not found or is not visible to the token",
3876 self.owner, self.project_number
3877 ),
3878 })
3879 }
3880
3881 /// The search that finds the issues of this board, narrowed by `also` when it is
3882 /// given.
3883 ///
3884 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3885 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3886 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3887 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3888 /// from a task by the `parent` field each issue carries rather than by the search.
3889 fn board_search(&self, also: Option<&str>) -> String {
3890 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3891 match also {
3892 Some(also) => format!("{scope} {also}"),
3893 None => scope,
3894 }
3895 }
3896
3897 /// One issue this source reached directly, as the board item a read of the board would
3898 /// have produced — or `None` when this board does not hold it.
3899 ///
3900 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3901 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3902 /// item's own id, that item's field values, and the issue as its content. One resolver
3903 /// for both routes is what makes an issue read through a search, through its own node
3904 /// id, or through its project's sub-issues report the same title, the same status, the
3905 /// same labels and the same qualified id.
3906 ///
3907 /// An issue with no entry for *this* board is not this source's to report, which is
3908 /// what keeps an id naming some other repository's issue from being answered as an item
3909 /// of this board. That answer is given about an **exhausted** connection and never
3910 /// about an unread page: the entry is looked for on the page in hand, and only if that
3911 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3912 /// rest of it.
3913 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3914 if optional_str(issue, "__typename")? != Some("Issue") {
3915 return Ok(None);
3916 }
3917 let memberships = issue
3918 .get("projectItems")
3919 .ok_or_else(|| SourceError::Malformed {
3920 message: "GitHub issue is missing projectItems".into(),
3921 })?;
3922 let nodes = memberships
3923 .get("nodes")
3924 .and_then(Value::as_array)
3925 .ok_or_else(|| SourceError::Malformed {
3926 message: "GitHub issue projectItems.nodes is not an array".into(),
3927 })?;
3928 let held = match self.board_entry(nodes) {
3929 Some(held) => held.clone(),
3930 None => {
3931 let info = memberships
3932 .get("pageInfo")
3933 .ok_or_else(|| SourceError::Malformed {
3934 message: "GitHub issue projectItems has no pageInfo".into(),
3935 })?;
3936 // The page held no entry for this board. Whether that means the issue is
3937 // not on it is a question about the rest of the connection, and only a
3938 // connection with no rest answers it here.
3939 if !required_bool(info, "hasNextPage")? {
3940 return Ok(None);
3941 }
3942 let cursor = required_str(info, "endCursor")?;
3943 validate_cursor_progress(None, cursor)?;
3944 let issue_id = required_str(issue, "id")?;
3945 match self.board_membership(issue_id, cursor).await? {
3946 Some(held) => held,
3947 None => return Ok(None),
3948 }
3949 }
3950 };
3951 let item = json!({
3952 "id": required_str(&held, "id")?,
3953 "project": held.get("project"),
3954 "fieldValues": held.get("fieldValues"),
3955 "content": issue,
3956 });
3957 self.resolve(&item)
3958 }
3959
3960 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3961 ///
3962 /// One spelling of *which membership is this board's*, so the page a read carries and
3963 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3964 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3965 nodes.iter().find(|node| {
3966 node.pointer("/project/number").and_then(Value::as_u64)
3967 == Some(u64::from(self.project_number))
3968 })
3969 }
3970
3971 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3972 ///
3973 /// The recovery read: a page of memberships that holds no entry for this board says
3974 /// nothing about the memberships past it, so the connection is walked to exhaustion
3975 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3976 /// positive answer — the whole connection was read and no entry named this board —
3977 /// rather than a failure, and the walk is held to
3978 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3979 /// with a cursor that does not advance is refused instead of spun on.
3980 async fn board_membership(
3981 &self,
3982 issue: &str,
3983 after: &str,
3984 ) -> Result<Option<Value>, SourceError> {
3985 let mut after = after.to_owned();
3986 loop {
3987 let data = self
3988 .graphql(
3989 graphql::ISSUE_BOARD_ITEMS,
3990 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3991 "nestedFirst":NESTED_PAGE_SIZE}),
3992 )
3993 .await?;
3994 let Some(connection) = data
3995 .pointer("/node/projectItems")
3996 .filter(|value| !value.is_null())
3997 else {
3998 // The id resolved to nothing, or to something with no memberships to walk —
3999 // which is the same answer as a connection holding no entry for this board.
4000 return Ok(None);
4001 };
4002 let nodes = connection
4003 .get("nodes")
4004 .and_then(Value::as_array)
4005 .ok_or_else(|| SourceError::Malformed {
4006 message: "GitHub issue projectItems.nodes is not an array".into(),
4007 })?;
4008 if let Some(held) = self.board_entry(nodes) {
4009 return Ok(Some(held.clone()));
4010 }
4011 let info = connection
4012 .get("pageInfo")
4013 .ok_or_else(|| SourceError::Malformed {
4014 message: "GitHub issue projectItems has no pageInfo".into(),
4015 })?;
4016 let next = required_bool(info, "hasNextPage")?
4017 .then(|| required_str(info, "endCursor"))
4018 .transpose()?;
4019 match next {
4020 Some(next) => {
4021 validate_cursor_progress(Some(&after), next)?;
4022 after = next.to_owned();
4023 }
4024 None => return Ok(None),
4025 }
4026 }
4027 }
4028
4029 /// One page of a board-scoped issue search, and where the next page resumes.
4030 async fn search_page(
4031 &self,
4032 search: &str,
4033 first: u32,
4034 after: Option<&str>,
4035 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
4036 let data = self
4037 .graphql(
4038 graphql::SEARCH_ISSUES,
4039 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
4040 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
4041 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4042 )
4043 .await?;
4044 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
4045 message: "GitHub search response has no search connection".into(),
4046 })?;
4047 let mut found = Vec::new();
4048 for node in connection
4049 .get("nodes")
4050 .and_then(Value::as_array)
4051 .ok_or_else(|| SourceError::Malformed {
4052 message: "GitHub search nodes is not an array".into(),
4053 })?
4054 {
4055 if let Some(resolved) = self.resolve_issue(node).await? {
4056 found.push(resolved);
4057 }
4058 }
4059 let info = connection
4060 .get("pageInfo")
4061 .ok_or_else(|| SourceError::Malformed {
4062 message: "GitHub search connection has no pageInfo".into(),
4063 })?;
4064 let next = required_bool(info, "hasNextPage")?
4065 .then(|| required_str(info, "endCursor"))
4066 .transpose()?
4067 .map(str::to_owned);
4068 if let Some(next) = &next {
4069 validate_cursor_progress(after, next)?;
4070 }
4071 Ok((found, next))
4072 }
4073
4074 /// Every issue this board holds, completed with what this run wrote.
4075 ///
4076 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4077 /// is an index and is eventually consistent, so an issue this run created seconds ago
4078 /// can be absent from it, and a project listed straight after being written would
4079 /// otherwise be missing from its own board. What is added back is only what this
4080 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4081 /// process.
4082 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4083 let found = self.searched_issues().await?;
4084 self.completed_with_written(found, |_| true)
4085 }
4086
4087 /// Every issue this board's own search reports, walked to exhaustion, read once per
4088 /// source.
4089 ///
4090 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4091 /// needs it too and the two would otherwise walk the same search twice in one command.
4092 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4093 /// is.
4094 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4095 let cached = self.search_cache()?.clone();
4096 if let Some(held) = cached {
4097 return Ok(held);
4098 }
4099 let mut after: Option<String> = None;
4100 let mut found = Vec::new();
4101 let search = self.board_search(None);
4102 loop {
4103 let (page, next) = self
4104 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4105 .await?;
4106 found.extend(page);
4107 match next {
4108 Some(next) => after = Some(next),
4109 None => break,
4110 }
4111 }
4112 *self.search_cache()? = Some(found.clone());
4113 Ok(found)
4114 }
4115
4116 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4117 fn search_cache(
4118 &self,
4119 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4120 self.search_cache
4121 .lock()
4122 .map_err(|_| SourceError::Unavailable {
4123 message: "this source's view of the board's issues was left inconsistent by an \
4124 earlier failure; next: run the command again"
4125 .into(),
4126 })
4127 }
4128
4129 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4130 /// report.
4131 ///
4132 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4133 /// at all: the search index is behind, and a node read of an item filed moments ago can
4134 /// be too.
4135 fn completed_with_written(
4136 &self,
4137 mut found: Vec<Resolved>,
4138 keep: impl Fn(&Resolved) -> bool,
4139 ) -> Result<Vec<Resolved>, SourceError> {
4140 for own in self.created()?.iter().filter(|own| keep(own)) {
4141 if !found.iter().any(|item| item.id == own.id) {
4142 found.push(own.clone());
4143 }
4144 }
4145 Ok(found)
4146 }
4147
4148 /// What resolving one node id reached.
4149 ///
4150 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4151 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4152 /// is completed by a read of the draft itself rather than reported as nothing.
4153 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4154 let asked = self
4155 .graphql(
4156 graphql::ISSUE,
4157 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4158 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4159 )
4160 .await;
4161 let data = match asked {
4162 Ok(data) => data,
4163 // A string that is not a node id at all is not a failure to report: it is an id
4164 // this board does not hold, which is what every read of one already answers.
4165 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4166 Err(error) => return Err(error),
4167 };
4168 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4169 return Ok(Reached::Nothing);
4170 };
4171 if optional_str(node, "__typename")? == Some("DraftIssue") {
4172 return Ok(Reached::Draft);
4173 }
4174 Ok(match self.resolve_issue(node).await? {
4175 Some(item) => Reached::Held(Box::new(item)),
4176 None => Reached::Nothing,
4177 })
4178 }
4179
4180 /// One item of this board by its own id, whatever kind it is.
4181 ///
4182 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4183 /// run wrote is read first, because a node read of an item created moments ago can
4184 /// still be behind the board field values written onto it — see [`Self::created`].
4185 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4186 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4187 return Ok(Some(own.clone()));
4188 }
4189 match self.reach(id).await? {
4190 Reached::Held(item) => Ok(Some(*item)),
4191 Reached::Nothing => Ok(None),
4192 Reached::Draft => self.draft_by_id(id).await,
4193 }
4194 }
4195
4196 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4197 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4198 /// than one request per id.
4199 ///
4200 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4201 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4202 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4203 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4204 /// is completed by a read of the draft, exactly as there.
4205 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4206 let mut found: Vec<Option<Option<Resolved>>> = {
4207 let created = self.created()?;
4208 ids.iter()
4209 .map(|id| {
4210 created
4211 .iter()
4212 .find(|own| own.id == *id)
4213 .map(|own| Some(own.clone()))
4214 })
4215 .collect()
4216 };
4217 let unread: Vec<NativeId> = ids
4218 .iter()
4219 .zip(&found)
4220 .filter(|(_, found)| found.is_none())
4221 .map(|(id, _)| id.clone())
4222 .collect();
4223 let mut read = Vec::with_capacity(unread.len());
4224 if let [one] = unread.as_slice() {
4225 read.push(self.item_by_id(one).await?);
4226 } else {
4227 for batch in unread.chunks(DETAIL_BATCH) {
4228 let data = match self
4229 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4230 .await
4231 {
4232 Ok(data) => data,
4233 Err(error) if unresolvable_node(&error) => {
4234 for id in batch {
4235 read.push(self.item_by_id(id).await?);
4236 }
4237 continue;
4238 }
4239 Err(error) => return Err(error),
4240 };
4241 for (slot, id) in batch.iter().enumerate() {
4242 let node =
4243 data.get(format!("i{slot}"))
4244 .ok_or_else(|| SourceError::Malformed {
4245 message: format!(
4246 "GitHub answered a batch read with no item for {}",
4247 id.0
4248 ),
4249 })?;
4250 read.push(if node.is_null() {
4251 None
4252 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4253 self.draft_by_id(id).await?
4254 } else {
4255 if optional_str(node, "__typename")? == Some("Issue")
4256 && required_str(node, "id")? != id.0
4257 {
4258 return Err(SourceError::Malformed {
4259 message: format!(
4260 "GitHub answered the read of {} with issue {}",
4261 id.0,
4262 required_str(node, "id")?
4263 ),
4264 });
4265 }
4266 self.resolve_issue(node).await?
4267 });
4268 }
4269 }
4270 }
4271 let mut read = read.into_iter();
4272 Ok(found
4273 .iter_mut()
4274 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4275 .collect())
4276 }
4277
4278 fn resolved_cache(
4279 &self,
4280 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4281 self.resolved_cache
4282 .lock()
4283 .map_err(|_| SourceError::Unavailable {
4284 message: "resolved item records were left inconsistent; run the command again"
4285 .into(),
4286 })
4287 }
4288
4289 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4290 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4291 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4292 let cached = self.resolved_cache()?.get(id).cloned();
4293 match cached {
4294 Some(item) => Ok(Some(item)),
4295 None => self.item_by_id(id).await,
4296 }
4297 }
4298
4299 /// One board draft by its own id, with the board item it sits in — or `None` when no
4300 /// item of this board is that draft's.
4301 ///
4302 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4303 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4304 /// links a draft to one board item, so the page this read carries is the whole of that
4305 /// connection, and a page that reports more than it holds is refused rather than read
4306 /// as an answer about memberships nobody read.
4307 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4308 let data = self
4309 .graphql(
4310 graphql::DRAFT,
4311 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4312 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4313 )
4314 .await?;
4315 // Gone between the two reads is an answer — the draft is no longer there. Anything
4316 // else than the draft [`Self::reach`] was just told this id is, is not one.
4317 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4318 return Ok(None);
4319 };
4320 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4321 return Err(SourceError::Malformed {
4322 message: format!(
4323 "GitHub answered {} as a draft and then as something else",
4324 id.0
4325 ),
4326 });
4327 }
4328 if required_str(draft, "id")? != id.0 {
4329 return Err(SourceError::Malformed {
4330 message: format!("GitHub answered a different draft for {}", id.0),
4331 });
4332 }
4333 let memberships = draft
4334 .get("projectV2Items")
4335 .ok_or_else(|| SourceError::Malformed {
4336 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4337 })?;
4338 let nodes = memberships
4339 .get("nodes")
4340 .and_then(Value::as_array)
4341 .ok_or_else(|| SourceError::Malformed {
4342 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4343 })?;
4344 let info = memberships
4345 .get("pageInfo")
4346 .ok_or_else(|| SourceError::Malformed {
4347 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4348 })?;
4349 // Read whether or not this board's entry is on the page: a page claiming more than
4350 // the one item GitHub links a draft to is a malformed answer either way.
4351 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4352 return Err(SourceError::Malformed {
4353 message: format!(
4354 "GitHub draft {} reports more board items than the one GitHub links a draft \
4355 to",
4356 id.0
4357 ),
4358 });
4359 }
4360 if let Some(node) = nodes.first()
4361 && node
4362 .pointer("/project/number")
4363 .and_then(Value::as_u64)
4364 .is_none()
4365 {
4366 return Err(SourceError::Malformed {
4367 message: format!(
4368 "GitHub draft {} board item has no numeric project number",
4369 id.0
4370 ),
4371 });
4372 }
4373 let Some(held) = self.board_entry(nodes) else {
4374 return Ok(None);
4375 };
4376 if required_str(
4377 held.get("project").ok_or_else(|| SourceError::Malformed {
4378 message: format!("GitHub draft {} board item has no project", id.0),
4379 })?,
4380 "id",
4381 )? != self.board_fields().await?.id.as_str()
4382 {
4383 return Ok(None);
4384 }
4385 let item = json!({
4386 "id": required_str(held, "id")?,
4387 "project": held.get("project"),
4388 "fieldValues": held.get("fieldValues"),
4389 "content": draft,
4390 });
4391 self.resolve(&item)
4392 }
4393
4394 /// The board's own id and field definitions, for a write whose item does not carry
4395 /// them — never its items.
4396 ///
4397 /// A board this command has already listed supplies them, since it read them beside its
4398 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4399 /// is consulted about which items the board holds: see the module documentation for
4400 /// why a question about one known item is answered by reading that item.
4401 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4402 if let Some(board) = self.board_cache()?.as_ref() {
4403 return Ok(BoardFields {
4404 id: BoardId::parse(&board.id)?,
4405 fields: board.fields.clone(),
4406 });
4407 }
4408 if let Some(held) = self.fields_cache()?.clone() {
4409 return Ok(held);
4410 }
4411 let data = self
4412 .graphql(
4413 graphql::BOARD_FIELDS,
4414 json!({"owner":self.owner,"number":self.project_number,
4415 "nestedFirst":NESTED_PAGE_SIZE}),
4416 )
4417 .await?;
4418 self.fields_read(&data)
4419 }
4420
4421 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4422 /// the rest of this command.
4423 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4424 let board = data
4425 .pointer("/boardFields/projectV2")
4426 .filter(|value| !value.is_null())
4427 .ok_or_else(|| SourceError::Refused {
4428 message: format!(
4429 "GitHub project {}/{} was not found or is not visible to the token",
4430 self.owner, self.project_number
4431 ),
4432 })?;
4433 let read = BoardFields {
4434 id: BoardId::parse(required_str(board, "id")?)?,
4435 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4436 };
4437 *self.fields_cache()? = Some(read.clone());
4438 Ok(read)
4439 }
4440
4441 /// Read what creating an issue in `repository` needs and this command has not read yet —
4442 /// the board's fields and the repository's node id — in one request when it needs both.
4443 ///
4444 /// When either is already known this sends nothing, and the other is read by its own
4445 /// document where it is asked for, so no create reads anything twice.
4446 async fn creation_context(
4447 &self,
4448 repository: &RepositoryTarget,
4449 incoming: &Incoming<'_>,
4450 ) -> Result<(), SourceError> {
4451 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4452 if fields_known || self.repository_cache()?.contains_key(repository) {
4453 return Ok(());
4454 }
4455 let data = self
4456 .graphql(
4457 graphql::CREATION_CONTEXT,
4458 json!({"owner":self.owner,"number":self.project_number,
4459 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4460 "repositoryName":repository.name}),
4461 )
4462 .await?;
4463 self.fields_read(&data)?;
4464 self.repository_read(&data, repository, incoming)?;
4465 Ok(())
4466 }
4467
4468 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4469 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4470 self.fields_cache
4471 .lock()
4472 .map_err(|_| SourceError::Unavailable {
4473 message: "this source's view of the board's fields was left inconsistent by an \
4474 earlier failure; next: run the command again"
4475 .into(),
4476 })
4477 }
4478
4479 /// What a write to `item` needs of the board, read off that item when it says enough and
4480 /// off [`Self::board_fields`] when it does not.
4481 ///
4482 /// A node read of an item names its board and carries the definition of every field it
4483 /// holds a value of — so an item naming its board, holding a value of the origin field,
4484 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4485 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4486 /// of may still be on the board, and a view reading it as absent would refuse a write the
4487 /// board can take or skip a field write the board needs, so such an item — and a create,
4488 /// which has no item yet — takes the board's fields from their own read instead.
4489 async fn fields_for(
4490 &self,
4491 item: Option<&Resolved>,
4492 writes_status: bool,
4493 selects_priority: bool,
4494 ) -> Result<BoardFields, SourceError> {
4495 if let Some(board) = item.and_then(Resolved::carried_board) {
4496 return Ok(board);
4497 }
4498 if let Some(item) = item
4499 && let Some(board_id) = item.named_board()
4500 && item.defines(ORIGIN_FIELD)
4501 && (!writes_status || item.defines("Status"))
4502 && (!selects_priority || item.defines(PRIORITY_FIELD))
4503 {
4504 return Ok(BoardFields {
4505 id: board_id,
4506 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4507 });
4508 }
4509 self.board_fields().await
4510 }
4511
4512 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4513 /// when that id names nothing here with a sub-issue relationship to walk.
4514 ///
4515 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4516 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4517 /// empty vector is a project that holds nothing.
4518 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4519 let mut after: Option<String> = None;
4520 let mut children = Vec::new();
4521 loop {
4522 let asked = self
4523 .graphql(
4524 graphql::SUB_ISSUES,
4525 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4526 "nestedFirst":NESTED_PAGE_SIZE,
4527 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4528 )
4529 .await;
4530 let data = match asked {
4531 Ok(data) => data,
4532 // A string that is not a node id at all is not a failure to report: it is
4533 // the ordinary answer to a selector naming a project by its name.
4534 Err(error) if unresolvable_node(&error) => return Ok(None),
4535 Err(error) => return Err(error),
4536 };
4537 let Some(connection) = data
4538 .pointer("/node/subIssues")
4539 .filter(|value| !value.is_null())
4540 else {
4541 // No such node, or one with no sub-issue relationship — a board draft is
4542 // the one this board can really hold.
4543 return Ok(None);
4544 };
4545 for node in connection
4546 .get("nodes")
4547 .and_then(Value::as_array)
4548 .ok_or_else(|| SourceError::Malformed {
4549 message: "GitHub subIssues.nodes is not an array".into(),
4550 })?
4551 {
4552 if let Some(resolved) = self.resolve_issue(node).await? {
4553 children.push(resolved);
4554 }
4555 }
4556 let info = connection
4557 .get("pageInfo")
4558 .ok_or_else(|| SourceError::Malformed {
4559 message: "GitHub subIssues connection has no pageInfo".into(),
4560 })?;
4561 let next = required_bool(info, "hasNextPage")?
4562 .then(|| required_str(info, "endCursor"))
4563 .transpose()?;
4564 match next {
4565 Some(next) => {
4566 validate_cursor_progress(after.as_deref(), next)?;
4567 after = Some(next.to_owned());
4568 }
4569 None => return Ok(Some(children)),
4570 }
4571 }
4572 }
4573
4574 /// Which issue of this board a project *name* is, or `None` when none is.
4575 ///
4576 /// One bounded query which filters on that name at the server, rather than a walk of
4577 /// every issue the board holds. The name is compared again here: the qualifier narrows
4578 /// what GitHub sends, and this source decides what it names.
4579 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4580 let search = self.board_search(Some(&title_qualifier(name)));
4581 let mut after = None;
4582 loop {
4583 let (candidates, next) = self
4584 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4585 .await?;
4586 if let Some(item) = candidates.into_iter().find(|item| {
4587 item.kind == BoardKind::Work(ItemKind::Project)
4588 && item.title.eq_ignore_ascii_case(name)
4589 }) {
4590 return Ok(Some(item.id));
4591 }
4592 match next {
4593 Some(next) => after = Some(next),
4594 None => return Ok(None),
4595 }
4596 }
4597 }
4598
4599 /// Everything filed under one project of this board: the sub-issues of the issue that
4600 /// project is.
4601 ///
4602 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4603 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4604 /// gains projects, or as another project gains tasks.
4605 ///
4606 /// A qualified id names the issue and is asked for its sub-issues directly: one
4607 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4608 /// read as a project *name*, which costs the one bounded search
4609 /// [`Self::project_by_name`] makes.
4610 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4611 let (project, children) = match self.sub_issues(selector).await? {
4612 Some(children) => (selector.clone(), children),
4613 None => match self.project_by_name(&selector.0).await? {
4614 Some(project) => {
4615 let children = self.sub_issues(&project).await?.unwrap_or_default();
4616 (project, children)
4617 }
4618 None => return Ok(Vec::new()),
4619 },
4620 };
4621 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4622 }
4623
4624 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4625 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4626 ///
4627 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4628 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4629 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4630 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4631 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4632 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4633 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4634 /// rather than silently narrowing a caller's answer.
4635 ///
4636 /// The instant is written to the second, rounded down, which can only widen what the
4637 /// search returns; confirmation against each candidate's own comments is what makes the
4638 /// answer exact. The search is an index that lags a write by a second or two — the module
4639 /// documentation records it — so a caller that asks again from its last instant should
4640 /// overlap the two by more than that.
4641 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4642 let found = self.searched(&updated_qualifier(since)).await?;
4643 self.completed_with_written(found, |_| true)
4644 }
4645
4646 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4647 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4648 ///
4649 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4650 /// own record is the fresher of the two.
4651 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4652 let search = self.board_search(Some(also));
4653 let mut after: Option<String> = None;
4654 let mut found = Vec::new();
4655 loop {
4656 let (page, next) = self
4657 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4658 .await?;
4659 found.extend(page);
4660 match next {
4661 Some(next) => after = Some(next),
4662 None => return Ok(found),
4663 }
4664 }
4665 }
4666
4667 /// A bounded task answer; the versioned cursor carries the connection position, how
4668 /// many rows of the page starting there were already handed out, and the own-write ids
4669 /// already observed, including across a new source instance.
4670 ///
4671 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4672 /// sliced from the pages it needs; why is the module documentation's paging contract.
4673 async fn search_tasks(
4674 &self,
4675 query: &TaskQuery,
4676 page: &PageRequest,
4677 also: &str,
4678 ) -> Result<Page<Task>, SourceError> {
4679 let mut position = match &page.cursor {
4680 None => SearchPosition::default(),
4681 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4682 .ok()
4683 .filter(|position| {
4684 position.version == SEARCH_CURSOR_VERSION
4685 && position.connection.valid_resume(position.offset)
4686 })
4687 .ok_or_else(|| SourceError::Config {
4688 message: "page cursor is invalid".into(),
4689 })?,
4690 };
4691 let search = self.board_search(Some(also));
4692 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4693 let own = self.with_own_writes(Vec::new())?;
4694 // An issue this process commented on is a candidate of a comment-activity read
4695 // whether or not the search has caught up with the comment; see `Self::commented`.
4696 let commented = match query.commented_since {
4697 Some(_) => self.commented()?.clone(),
4698 None => Vec::new(),
4699 };
4700 for id in own.iter().map(|item| &item.id).chain(&commented) {
4701 if !position.own.contains(id) {
4702 position.own.push(id.clone());
4703 }
4704 }
4705 let mut tasks = Vec::new();
4706 while !position.connection.exhausted() && tasks.len() < limit {
4707 let first = SEARCH_PAGE_SIZE;
4708 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4709 let key =
4710 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4711 .expect("search page key is serializable");
4712 let cached = if query.commented_since.is_none() {
4713 self.narrowed_cache()?.get(&key).cloned()
4714 } else {
4715 None
4716 };
4717 let (found, next) = match cached {
4718 Some(found) => {
4719 let next = self
4720 .search_next
4721 .lock()
4722 .map_err(|_| SourceError::Unavailable {
4723 message:
4724 "search pagination was left inconsistent; run the command again"
4725 .into(),
4726 })?
4727 .get(&key)
4728 .cloned()
4729 .flatten();
4730 (found, next)
4731 }
4732 None => {
4733 let (found, next) = self
4734 .search_page(&search, first, position.connection.after())
4735 .await?;
4736 if query.commented_since.is_none() {
4737 self.search_next
4738 .lock()
4739 .map_err(|_| SourceError::Unavailable {
4740 message:
4741 "search pagination was left inconsistent; run the command again"
4742 .into(),
4743 })?
4744 .insert(key.clone(), next.clone());
4745 self.narrowed_cache()?.insert(key, found.clone());
4746 }
4747 (found, next)
4748 }
4749 };
4750 let rows = found.len();
4751 for mut item in found.into_iter().skip(position.offset) {
4752 if tasks.len() == limit {
4753 break;
4754 }
4755 position.offset += 1;
4756 if position.own.contains(&item.id) {
4757 if position.seen.contains(&item.id) {
4758 continue;
4759 }
4760 position.seen.push(item.id.clone());
4761 // The search's own copy of an issue this process only commented on is as
4762 // good as a node read of it, since its comments are read either way.
4763 let only_commented = commented.contains(&item.id)
4764 && !own.iter().any(|written| written.id == item.id);
4765 if !only_commented {
4766 let updated_at = item.updated_at;
4767 let Some(written) = self.search_written(&own, &item.id).await? else {
4768 continue;
4769 };
4770 item = written;
4771 item.updated_at = item.updated_at.max(updated_at);
4772 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4773 }
4774 }
4775 if item.kind == BoardKind::Work(ItemKind::Task) {
4776 let task = item.task()?;
4777 if task_matches(&task, query, &query.project)
4778 && self.commented_since(&item, query.commented_since).await?
4779 {
4780 tasks.push(task);
4781 }
4782 }
4783 }
4784 if position.offset < rows {
4785 continue;
4786 }
4787 position.offset = 0;
4788 position.connection = match next {
4789 Some(after) => SearchConnection::Continuing {
4790 after: Cursor(after),
4791 },
4792 None => SearchConnection::Exhausted {},
4793 };
4794 }
4795 if position.connection.exhausted() {
4796 for id in position.own.clone() {
4797 if position.seen.contains(&id) {
4798 continue;
4799 }
4800 if tasks.len() == limit {
4801 break;
4802 }
4803 position.seen.push(id.clone());
4804 let Some(item) = self.search_written(&own, &id).await? else {
4805 continue;
4806 };
4807 if item.kind == BoardKind::Work(ItemKind::Task) {
4808 let task = item.task()?;
4809 if task_matches(&task, query, &query.project)
4810 && self.commented_since(&item, query.commented_since).await?
4811 {
4812 tasks.push(task);
4813 }
4814 }
4815 }
4816 }
4817 let more = !position.connection.exhausted()
4818 || position.own.iter().any(|id| !position.seen.contains(id));
4819 Ok(Page {
4820 items: tasks,
4821 next: more.then(|| {
4822 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4823 }),
4824 })
4825 }
4826
4827 /// A resumed process has the ids but no write records; resolve only a record the
4828 /// current page needs, by its uncached node read rather than the lagging search index.
4829 async fn search_written(
4830 &self,
4831 own: &[Resolved],
4832 id: &NativeId,
4833 ) -> Result<Option<Resolved>, SourceError> {
4834 match own.iter().find(|item| item.id == *id) {
4835 Some(item) => Ok(Some(item.clone())),
4836 None => self.item_by_id(id).await,
4837 }
4838 }
4839
4840 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4841 /// without enumerating the board — or `None` for a query carrying none of the three, which
4842 /// keeps the reads it always had.
4843 ///
4844 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4845 /// because it names at most a handful of items. Text and metadata are answered by one
4846 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4847 /// further by `updated:>=` when the query also asks for comment activity, since both
4848 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4849 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4850 ///
4851 /// Completed with what this process wrote, its own record winning over the index's copy
4852 /// of the same item: see [`Self::with_own_writes`].
4853 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4854 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4855 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4856 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4857 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4858 None => qualifiers,
4859 }),
4860 (None, None) => return Ok(None),
4861 };
4862 // A question about comment activity is asked afresh every time, as it always was: it
4863 // is the one a caller polls from one source while waiting for the index, and an
4864 // answer held from the first poll would be the answer to every later one.
4865 let key = query.commented_since.is_none().then(|| asked.key());
4866 let cached = match &key {
4867 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4868 None => None,
4869 };
4870 let found = match cached {
4871 Some(found) => found,
4872 None => {
4873 let found = match &asked {
4874 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4875 Narrowing::Search(also) => self.searched(also).await?,
4876 };
4877 if let Some(key) = key {
4878 self.narrowed_cache()?.insert(key, found.clone());
4879 }
4880 found
4881 }
4882 };
4883 self.with_own_writes(found).map(Some)
4884 }
4885
4886 /// The candidates for a project or unscoped document query carrying a searchable text,
4887 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4888 /// which keeps the read it always had.
4889 ///
4890 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4891 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4892 /// costs is the issues that match and never the board. Its answer is held for the command
4893 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4894 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4895 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4896 /// process wrote: see [`Self::with_own_writes`].
4897 async fn text_searched(
4898 &self,
4899 text: Option<&TextQuery>,
4900 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4901 let Some(also) = text_qualifiers(text) else {
4902 return Ok(None);
4903 };
4904 let key = Narrowing::Search(also.clone()).key();
4905 let cached = self.narrowed_cache()?.get(&key).cloned();
4906 let found = match cached {
4907 Some(found) => found,
4908 None => {
4909 let found = self.searched(&also).await?;
4910 self.narrowed_cache()?.insert(key, found.clone());
4911 found
4912 }
4913 };
4914 self.with_own_writes(found).map(Some)
4915 }
4916
4917 /// Every item of this board that may carry `origin` — a superset of those that do — found
4918 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4919 ///
4920 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4921 /// which reads the field every carrier holds, whichever release wrote it — and the
4922 /// board-scoped issue search for the same id as a phrase in the body, where this source
4923 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4924 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4925 /// query's, exactly.
4926 ///
4927 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4928 /// already ended is sent its last cursor again, which answers an empty page, so the one
4929 /// document serves every page of either. What the two leave is stated in the module
4930 /// documentation: a carrier another process added within the last second or two, before
4931 /// either index has it.
4932 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4933 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4934 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4935 let mut items_after: Option<String> = None;
4936 let mut search_after: Option<String> = None;
4937 let mut found: Vec<Resolved> = Vec::new();
4938 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4939 if !found.iter().any(|held| held.id == resolved.id) {
4940 found.push(resolved);
4941 }
4942 };
4943 loop {
4944 let data = self
4945 .graphql(
4946 graphql::ORIGIN_LOOKUP,
4947 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4948 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4949 "itemsAfter":items_after,"searchAfter":search_after,
4950 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4951 "duplicates":true}),
4952 )
4953 .await?;
4954 let items = data
4955 .pointer("/originItems/projectV2/items")
4956 .filter(|value| !value.is_null())
4957 .ok_or_else(|| SourceError::Refused {
4958 message: format!(
4959 "GitHub project {}/{} was not found or is not visible to the token",
4960 self.owner, self.project_number
4961 ),
4962 })?;
4963 for item in optional_nodes(Some(items), "project items")?
4964 .into_iter()
4965 .flatten()
4966 {
4967 // The board's own items list its drafts too, and a draft is not an issue: no
4968 // narrowed read answers with one, whatever its origin field holds.
4969 if let Some(resolved) = self.resolve(item)?
4970 && resolved.content_kind == ContentKind::Issue
4971 {
4972 keep(resolved, &mut found);
4973 }
4974 }
4975 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4976 message: "GitHub search response has no search connection".into(),
4977 })?;
4978 for node in optional_nodes(Some(searched), "search")?
4979 .into_iter()
4980 .flatten()
4981 {
4982 if let Some(resolved) = self.resolve_issue(node).await? {
4983 keep(resolved, &mut found);
4984 }
4985 }
4986 let items_next = resumed(items, items_after.as_deref())?;
4987 let search_next = resumed(searched, search_after.as_deref())?;
4988 if !items_next.has_more() && !search_next.has_more() {
4989 return Ok(found);
4990 }
4991 items_after = items_next.cursor();
4992 search_after = search_next.cursor();
4993 }
4994 }
4995
4996 /// `found`, with every item this process created or wrote in its place, and every one of
4997 /// them the read did not report added.
4998 ///
4999 /// This process's own record wins over the read's copy of the same item, because a read
5000 /// of an item written moments ago can still be behind what was written onto it — the
5001 /// origin field included, which is the one a narrowed read is confirmed against — and a
5002 /// read that still names an item under a predicate this process's write moved it out of
5003 /// must not return it. The one thing the read knows that the record cannot is when GitHub
5004 /// last saw the item change, which is what a comment-activity read rules a candidate out
5005 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
5006 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
5007 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
5008 // A board draft is not an issue, so no narrowed read returns one, and this process
5009 // having written one does not make it an answer either.
5010 let own: Vec<Resolved> = self
5011 .created()?
5012 .iter()
5013 .chain(self.updated()?.iter())
5014 .filter(|own| own.content_kind == ContentKind::Issue)
5015 .cloned()
5016 .collect();
5017 for mut own in own {
5018 self.resolved_cache()?.insert(own.id.clone(), own.clone());
5019 match found.iter_mut().find(|read| read.id == own.id) {
5020 Some(read) => {
5021 own.updated_at = own.updated_at.max(read.updated_at);
5022 *read = own;
5023 }
5024 None => found.push(own),
5025 }
5026 }
5027 Ok(found)
5028 }
5029
5030 /// Whether `item` has a comment created or last edited at or after `since` — always, when
5031 /// there is no instant to hold it to.
5032 ///
5033 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
5034 /// or after the instant moved it there: an issue not updated since holds no such comment,
5035 /// and its comments are never asked for — unless this process commented on it in this
5036 /// command, when the `updatedAt` held may predate that comment; see [`Self::commented`]. Otherwise its comments are walked, oldest first,
5037 /// only as far as the first that matches. A board draft is not an issue and has no
5038 /// comments, so it never matches.
5039 async fn commented_since(
5040 &self,
5041 item: &Resolved,
5042 since: Option<DateTime<Utc>>,
5043 ) -> Result<bool, SourceError> {
5044 let Some(since) = since else {
5045 return Ok(true);
5046 };
5047 if item.content_kind == ContentKind::DraftIssue {
5048 return Ok(false);
5049 }
5050 // An `updatedAt` this process's own record or a lagging index holds can predate a
5051 // comment this process wrote since, so only an issue it did not comment on is ruled
5052 // out by one.
5053 if item.updated_at.is_some_and(|updated| updated < since)
5054 && !self.commented()?.contains(&item.id)
5055 {
5056 return Ok(false);
5057 }
5058 let query = TaskQuery {
5059 commented_since: Some(since),
5060 ..TaskQuery::default()
5061 };
5062 let mut after: Option<String> = None;
5063 loop {
5064 let data = self
5065 .graphql(
5066 graphql::ISSUE_COMMENTS,
5067 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
5068 )
5069 .await?;
5070 let Some(connection) = data
5071 .get("node")
5072 .filter(|value| !value.is_null())
5073 .and_then(|node| node.get("comments"))
5074 .filter(|value| !value.is_null())
5075 else {
5076 // Removed since the search reported it: no longer an issue with comments.
5077 return Ok(false);
5078 };
5079 let comments = optional_nodes(Some(connection), "issue comments")?
5080 .into_iter()
5081 .flatten()
5082 .map(comment_from)
5083 .collect::<Result<Vec<_>, _>>()?;
5084 if query.comments_match(&comments) {
5085 return Ok(true);
5086 }
5087 match next_cursor(connection)? {
5088 Some(next) => {
5089 validate_cursor_progress(after.as_deref(), &next.0)?;
5090 after = Some(next.0);
5091 }
5092 None => return Ok(false),
5093 }
5094 }
5095 }
5096
5097 /// Every item on the board: the union of both enumerations GitHub offers of one.
5098 ///
5099 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5100 /// board **draft** and reads the board's own fields beside its items, and only the search
5101 /// reports an item that connection is behind on. The module documentation is where the lag and the
5102 /// measurements behind it are written down.
5103 ///
5104 /// A search result is admitted on the same terms as any other issue this source reaches
5105 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5106 /// names *this* board — so an issue the index still believes is here after it was taken
5107 /// off is refused rather than reported.
5108 ///
5109 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5110 /// which is what the cache could otherwise have broken.
5111 async fn board(&self) -> Result<Board, SourceError> {
5112 let cached = self.board_cache()?.clone();
5113 let mut board = match cached {
5114 Some(board) => board,
5115 None => {
5116 let read = self.read_board().await?;
5117 *self.board_cache()? = Some(read.clone());
5118 read
5119 }
5120 };
5121 for held in self.searched_issues().await? {
5122 if !board.items.iter().any(|item| item.id == held.id) {
5123 board.items.push(held);
5124 }
5125 }
5126 for own in self.created()?.iter() {
5127 if !board.items.iter().any(|item| item.id == own.id) {
5128 board.items.push(own.clone());
5129 }
5130 }
5131 Ok(board)
5132 }
5133
5134 /// This process's own view of the board, or the refusal a poisoned lock is.
5135 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5136 self.board_cache
5137 .lock()
5138 .map_err(|_| SourceError::Unavailable {
5139 message: "this source's view of the board was left inconsistent by an earlier \
5140 failure; next: run the command again"
5141 .into(),
5142 })
5143 }
5144
5145 /// Bring this process's own view of the board up to an item it has just written.
5146 ///
5147 /// A created item goes to `created`, which is what completes a board read GitHub's own
5148 /// eventual consistency has left behind. An item that was already there is replaced
5149 /// where it sits, so a second write of it in the same command reads its real parent
5150 /// rather than the one it had before the first write.
5151 ///
5152 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5153 /// that wins: an item this same run created is held in `created` and not in the cached
5154 /// board, and `board` completes the cached board *from* `created`, so replacing only
5155 /// the cached copy of such an item replaces nothing and the read still reports the
5156 /// title it was created with. The search is the third, and it is the one an item the
5157 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5158 /// source is least able to re-read, so leaving it out would put the stale title back on
5159 /// the only items the completion in [`Self::board`] exists for.
5160 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5161 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5162 if created {
5163 self.created()?.push(item);
5164 return Ok(());
5165 }
5166 {
5167 let mut own = self.created()?;
5168 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5169 *held = item;
5170 return Ok(());
5171 }
5172 }
5173 {
5174 let mut own = self.updated()?;
5175 match own.iter_mut().find(|held| held.id == item.id) {
5176 Some(held) => *held = item.clone(),
5177 None => own.push(item.clone()),
5178 }
5179 }
5180 if let Some(board) = self.board_cache()?.as_mut()
5181 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5182 {
5183 *held = item.clone();
5184 }
5185 if let Some(found) = self.search_cache()?.as_mut()
5186 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5187 {
5188 *held = item.clone();
5189 }
5190 for found in self.narrowed_cache()?.values_mut() {
5191 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5192 *held = item.clone();
5193 }
5194 }
5195 Ok(())
5196 }
5197
5198 /// Forget one item this process has just deleted, from every half of its own view.
5199 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5200 self.resolved_cache()?.remove(id);
5201 self.created()?.retain(|own| own.id != *id);
5202 self.updated()?.retain(|own| own.id != *id);
5203 self.commented()?.retain(|own| own != id);
5204 if let Some(board) = self.board_cache()?.as_mut() {
5205 board.items.retain(|item| item.id != *id);
5206 }
5207 if let Some(found) = self.search_cache()?.as_mut() {
5208 found.retain(|item| item.id != *id);
5209 }
5210 for found in self.narrowed_cache()?.values_mut() {
5211 found.retain(|item| item.id != *id);
5212 }
5213 Ok(())
5214 }
5215
5216 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5217 fn narrowed_cache(
5218 &self,
5219 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5220 self.narrowed_cache
5221 .lock()
5222 .map_err(|_| SourceError::Unavailable {
5223 message: "this source's view of a narrowed read was left inconsistent by an \
5224 earlier failure; next: run the command again"
5225 .into(),
5226 })
5227 }
5228
5229 /// Every page of the board, read from GitHub.
5230 async fn read_board(&self) -> Result<Board, SourceError> {
5231 let mut after: Option<String> = None;
5232 let mut items = Vec::new();
5233 let mut board;
5234 loop {
5235 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5236 for item in page
5237 .pointer("/items/nodes")
5238 .and_then(Value::as_array)
5239 .ok_or_else(|| SourceError::Malformed {
5240 message: "GitHub project items.nodes is not an array".into(),
5241 })?
5242 {
5243 if let Some(resolved) = self.resolve(item)? {
5244 items.push(resolved);
5245 }
5246 }
5247 let info = page
5248 .pointer("/items/pageInfo")
5249 .ok_or_else(|| SourceError::Malformed {
5250 message: "GitHub project items have no pageInfo".into(),
5251 })?;
5252 let has_next = required_bool(info, "hasNextPage")?;
5253 let next = has_next
5254 .then(|| required_str(info, "endCursor"))
5255 .transpose()?;
5256 board = page.clone();
5257 match next {
5258 Some(next) => {
5259 validate_cursor_progress(after.as_deref(), next)?;
5260 after = Some(next.to_owned());
5261 }
5262 None => break,
5263 }
5264 }
5265 Ok(Board {
5266 id: required_str(&board, "id")?.to_owned(),
5267 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5268 items,
5269 })
5270 }
5271
5272 /// The existing items this source has written, for completing a narrowed read that is
5273 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5274 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5275 self.updated.lock().map_err(|_| SourceError::Unavailable {
5276 message: "this source's record of what it wrote in this run was left inconsistent \
5277 by an earlier failure; next: run the command again"
5278 .into(),
5279 })
5280 }
5281
5282 /// The issues this source has commented on in this command; see
5283 /// [`Self::commented`](GitHubProjectsSource::commented).
5284 fn commented(&self) -> Result<std::sync::MutexGuard<'_, Vec<NativeId>>, SourceError> {
5285 self.commented.lock().map_err(|_| SourceError::Unavailable {
5286 message: "this source's record of what it commented on in this run was left \
5287 inconsistent by an earlier failure; next: run the command again"
5288 .into(),
5289 })
5290 }
5291
5292 /// Called only once GitHub has answered the comment write, so an issue whose comment
5293 /// failed is never made a candidate a later read would pay a node read for.
5294 fn remember_commented(&self, issue: &NativeId) -> Result<(), SourceError> {
5295 let mut commented = self.commented()?;
5296 if !commented.contains(issue) {
5297 commented.push(issue.clone());
5298 }
5299 Ok(())
5300 }
5301
5302 /// The items this source has created, for completing a board read that is behind.
5303 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5304 self.created.lock().map_err(|_| SourceError::Unavailable {
5305 message: "this source's record of what it created in this run was left \
5306 inconsistent by an earlier failure; next: run the command again"
5307 .into(),
5308 })
5309 }
5310
5311 /// One board item as this source reports it, or `None` for content it ignores.
5312 ///
5313 /// A pull request is neither a project nor a task — it is somebody's change, not a
5314 /// unit of plan — and an item whose content the token cannot see has nothing to
5315 /// report at all.
5316 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5317 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5318 message: "GitHub project item is missing content".into(),
5319 })?;
5320 if content.is_null() {
5321 return Ok(None);
5322 }
5323 let content_kind = match required_str(content, "__typename")? {
5324 "Issue" => ContentKind::Issue,
5325 "DraftIssue" => ContentKind::DraftIssue,
5326 _ => return Ok(None),
5327 };
5328 let field_values = item
5329 .get("fieldValues")
5330 .ok_or_else(|| SourceError::Malformed {
5331 message: "GitHub project item is missing fieldValues".into(),
5332 })?;
5333 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5334 let nodes = field_values
5335 .get("nodes")
5336 .and_then(Value::as_array)
5337 .ok_or_else(|| SourceError::Malformed {
5338 message: "GitHub project item fieldValues.nodes is not an array".into(),
5339 })?;
5340 if let Some(labels) = content.get("labels") {
5341 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5342 }
5343 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5344 let (body, slot) = metadata_body(raw_body.clone())?;
5345 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5346 .map(|id| NativeId(id.to_owned()));
5347 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5348 // to read one from; it is a task, and never a project.
5349 let sub_issues = match content_kind {
5350 ContentKind::Issue => sub_issue_total(content)?,
5351 ContentKind::DraftIssue => 0,
5352 };
5353 let content_id = required_str(content, "id")?;
5354 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5355 message: format!("GitHub issue {content_id}: {message}"),
5356 })?;
5357 let raw_title = required_str(content, "title")?;
5358 // The design prefix is read *first*, before either of the two rules that separate
5359 // a project from a task. A document is not work whatever sub-issues it has and
5360 // whatever marker it carries, and reading the prefix later would make a design
5361 // issue with none of either an empty project.
5362 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5363 BoardKind::Document
5364 } else if parent.is_some() {
5365 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5366 // under a project is that project's task even when it has sub-issues of its
5367 // own.
5368 BoardKind::Work(ItemKind::Task)
5369 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5370 BoardKind::Work(ItemKind::Project)
5371 } else {
5372 BoardKind::Work(ItemKind::Task)
5373 };
5374 // The title a person wrote, which for a document is the one without the prefix —
5375 // the same way `content` above is the body without this source's metadata slot.
5376 let title = match kind {
5377 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5378 BoardKind::Work(_) => raw_title.to_owned(),
5379 };
5380 let own_repository = content
5381 .pointer("/repository/nameWithOwner")
5382 .and_then(Value::as_str)
5383 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5384 .transpose()
5385 .map_err(|message| SourceError::Malformed { message })?;
5386 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5387 Repository::from_metadata(&slot)
5388 .map_err(|message| SourceError::Malformed { message })?
5389 } else {
5390 own_repository.clone().into_iter().collect()
5391 };
5392 let id = NativeId(content_id.to_owned());
5393 // Read only for a task, because only a task has either list: a project or a
5394 // document holding one of these keys holds nothing this source reports, and the
5395 // keys are left out of its caller-visible metadata all the same.
5396 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5397 let listed = |key: &str| {
5398 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5399 .map_err(|message| SourceError::Malformed { message })
5400 };
5401 (
5402 listed(TaskRef::DELIVERS_KEY)?,
5403 listed(TaskRef::DELIVERED_BY_KEY)?,
5404 )
5405 } else {
5406 (Vec::new(), Vec::new())
5407 };
5408 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5409 let priority = self.held_priority(nodes)?;
5410 // Present when the item was reached through its own issue, whose board entry
5411 // names the board; a read of the board's own items has the board already. An
5412 // empty id names nothing a field write could address, so it is read as absent and
5413 // the write goes back to reading the board.
5414 let board_id = item
5415 .pointer("/project/id")
5416 .and_then(Value::as_str)
5417 .filter(|id| !id.is_empty());
5418 let resolved = Resolved {
5419 item_id: required_str(item, "id")?.to_owned(),
5420 id,
5421 content_kind,
5422 kind,
5423 title,
5424 body: body.filter(|value| !value.is_empty()),
5425 raw_body,
5426 status: self
5427 .statuses
5428 .status(kind.status_kind(), option, closed, reason),
5429 option: option.map(str::to_owned),
5430 priority,
5431 closed,
5432 delivers,
5433 delivered_by,
5434 labels: labels(content)?,
5435 parent,
5436 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5437 number: match content_kind {
5438 ContentKind::Issue => Some(issue_number(content)?),
5439 // A draft is filed in no repository, so nothing ever numbered it:
5440 // `DraftIssue` declares no `number` at all, exactly as it declares no
5441 // `subIssuesSummary` the branch above reads.
5442 ContentKind::DraftIssue => None,
5443 },
5444 url: optional_str(content, "url")?.map(str::to_owned),
5445 created_at: optional_time(content, "createdAt")?,
5446 updated_at: optional_time(content, "updatedAt")?,
5447 own_repository,
5448 repositories,
5449 slot,
5450 board_id: board_id.map(str::to_owned),
5451 fields: field_definitions(nodes),
5452 board_fields: Self::carried_board_fields(content, board_id)?,
5453 blocked_by: carried_blocked_by(content)?,
5454 };
5455 self.resolved_cache()?
5456 .insert(resolved.id.clone(), resolved.clone());
5457 Ok(Some(resolved))
5458 }
5459
5460 /// The field definitions of the board `board_id` names — the project this issue's own
5461 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5462 /// `None` when the read carried none, carried no entry for that board, or the board item
5463 /// named no board, which a write then answers by reading the board's fields itself.
5464 ///
5465 /// Matched by the board's node id and never by its number alone: a project number is
5466 /// unique only within its owner, so another owner's board numbered alike can sit on the
5467 /// same page, and its field and option ids address nothing on this one.
5468 fn carried_board_fields(
5469 content: &Value,
5470 board_id: Option<&str>,
5471 ) -> Result<Option<Value>, SourceError> {
5472 let (Some(nodes), Some(board_id)) = (
5473 content.pointer("/boards/nodes").and_then(Value::as_array),
5474 board_id,
5475 ) else {
5476 return Ok(None);
5477 };
5478 let Some(board) = nodes.iter().find_map(|node| {
5479 let project = node.get("project")?;
5480 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5481 }) else {
5482 return Ok(None);
5483 };
5484 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5485 return Ok(None);
5486 };
5487 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5488 Ok(Some(fields.clone()))
5489 }
5490
5491 /// What one board item's `Priority` field says, through this instance's mapping.
5492 ///
5493 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5494 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5495 /// option the mapping does not name is kept as itself — never read as a level or as
5496 /// `none` — for a read of the task to report by name.
5497 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5498 let Some(mapping) = &self.priorities else {
5499 return Ok(HeldPriority::Read(Priority::None));
5500 };
5501 // A value of the field that names no option — a text field someone called `Priority` —
5502 // is malformed rather than `none`: reading it as no priority would let the next copy
5503 // clear one a person set.
5504 let Some(option) = field_values
5505 .iter()
5506 .find(|value| {
5507 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5508 })
5509 .map(|value| required_str(value, "name"))
5510 .transpose()?
5511 else {
5512 return Ok(HeldPriority::Read(Priority::None));
5513 };
5514 Ok(mapping.priority_of(option).map_or_else(
5515 || HeldPriority::Unmapped(option.to_owned()),
5516 HeldPriority::Read,
5517 ))
5518 }
5519
5520 /// What one board item's status is read from: its `Status` option, whether its issue
5521 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5522 /// three into the status it reports.
5523 fn status_parts<'a>(
5524 field_values: &'a [Value],
5525 content: &'a Value,
5526 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5527 let option = field_values
5528 .iter()
5529 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5530 .map(|value| required_str(value, "name"))
5531 .transpose()?;
5532 let closed = optional_str(content, "state")? == Some("CLOSED");
5533 Ok((option, closed, optional_str(content, "stateReason")?))
5534 }
5535
5536 /// The board Status option this write selects, or the refusal that says why not.
5537 ///
5538 /// The mapped option is required for both open and terminal targets. A terminal write
5539 /// validates it before changing either representation, so it can never fall back to
5540 /// closing an issue whose board cannot display the matching status.
5541 ///
5542 /// Answers the field's id, the option's id, and the option's name as the board spells
5543 /// it — which is the name a read of the item reports once it sits there.
5544 fn column_for(
5545 &self,
5546 fields: &Value,
5547 kind: ItemKind,
5548 category: StatusCategory,
5549 target: &StatusTarget,
5550 ) -> Result<Option<(String, String, String)>, SourceError> {
5551 let Some(wanted) = target.option() else {
5552 return Ok(None);
5553 };
5554 let missing = |detail: &str| SourceError::Refused {
5555 message: format!(
5556 "{} status {} of source {} needs the board Status option {wanted:?}, and \
5557 {detail}; next: add that option to the board, which `onetaskgraph sources \
5558 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5559 it has",
5560 kind.marker(),
5561 category_name(category),
5562 self.name,
5563 self.name,
5564 category_name(category),
5565 kind.marker()
5566 ),
5567 };
5568 let Some(field) = Board::field(fields, "Status")? else {
5569 return Err(missing("this board has no Status field"));
5570 };
5571 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5572 return Err(missing(
5573 "this board's Status field is not a single-select field",
5574 ));
5575 }
5576 let option = field
5577 .get("options")
5578 .and_then(Value::as_array)
5579 .and_then(|options| {
5580 options.iter().find(|option| {
5581 option
5582 .get("name")
5583 .and_then(Value::as_str)
5584 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5585 })
5586 });
5587 match option {
5588 None => Err(missing("this board does not have it")),
5589 Some(option) => Ok(Some((
5590 required_str(field, "id")?.to_owned(),
5591 required_str(option, "id")?.to_owned(),
5592 required_str(option, "name")?.to_owned(),
5593 ))),
5594 }
5595 }
5596
5597 /// The refusal a status that closes an issue is answered with over a board draft.
5598 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5599 SourceError::Refused {
5600 message: format!(
5601 "status {} of source {} closes the item's issue, and GitHub draft items have \
5602 no open or closed state",
5603 category_name(category),
5604 self.name
5605 ),
5606 }
5607 }
5608
5609 /// What a status write to one item needs of the board: the board's id and the
5610 /// definition of its `Status` field, read off the item when the item says both.
5611 ///
5612 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5613 /// and its `Status` value carries that field's definition, options and all. An item that
5614 /// does not say — no board id, or no `Status` value to read the field off — takes them
5615 /// from [`Self::board_fields`], which reads no item.
5616 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5617 if let Some(board) = item.carried_board() {
5618 return Ok(board);
5619 }
5620 if item.defines("Status")
5621 && let Some(board_id) = item.named_board()
5622 {
5623 return Ok(BoardFields {
5624 id: board_id,
5625 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5626 });
5627 }
5628 self.board_fields().await
5629 }
5630
5631 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5632 async fn set_status(
5633 &self,
5634 id: &NativeId,
5635 category: StatusCategory,
5636 ) -> Result<Option<Status>, SourceError> {
5637 // Refused before anything is read, in the words a write of the same status is.
5638 let target = self.resolved_target(ItemKind::Task, category)?;
5639 let Some(mut item) = self
5640 .bound_item(id)
5641 .await?
5642 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5643 else {
5644 return Ok(None);
5645 };
5646 let board = self.status_board(&item).await?;
5647 let (field, option, name) = self
5648 .column_for(&board.fields, ItemKind::Task, category, &target)?
5649 .ok_or_else(|| SourceError::Malformed {
5650 message: format!(
5651 "status {} of source {} names no board Status option",
5652 category_name(category),
5653 self.name
5654 ),
5655 })?;
5656 if item.status.category == category && item.option.as_deref() == Some(&name) {
5657 return Ok(Some(item.status));
5658 }
5659 match &target {
5660 StatusTarget::Terminal(_, reason) => {
5661 if item.content_kind == ContentKind::DraftIssue {
5662 return Err(self.closes_a_draft(category));
5663 }
5664 self.set_item_field(
5665 board.id.as_str(),
5666 &item.item_id,
5667 &field,
5668 json!({"singleSelectOptionId": option}),
5669 )
5670 .await?;
5671 self.update_content(
5672 ContentKind::Issue,
5673 &item.id,
5674 json!({"stateInput": state_input(Some(&target))}),
5675 )
5676 .await?;
5677 item.closed = true;
5678 item.status =
5679 self.statuses
5680 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5681 item.option = Some(name);
5682 }
5683 StatusTarget::Column(_) => {
5684 // An option is what an open item's status is, so a closed issue is reopened
5685 // first — sitting closed in the column, it would read back as closed. A draft has
5686 // no state to reopen.
5687 if item.content_kind == ContentKind::Issue && item.closed {
5688 self.update_content(
5689 ContentKind::Issue,
5690 &item.id,
5691 json!({"stateInput": state_input(Some(&target))}),
5692 )
5693 .await?;
5694 item.closed = false;
5695 }
5696 self.set_item_field(
5697 board.id.as_str(),
5698 &item.item_id,
5699 &field,
5700 json!({"singleSelectOptionId": option}),
5701 )
5702 .await?;
5703 item.status = self
5704 .statuses
5705 .status(ItemKind::Task, Some(&name), false, None);
5706 item.option = Some(name);
5707 }
5708 StatusTarget::Disabled(_) => {
5709 unreachable!("resolved_target refused a disabled status")
5710 }
5711 }
5712 let status = item.status.clone();
5713 self.remember_written(item, false)?;
5714 Ok(Some(status))
5715 }
5716
5717 /// Replace one task's `delivered_by` and nothing else; see
5718 /// [`TaskSource::set_delivered_by`].
5719 ///
5720 /// One update of the body, which differs from the body GitHub holds only inside the
5721 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5722 async fn replace_delivered_by(
5723 &self,
5724 id: &NativeId,
5725 delivered_by: &[TaskRef],
5726 ) -> Result<Option<()>, SourceError> {
5727 let entries = TaskRef::listed(
5728 TaskRef::DELIVERED_BY_KEY,
5729 id,
5730 Some(&self.name),
5731 delivered_by.to_vec(),
5732 )
5733 .map_err(|message| SourceError::Refused { message })?;
5734 let Some(mut item) = self
5735 .bound_item(id)
5736 .await?
5737 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5738 else {
5739 return Ok(None);
5740 };
5741 let mut slot = item.slot.clone();
5742 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5743 self.write_slot(&mut item, &slot).await?;
5744 item.delivered_by = entries;
5745 self.remember_written(item, false)?;
5746 Ok(Some(()))
5747 }
5748
5749 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5750 /// see [`TaskSource::set_task_metadata`].
5751 ///
5752 /// `None` when this board holds no item by that id, or holds one of another kind. The
5753 /// answer is the item as this source now reads it, so what a caller is told the key
5754 /// holds is what the slot holds.
5755 ///
5756 /// A key already holding the value is answered without a write, compared as JSON rather
5757 /// than as the body's bytes: a slot a person spelled with other whitespace would
5758 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5759 async fn set_slot_key(
5760 &self,
5761 id: &NativeId,
5762 kind: BoardKind,
5763 key: &MetadataKey,
5764 value: &Value,
5765 ) -> Result<Option<Resolved>, SourceError> {
5766 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5767 return Ok(None);
5768 };
5769 if item.slot.get(key.as_str()) == Some(value) {
5770 return Ok(Some(item));
5771 }
5772 let mut slot = item.slot.clone();
5773 slot.insert(key.as_str().to_owned(), value.clone());
5774 self.write_slot(&mut item, &slot).await?;
5775 self.remember_written(item.clone(), false)?;
5776 Ok(Some(item))
5777 }
5778
5779 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5780 /// `item` up to what that write left.
5781 ///
5782 /// The body sent differs from the body GitHub holds only inside the slot — see
5783 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5784 /// the mutation the item's content takes, so a board draft's body is written with
5785 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5786 async fn write_slot(
5787 &self,
5788 item: &mut Resolved,
5789 slot: &BTreeMap<String, Value>,
5790 ) -> Result<(), SourceError> {
5791 let held = item.raw_body.clone().unwrap_or_default();
5792 let body = with_slot(&held, slot)?;
5793 if body != held {
5794 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5795 .await?;
5796 }
5797 let (visible, slot) = metadata_body(Some(body.clone()))?;
5798 item.body = visible.filter(|value| !value.is_empty());
5799 item.raw_body = Some(body);
5800 item.slot = slot;
5801 Ok(())
5802 }
5803
5804 /// This instance's target for a category written to an item of `kind`, refusing one
5805 /// that kind has no option for — before anything is read or written.
5806 ///
5807 /// Nothing here mutates the board's option set to make room for a status. GitHub
5808 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5809 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5810 /// field and every item's status.
5811 fn resolved_target(
5812 &self,
5813 kind: ItemKind,
5814 category: StatusCategory,
5815 ) -> Result<StatusTarget, SourceError> {
5816 let target = self.statuses.target(kind, category).clone();
5817 let StatusTarget::Disabled(why) = target else {
5818 return Ok(target);
5819 };
5820 let refusal = why.refusal(&self.name, category, kind);
5821 // Why there is no shipped default, which is the question a person meeting this
5822 // refusal on a source that never mentioned the category asks.
5823 let shipped_none = match category {
5824 StatusCategory::Draft => Some(
5825 "draft has no shipped default because GitHub draft issues cannot have \
5826 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5827 ),
5828 StatusCategory::Unknown => Some(
5829 "unknown has no shipped default because this board keeps no open-ended status \
5830 word: every word classified unknown is written to the one board Status option \
5831 status_mapping.unknown names",
5832 ),
5833 _ => None,
5834 };
5835 Err(match (refusal, shipped_none, why) {
5836 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5837 SourceError::Refused {
5838 message: format!("{message}; {note}"),
5839 }
5840 }
5841 (refusal, _, _) => refusal,
5842 })
5843 }
5844
5845 /// What writing `priority` does to one item's `Priority` field on this board, or the
5846 /// refusal naming what the board lacks.
5847 ///
5848 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5849 /// none already, or of an item not created yet. Every other priority selects the option
5850 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5851 /// without that option, is refused rather than given one: reads and writes never create
5852 /// a field or an option.
5853 fn priority_write(
5854 &self,
5855 fields: &Value,
5856 existing: Option<&Resolved>,
5857 priority: Priority,
5858 ) -> Result<Option<PriorityWrite>, SourceError> {
5859 let Some(mapping) = &self.priorities else {
5860 return Err(self.holds_no_priority());
5861 };
5862 let Some(wanted) = mapping.option(priority) else {
5863 if !existing.is_some_and(Resolved::holds_priority) {
5864 return Ok(None);
5865 }
5866 let field =
5867 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5868 message: format!(
5869 "an item holding a {PRIORITY_FIELD} value was read without that field"
5870 ),
5871 })?;
5872 return Ok(Some(PriorityWrite::Clear {
5873 field: required_str(field, "id")?.to_owned(),
5874 }));
5875 };
5876 let missing = |detail: &str| SourceError::Refused {
5877 message: format!(
5878 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5879 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5880 it, or point priority_mapping.{priority} of this source at an option the board \
5881 has",
5882 self.name, self.name
5883 ),
5884 };
5885 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5886 return Err(missing(&format!(
5887 "this board has no {PRIORITY_FIELD} field"
5888 )));
5889 };
5890 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5891 return Err(missing(&format!(
5892 "this board's {PRIORITY_FIELD} field is not a single-select field"
5893 )));
5894 }
5895 // An options list that is absent or not a list is an answer this source cannot read,
5896 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5897 let option = field
5898 .get("options")
5899 .and_then(Value::as_array)
5900 .ok_or_else(|| SourceError::Malformed {
5901 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5902 })?
5903 .iter()
5904 .find(|option| {
5905 option
5906 .get("name")
5907 .and_then(Value::as_str)
5908 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5909 })
5910 .ok_or_else(|| missing("this board does not have it"))?;
5911 Ok(Some(PriorityWrite::Select {
5912 field: required_str(field, "id")?.to_owned(),
5913 option: required_str(option, "id")?.to_owned(),
5914 }))
5915 }
5916
5917 /// Apply one priority write to one board item.
5918 async fn write_priority(
5919 &self,
5920 board_id: &str,
5921 item_id: &str,
5922 write: &PriorityWrite,
5923 ) -> Result<(), SourceError> {
5924 match write {
5925 PriorityWrite::Select { field, option } => {
5926 self.set_item_field(
5927 board_id,
5928 item_id,
5929 field,
5930 json!({"singleSelectOptionId": option}),
5931 )
5932 .await
5933 }
5934 PriorityWrite::Clear { field } => {
5935 let data = self
5936 .graphql(
5937 graphql::CLEAR_FIELD,
5938 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5939 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5940 )
5941 .await?;
5942 let returned = data
5943 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5944 .ok_or_else(|| SourceError::Malformed {
5945 message: "GitHub field clear returned no project item".into(),
5946 })?;
5947 if required_str(returned, "id")? != item_id {
5948 return Err(SourceError::Malformed {
5949 message: "GitHub field clear returned the wrong project item".into(),
5950 });
5951 }
5952 Ok(())
5953 }
5954 }
5955 }
5956
5957 /// The refusal a priority is answered with by an instance configured with no
5958 /// `priority_mapping`, which holds none.
5959 fn holds_no_priority(&self) -> SourceError {
5960 SourceError::Refused {
5961 message: format!(
5962 "source {} holds no task priority: its configuration sets no priority_mapping; \
5963 next: set priority_mapping on this source, then run `onetaskgraph sources \
5964 fields {} --apply` to set its board up",
5965 self.name, self.name
5966 ),
5967 }
5968 }
5969
5970 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5971 ///
5972 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5973 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5974 async fn set_priority(
5975 &self,
5976 id: &NativeId,
5977 priority: Priority,
5978 ) -> Result<Option<Priority>, SourceError> {
5979 if self.priorities.is_none() {
5980 return Err(self.holds_no_priority());
5981 }
5982 let Some(mut item) = self
5983 .bound_item(id)
5984 .await?
5985 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5986 else {
5987 return Ok(None);
5988 };
5989 if priority == Priority::None && !item.holds_priority() {
5990 return Ok(Some(priority));
5991 }
5992 // The item's own read carries the field's definition whenever it holds a value of
5993 // it, which a clear always does; a select onto an item holding none reads the board.
5994 let board = match (item.carried_board(), item.named_board()) {
5995 (Some(board), _) => board,
5996 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5997 id,
5998 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5999 },
6000 _ => self.board_fields().await?,
6001 };
6002 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
6003 return Ok(Some(priority));
6004 };
6005 let (document, root, input) = match write {
6006 PriorityWrite::Select { field, option } => (
6007 graphql::UPDATE_FIELD,
6008 "updateProjectV2ItemFieldValue",
6009 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
6010 ),
6011 PriorityWrite::Clear { field } => (
6012 graphql::CLEAR_FIELD,
6013 "clearProjectV2ItemFieldValue",
6014 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
6015 ),
6016 };
6017 let data = self
6018 .graphql(
6019 document,
6020 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
6021 )
6022 .await?;
6023 let returned = data
6024 .get(root)
6025 .and_then(|value| value.get("projectV2Item"))
6026 .ok_or_else(|| SourceError::Malformed {
6027 message: "GitHub priority write returned no project item".into(),
6028 })?;
6029 if required_str(returned, "id")? != item.item_id {
6030 return Err(SourceError::Malformed {
6031 message: "GitHub priority write returned the wrong project item".into(),
6032 });
6033 }
6034 let value = returned
6035 .get("fieldValueByName")
6036 .ok_or_else(|| SourceError::Malformed {
6037 message: "GitHub priority write returned no priority read-back".into(),
6038 })?;
6039 if !value.is_null()
6040 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
6041 {
6042 return Err(SourceError::Malformed {
6043 message: "GitHub priority read-back is not a Priority field value".into(),
6044 });
6045 }
6046 let values = if value.is_null() {
6047 Vec::new()
6048 } else {
6049 vec![value.clone()]
6050 };
6051 item.priority = self.held_priority(&values)?;
6052 let answer = item.task()?.priority;
6053 self.remember_written(item, false)?;
6054 Ok(Some(answer))
6055 }
6056
6057 /// Replace one task's visible body and nothing else; see
6058 /// [`TaskSource::set_task_content`].
6059 ///
6060 /// One update of the body, which differs from the body GitHub holds only outside the
6061 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
6062 /// this source keeps there reads back as it was. A body that would not change is not
6063 /// sent at all.
6064 async fn replace_content(
6065 &self,
6066 id: &NativeId,
6067 content: &str,
6068 ) -> Result<Option<()>, SourceError> {
6069 let Some(mut item) = self
6070 .bound_item(id)
6071 .await?
6072 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6073 else {
6074 return Ok(None);
6075 };
6076 let held = item.raw_body.clone().unwrap_or_default();
6077 let body = with_content(&held, content)?;
6078 // Checked before anything is sent: content ending in what this source reads as its own
6079 // metadata slot would read back as metadata rather than as the content it was.
6080 let (visible, slot) = metadata_body(Some(body.clone()))?;
6081 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
6082 return Err(SourceError::Refused {
6083 message: format!(
6084 "this content ends in what source {} reads as its own metadata slot \
6085 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6086 as content; next: remove that trailing block from the content",
6087 self.name
6088 ),
6089 });
6090 }
6091 if body != held {
6092 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6093 .await?;
6094 }
6095 item.body = visible.filter(|value| !value.is_empty());
6096 item.raw_body = Some(body);
6097 item.slot = slot;
6098 self.remember_written(item, false)?;
6099 Ok(Some(()))
6100 }
6101
6102 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
6103 ///
6104 /// One read of the item — which carries the board's field definitions and the issue's
6105 /// `blockedBy`, so neither is read again — and then only what differs from it: the
6106 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
6107 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
6108 /// the title, the body — visible content and metadata slot together — and a state change.
6109 /// So an update naming any of title, body, metadata, status and priority is one read and
6110 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
6111 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
6112 /// as a whole write does; an open one selects its option and then reopens. The origin
6113 /// field is never written: an update is of an item that already exists, whose origin is
6114 /// what it is.
6115 ///
6116 /// The task answered is the item as those writes left it, built from the read and what was
6117 /// sent rather than read again — the same record a later read in this run answers from.
6118 async fn targeted_update(
6119 &self,
6120 id: &NativeId,
6121 update: &TaskUpdate,
6122 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6123 // Everything this source can refuse without reading the item is refused first, in the
6124 // words a whole write of the same fields is refused with.
6125 update.consistent()?;
6126 if update
6127 .title
6128 .as_deref()
6129 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6130 {
6131 return Err(SourceError::Refused {
6132 message: format!(
6133 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6134 spells a document, so it would read back as one rather than as a task; \
6135 retitle it",
6136 self.name
6137 ),
6138 });
6139 }
6140 if let Some(delivers) = &update.delivers {
6141 TaskRef::listed(
6142 TaskRef::DELIVERS_KEY,
6143 id,
6144 Some(&self.name),
6145 delivers.clone(),
6146 )
6147 .map_err(|message| SourceError::Refused { message })?;
6148 }
6149 if self.priorities.is_none()
6150 && update
6151 .priority
6152 .is_some_and(|priority| priority != Priority::None)
6153 {
6154 return Err(self.holds_no_priority());
6155 }
6156 let target = update
6157 .status
6158 .as_ref()
6159 .map(|status| self.resolved_target(ItemKind::Task, status.category))
6160 .transpose()?;
6161 let Some(mut item) = self
6162 .bound_item(id)
6163 .await?
6164 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6165 else {
6166 return Ok(None);
6167 };
6168 let before = item.task()?;
6169
6170 let mut status_move = None;
6171 if let (Some(status), Some(target)) = (&update.status, target) {
6172 let board = self.status_board(&item).await?;
6173 let (field, option, name) = self
6174 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6175 .ok_or_else(|| SourceError::Malformed {
6176 message: format!(
6177 "status {} of source {} names no board Status option",
6178 category_name(status.category),
6179 self.name
6180 ),
6181 })?;
6182 let terminal = matches!(target, StatusTarget::Terminal(_, _));
6183 if terminal && item.content_kind == ContentKind::DraftIssue {
6184 return Err(self.closes_a_draft(status.category));
6185 }
6186 let landed = match &target {
6187 StatusTarget::Terminal(_, reason) => {
6188 self.statuses
6189 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6190 }
6191 _ => self
6192 .statuses
6193 .status(ItemKind::Task, Some(&name), false, None),
6194 };
6195 let option_moves = item
6196 .option
6197 .as_deref()
6198 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6199 let state_moves = item.content_kind == ContentKind::Issue
6200 && (item.closed != terminal || (terminal && item.status != landed));
6201 if let Some(moves) = Moves::of(option_moves, state_moves) {
6202 status_move = Some(StatusMove {
6203 board: board.id,
6204 field,
6205 option,
6206 name,
6207 target,
6208 landed,
6209 moves,
6210 });
6211 }
6212 }
6213
6214 let mut priority_move = None;
6215 if let Some(priority) = update.priority
6216 && self.priorities.is_some()
6217 && item.priority != HeldPriority::Read(priority)
6218 {
6219 let board = match (item.carried_board(), item.named_board()) {
6220 (Some(board), _) => board,
6221 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6222 id: board,
6223 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6224 },
6225 _ => self.board_fields().await?,
6226 };
6227 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6228 priority_move = Some((board.id, write, priority));
6229 }
6230 }
6231
6232 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6233 // recorded in the slot, and the slot travels in the one body update below.
6234 let edges = match &update.depends_on {
6235 Some(edges) => Some(
6236 self.partition_edges(
6237 BoardKind::Work(ItemKind::Task),
6238 item.content_kind,
6239 item.blocked_by.as_deref(),
6240 edges,
6241 )
6242 .await?,
6243 ),
6244 None => None,
6245 };
6246
6247 let mut slot = item.slot.clone();
6248 for (key, value) in &update.metadata_set {
6249 slot.insert(key.as_str().to_owned(), value.clone());
6250 }
6251 for key in &update.metadata_remove {
6252 slot.remove(key.as_str());
6253 }
6254 if let Some(delivers) = &update.delivers {
6255 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6256 }
6257 if let Some((_, recorded)) = &edges {
6258 record_edges(&mut slot, recorded);
6259 }
6260 let held = item.raw_body.clone().unwrap_or_default();
6261 let content = match &update.content {
6262 Some(content) => with_content(&held, content)?,
6263 None => held.clone(),
6264 };
6265 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6266 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6267 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6268 let body = if slot == item.slot {
6269 content
6270 } else {
6271 with_slot(&content, &slot)?
6272 };
6273 // Checked before anything is sent, as a content write checks it: content ending in
6274 // what this source reads as its own slot would read back as metadata.
6275 let (visible, read) = metadata_body(Some(body.clone()))?;
6276 let wanted = update.content.as_deref().or(item.body.as_deref());
6277 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6278 return Err(SourceError::Refused {
6279 message: format!(
6280 "this content ends in what source {} reads as its own metadata slot \
6281 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6282 as content; next: remove that trailing block from the content",
6283 self.name
6284 ),
6285 });
6286 }
6287 let recorded_moves =
6288 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6289
6290 // One `updateIssue` carries all three, because every mutation spends the secondary
6291 // limiter and the title, body and state are one mutation's inputs.
6292 let mut fields = serde_json::Map::new();
6293 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6294 fields.insert("title".to_owned(), json!(title));
6295 }
6296 if body != held {
6297 fields.insert("body".to_owned(), json!(body));
6298 }
6299 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6300 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6301 }
6302 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6303 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6304 // without undoing an earlier field when a later one fails — so a body written before a
6305 // board field the board then refused would be left changed. Written after every other
6306 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6307 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6308 // first, together in one request — a terminal option selected before the issue
6309 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6310 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6311 let mut clear: Option<(&BoardId, &str)> = None;
6312 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6313 board_writes.push((
6314 &moving.board,
6315 (
6316 moving.field.clone(),
6317 json!({"singleSelectOptionId": moving.option}),
6318 ),
6319 ));
6320 }
6321 match &priority_move {
6322 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6323 board,
6324 (field.clone(), json!({"singleSelectOptionId": option})),
6325 )),
6326 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6327 None => {}
6328 }
6329 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6330 boards.extend(clear.map(|(board, _)| board));
6331 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6332 for board in boards {
6333 let writes = board_writes
6334 .iter()
6335 .filter(|(on, _)| on.as_str() == board.as_str())
6336 .map(|(_, write)| write.clone())
6337 .collect::<Vec<_>>();
6338 let cleared = clear
6339 .filter(|(on, _)| on.as_str() == board.as_str())
6340 .map(|(_, field)| field);
6341 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6342 .await?;
6343 }
6344 let mut blocked_by_moved = false;
6345 if let Some((native, _)) = &edges
6346 && item.content_kind == ContentKind::Issue
6347 {
6348 blocked_by_moved = self
6349 .reconcile_blocked_by(
6350 &item.id,
6351 native,
6352 Issue::Existing(item.blocked_by.as_deref()),
6353 )
6354 .await?;
6355 }
6356 if !fields.is_empty() {
6357 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6358 .await?;
6359 }
6360
6361 if let Some(title) = &update.title {
6362 item.title.clone_from(title);
6363 }
6364 item.body = visible.filter(|value| !value.is_empty());
6365 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6366 item.slot = slot;
6367 if let Some(delivers) = &update.delivers {
6368 item.delivers.clone_from(delivers);
6369 }
6370 if let Some(moving) = status_move {
6371 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6372 && item.content_kind == ContentKind::Issue;
6373 item.status = moving.landed;
6374 item.option = Some(moving.name);
6375 }
6376 if let Some((_, _, priority)) = priority_move {
6377 item.priority = HeldPriority::Read(priority);
6378 }
6379 let task = item.task()?;
6380 let mut written = update.changed(&before, &task);
6381 if blocked_by_moved || recorded_moves {
6382 written.insert(UpdatedField::DependsOn);
6383 }
6384 self.remember_written(item, false)?;
6385 Ok(Some(TaskUpdateOutcome {
6386 task,
6387 written,
6388 delivers_before: before.delivers,
6389 }))
6390 }
6391
6392 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6393 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6394 ///
6395 /// One update of the body: the content outside the slot, and inside it that one entry,
6396 /// every other entry kept as it was. This source keeps no template answers — an issue has
6397 /// no room beside itself that is not its body, and answers written there would duplicate
6398 /// what the content already says and count against GitHub's body limit — so `answers`
6399 /// reaches nothing here. A body that would not change is not sent at all.
6400 async fn replace_rendering(
6401 &self,
6402 id: &NativeId,
6403 kind: BoardKind,
6404 content: &str,
6405 provenance: &Value,
6406 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
6407 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
6408 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6409 return Ok(None);
6410 };
6411 let held = item.raw_body.clone().unwrap_or_default();
6412 let mut slot = item.slot.clone();
6413 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6414 let rewritten;
6415 let content = if let Some(assets) = assets {
6416 let uploads = self
6417 .upload_assets(item.own_repository.as_ref(), assets)
6418 .await?;
6419 rewritten =
6420 onetaskgraph_plugin_api::serve_asset_references(content, &mut slot, &uploads);
6421 rewritten.as_str()
6422 } else {
6423 content
6424 };
6425 let body = with_slot(&with_content(&held, content)?, &slot)?;
6426 // Checked before anything is sent, as a content write checks it.
6427 let (visible, read) = metadata_body(Some(body.clone()))?;
6428 if visible.as_deref().unwrap_or_default() != content || read != slot {
6429 return Err(SourceError::Refused {
6430 message: format!(
6431 "this content ends in what source {} reads as its own metadata slot \
6432 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6433 as content; next: remove that trailing block from the template",
6434 self.name
6435 ),
6436 });
6437 }
6438 if body != held {
6439 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6440 .await?;
6441 }
6442 item.body = visible.filter(|value| !value.is_empty());
6443 item.raw_body = Some(body);
6444 item.slot = read;
6445 self.remember_written(item, false)?;
6446 Ok(Some(onetaskgraph_plugin_api::AssetsWritten {
6447 id: id.clone(),
6448 content: Some(content.to_owned()),
6449 }))
6450 }
6451
6452 async fn set_item_field(
6453 &self,
6454 board_id: &str,
6455 item_id: &str,
6456 field_id: &str,
6457 value: Value,
6458 ) -> Result<(), SourceError> {
6459 let data = self
6460 .graphql(
6461 graphql::UPDATE_FIELD,
6462 json!({"input":{
6463 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6464 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6465 )
6466 .await?;
6467 let returned = data
6468 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6469 .ok_or_else(|| SourceError::Malformed {
6470 message: "GitHub field update returned no project item".into(),
6471 })?;
6472 if required_str(returned, "id")? != item_id {
6473 return Err(SourceError::Malformed {
6474 message: "GitHub field update returned the wrong project item".into(),
6475 });
6476 }
6477 Ok(())
6478 }
6479
6480 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6481 /// one request. Every returned item id is checked, including optional aliases.
6482 async fn set_item_fields(
6483 &self,
6484 board: &str,
6485 item: &str,
6486 fields: &[(String, Value)],
6487 clear: Option<&str>,
6488 ) -> Result<(), SourceError> {
6489 if fields.len() <= 1 && clear.is_none() {
6490 if let Some((field, value)) = fields.first() {
6491 self.set_item_field(board, item, field, value.clone())
6492 .await?;
6493 }
6494 return Ok(());
6495 }
6496 if fields.is_empty() {
6497 if let Some(field) = clear {
6498 self.write_priority(
6499 board,
6500 item,
6501 &PriorityWrite::Clear {
6502 field: field.to_owned(),
6503 },
6504 )
6505 .await?;
6506 }
6507 return Ok(());
6508 }
6509 let input = |index: usize| {
6510 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6511 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6512 };
6513 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6514 "input":input(0),"second":input(1),"third":input(2),
6515 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6516 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6517 })).await?;
6518 for alias in [
6519 Some("updateProjectV2ItemFieldValue"),
6520 (fields.len() > 1).then_some("second"),
6521 (fields.len() > 2).then_some("third"),
6522 clear.map(|_| "cleared"),
6523 ]
6524 .into_iter()
6525 .flatten()
6526 {
6527 let returned = data
6528 .get(alias)
6529 .and_then(|value| value.get("projectV2Item"))
6530 .ok_or_else(|| SourceError::Malformed {
6531 message: format!("GitHub field update {alias} returned no project item"),
6532 })?;
6533 if required_str(returned, "id")? != item {
6534 return Err(SourceError::Malformed {
6535 message: format!("GitHub field update {alias} returned the wrong project item"),
6536 });
6537 }
6538 }
6539 Ok(())
6540 }
6541
6542 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6543 let mut after: Option<String> = None;
6544 let mut ids = Vec::new();
6545 loop {
6546 let data = self
6547 .graphql(
6548 graphql::ISSUE_DEPENDENCIES,
6549 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6550 )
6551 .await?;
6552 let connection =
6553 data.pointer("/node/blockedBy")
6554 .ok_or_else(|| SourceError::Malformed {
6555 message: "GitHub dependency response has no blockedBy connection".into(),
6556 })?;
6557 ids.extend(
6558 connection
6559 .get("nodes")
6560 .and_then(Value::as_array)
6561 .ok_or_else(|| SourceError::Malformed {
6562 message: "GitHub dependency response nodes is not an array".into(),
6563 })?
6564 .iter()
6565 .map(|value| required_str(value, "id").map(str::to_owned))
6566 .collect::<Result<Vec<_>, _>>()?,
6567 );
6568 let next = next_cursor(connection)?;
6569 if let Some(next) = &next {
6570 validate_cursor_progress(after.as_deref(), &next.0)?;
6571 }
6572 after = next.map(|cursor| cursor.0);
6573 if after.is_none() {
6574 return Ok(ids);
6575 }
6576 }
6577 }
6578
6579 async fn dependencies(
6580 &self,
6581 id: &NativeId,
6582 near_kind: ItemKind,
6583 direction: Direction,
6584 page: &PageRequest,
6585 ) -> Result<Page<DependencyEdge>, SourceError> {
6586 validate_page(page)?;
6587 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6588 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6589 let recorded = recorded_offset(cursor, direction)?;
6590 // What this issue is blocked by, when a read of it by its own id in this command
6591 // already carried the whole connection — a copy reads the item it writes before it
6592 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6593 // after it. Answered from that read, in the shape the dependency read answers in;
6594 // anything else is asked of GitHub.
6595 let carried = match direction {
6596 Direction::DependsOn => self
6597 .resolved_cache()?
6598 .get(id)
6599 .filter(|item| item.content_kind == ContentKind::Issue)
6600 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6601 Direction::DependedOnBy => None,
6602 }
6603 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6604 // Asked for even in the recorded phase, whose page reads nothing from the
6605 // connection: `__typename` is what says whether this item has a native
6606 // relationship at all, and that is what decides which far ends the reserved key is
6607 // allowed to hold.
6608 let data = match carried {
6609 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6610 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6611 None => {
6612 self.graphql(
6613 graphql::ISSUE_DEPENDENCIES,
6614 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6615 "after":if recorded.is_some() {None} else {cursor}}),
6616 )
6617 .await?
6618 }
6619 };
6620 let node =
6621 data.get("node")
6622 .filter(|v| !v.is_null())
6623 .ok_or_else(|| SourceError::Refused {
6624 message: format!(
6625 "GitHub item {} was not found or does not support dependencies",
6626 id.0
6627 ),
6628 })?;
6629 let connection_name = match direction {
6630 Direction::DependsOn => "blockedBy",
6631 Direction::DependedOnBy => "blocking",
6632 };
6633 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6634 // named natively and the reserved key may hold any far end. An issue's connections
6635 // hold issues, and this source reads them at the near item's own level.
6636 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6637 if let Some(offset) = recorded {
6638 return Ok(recorded_page(
6639 self.recorded_edges(id, near_kind, direction, natively_names, node)
6640 .await?,
6641 offset,
6642 limit,
6643 ));
6644 }
6645 if natively_names.is_none() {
6646 return Ok(recorded_page(
6647 self.recorded_edges(id, near_kind, direction, natively_names, node)
6648 .await?,
6649 0,
6650 limit,
6651 ));
6652 }
6653 let connection = node
6654 .get(connection_name)
6655 .ok_or_else(|| SourceError::Malformed {
6656 message: "GitHub dependency response is missing its connection".into(),
6657 })?;
6658 let nodes = connection
6659 .get("nodes")
6660 .and_then(Value::as_array)
6661 .ok_or_else(|| SourceError::Malformed {
6662 message: "GitHub dependency response nodes is not an array".into(),
6663 })?;
6664 // `from` depends on `to`, always. GitHub spells the same relationship from either
6665 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6666 // it — so the near item is `from` in one direction and `to` in the other.
6667 let items = nodes
6668 .iter()
6669 .map(|value| {
6670 let related = NativeId(required_str(value, "id")?.into());
6671 let related_kind = related_kind(value)?;
6672 let (from, to) = match direction {
6673 Direction::DependsOn => (
6674 DependencyEndpoint::from_native(id.clone(), near_kind),
6675 DependencyEndpoint::from_native(related, related_kind),
6676 ),
6677 Direction::DependedOnBy => (
6678 DependencyEndpoint::from_native(related, related_kind),
6679 DependencyEndpoint::from_native(id.clone(), near_kind),
6680 ),
6681 };
6682 Ok(DependencyEdge {
6683 from,
6684 to,
6685 kind: DependencyKind::Blocks,
6686 })
6687 })
6688 .collect::<Result<Vec<_>, SourceError>>()?;
6689 let mut next = next_cursor(connection)?;
6690 if let Some(next) = &next {
6691 validate_cursor_progress(cursor, &next.0)?;
6692 }
6693 if next.is_none()
6694 && !self
6695 .recorded_edges(id, near_kind, direction, natively_names, node)
6696 .await?
6697 .is_empty()
6698 {
6699 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6700 }
6701 Ok(Page { items, next })
6702 }
6703
6704 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6705 /// a far end in another source has to live: no GitHub issue relationship can name one.
6706 ///
6707 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6708 /// source never writes one down.
6709 ///
6710 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6711 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6712 /// request beyond the read already made, and reading the board for them would be a
6713 /// walk of every item for one field of one. A draft has no body in that answer, because
6714 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6715 /// listing of the board, which can be behind on the very item asked about.
6716 async fn recorded_edges(
6717 &self,
6718 id: &NativeId,
6719 near_kind: ItemKind,
6720 direction: Direction,
6721 natively_names: Option<ItemKind>,
6722 node: &Value,
6723 ) -> Result<Vec<DependencyEdge>, SourceError> {
6724 if direction != Direction::DependsOn {
6725 return Ok(Vec::new());
6726 }
6727 let slot = match node.get("body") {
6728 Some(body) if natively_names.is_some() => {
6729 metadata_body(body.as_str().map(str::to_owned))?.1
6730 }
6731 _ => {
6732 let Some(item) = self.bound_item(id).await? else {
6733 return Ok(Vec::new());
6734 };
6735 item.slot
6736 }
6737 };
6738 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6739 .map_err(|message| SourceError::Malformed { message })
6740 }
6741
6742 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6743 self.repository
6744 .as_ref()
6745 .ok_or_else(|| SourceError::Refused {
6746 message: format!(
6747 "source {} has no repository configured, and a GitHub Projects board has no \
6748 repository of its own to create an issue in; set repository: owner/name on \
6749 this source",
6750 self.name
6751 ),
6752 })
6753 }
6754
6755 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6756 /// states.
6757 ///
6758 /// The fallback is demanded first, whichever arm answers: a write without a configured
6759 /// repository is refused naming the field exactly as it was before the rule existed,
6760 /// so a source that could not write before cannot write now, rather than writing for
6761 /// the one item whose own field happens to decide it.
6762 ///
6763 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6764 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6765 /// entry owned by someone other than the owner of the parent issue's repository —
6766 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6767 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6768 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6769 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6770 /// and is visible to the token is checked where its node id is resolved, still before
6771 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6772 /// looked up in a listing of the board, which can be minutes behind an issue its own
6773 /// `projectItems` already places on it — and that read answers first from this process's
6774 /// own record, so a project created moments ago in this command answers though GitHub
6775 /// has not caught up.
6776 async fn creation_target(
6777 &self,
6778 incoming: &Incoming<'_>,
6779 ) -> Result<RepositoryTarget, SourceError> {
6780 let fallback = self.configured_repository()?;
6781 let what = |incoming: &Incoming<'_>| {
6782 format!(
6783 "{} {:?}",
6784 incoming.written.kind().describes(),
6785 incoming.title
6786 )
6787 };
6788 let parent = match incoming.parent {
6789 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6790 SourceError::Refused {
6791 message: format!(
6792 "GitHub project issue {} was not found on the board of source {}, so {} \
6793 cannot be filed under it",
6794 parent.0,
6795 self.name,
6796 what(incoming)
6797 ),
6798 }
6799 })?),
6800 None => None,
6801 };
6802 let parents_repository = parent
6803 .as_ref()
6804 .map(|parent| {
6805 // A draft is on the board and so is found, but it has no repository to
6806 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6807 // would refuse the task only once `createIssue` had made it.
6808 if parent.content_kind == ContentKind::DraftIssue {
6809 return Err(SourceError::Refused {
6810 message: format!(
6811 "GitHub project item {} on the board of source {} is a draft, \
6812 which cannot have sub-issues, so {} cannot be filed under it",
6813 parent.id.0,
6814 self.name,
6815 what(incoming)
6816 ),
6817 });
6818 }
6819 // An issue's repository is where a sub-issue is placed and whose owner it
6820 // is compared against, so a parent whose repository this source cannot
6821 // spell as `owner/name` — GitHub's login grammar is wider than this
6822 // source's floor — is one nothing can be filed under.
6823 parent
6824 .own_repository
6825 .as_ref()
6826 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6827 .ok_or_else(|| SourceError::Malformed {
6828 message: format!(
6829 "GitHub project issue {} on the board of source {} is in {}, which \
6830 is not a {}/owner/name repository this source can place {} in",
6831 parent.id.0,
6832 self.name,
6833 parent
6834 .own_repository
6835 .as_ref()
6836 .map_or("no repository", Repository::as_str),
6837 RepositoryTarget::HOST,
6838 what(incoming)
6839 ),
6840 })
6841 })
6842 .transpose()?;
6843 match incoming.repositories {
6844 [named] => {
6845 let target =
6846 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6847 message: format!(
6848 "{} names repository {}, which is not a {}/owner/name repository \
6849 source {} can create an issue in; name one that is, or name none",
6850 what(incoming),
6851 named.as_str(),
6852 RepositoryTarget::HOST,
6853 self.name
6854 ),
6855 })?;
6856 if let Some(parents) = &parents_repository
6857 && parents.owner != target.owner
6858 {
6859 return Err(SourceError::Refused {
6860 message: format!(
6861 "{} names repository {}, owned by {}, but its project's issue is in \
6862 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6863 of the same owner as its parent issue; name a repository of {}, or \
6864 name none",
6865 what(incoming),
6866 target.slug(),
6867 target.owner,
6868 parents.slug(),
6869 parents.owner,
6870 parents.owner
6871 ),
6872 });
6873 }
6874 Ok(target)
6875 }
6876 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6877 }
6878 }
6879
6880 /// The node id of the repository `incoming` is being created in, or the refusal naming
6881 /// the item and the repository the token cannot see.
6882 ///
6883 /// Resolved once per command per repository; see [`Self::repository_cache`].
6884 async fn repository_id(
6885 &self,
6886 repository: &RepositoryTarget,
6887 incoming: &Incoming<'_>,
6888 ) -> Result<String, SourceError> {
6889 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6890 return Ok(id);
6891 }
6892 let data = self
6893 .graphql(
6894 graphql::REPOSITORY,
6895 json!({"owner":repository.owner,"name":repository.name}),
6896 )
6897 .await?;
6898 self.repository_read(&data, repository, incoming)
6899 }
6900
6901 /// The repository's node id out of an answer carrying the `repository` root, held for
6902 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6903 fn repository_read(
6904 &self,
6905 data: &Value,
6906 repository: &RepositoryTarget,
6907 incoming: &Incoming<'_>,
6908 ) -> Result<String, SourceError> {
6909 let node = data
6910 .get("repository")
6911 .filter(|value| !value.is_null())
6912 .ok_or_else(|| SourceError::Refused {
6913 message: format!(
6914 "GitHub repository {} was not found or is not visible to the token, so {} \
6915 {:?} cannot be created in it",
6916 repository.slug(),
6917 incoming.written.kind().describes(),
6918 incoming.title
6919 ),
6920 })?;
6921 let id = required_str(node, "id")?.to_owned();
6922 self.repository_cache()?
6923 .insert(repository.clone(), id.clone());
6924 Ok(id)
6925 }
6926
6927 fn repository_cache(
6928 &self,
6929 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6930 self.repository_cache
6931 .lock()
6932 .map_err(|_| SourceError::Unavailable {
6933 message: "this source's record of the destination repository was left \
6934 inconsistent by an earlier failure; next: run the command again"
6935 .into(),
6936 })
6937 }
6938
6939 /// Create or update one board item, whichever kind it is.
6940 async fn write_item(
6941 &self,
6942 incoming: &Incoming<'_>,
6943 target: Option<&NativeId>,
6944 depends_on: &[DependencyEdge],
6945 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
6946 // Refused before anything is read or written: a task or a project titled the way
6947 // this board spells a document would land as an issue this same source reads back
6948 // as a document, so the field this destination cannot carry is named rather than
6949 // written and silently reclassified.
6950 if let Written::Work(kind, _) = incoming.written
6951 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6952 {
6953 return Err(SourceError::Refused {
6954 message: format!(
6955 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6956 spells a document, so it would read back as one rather than as a {}; \
6957 retitle it, or copy it as a document",
6958 kind.marker(),
6959 self.name,
6960 kind.marker()
6961 ),
6962 });
6963 }
6964 // The destination is read by its own id, and whether this board holds it is decided
6965 // by that read — its own `projectItems` — rather than by whether a listing of the
6966 // board happens to include it yet. See the module documentation.
6967 let existing = match target {
6968 Some(target) => {
6969 Some(
6970 self.bound_item(target)
6971 .await?
6972 .ok_or_else(|| SourceError::Refused {
6973 message: format!("GitHub destination item {} was not found", target.0),
6974 })?,
6975 )
6976 }
6977 None => None,
6978 };
6979 let existing = existing.as_ref();
6980 // An existing issue is never moved; a new one is created where the rule says — and
6981 // knowing where is what lets the board's fields and that repository's id be read
6982 // together, before anything below needs either.
6983 let creation_target = match existing {
6984 Some(_) => None,
6985 None => {
6986 let target = self.creation_target(incoming).await?;
6987 self.creation_context(&target, incoming).await?;
6988 Some(target)
6989 }
6990 };
6991 let board = self
6992 .fields_for(
6993 existing,
6994 incoming.written.status().is_some(),
6995 incoming
6996 .priority
6997 .is_some_and(|priority| priority != Priority::None),
6998 )
6999 .await?;
7000 let status_target = incoming
7001 .written
7002 .work_status()
7003 .map(|(kind, status)| self.resolved_target(kind, status.category))
7004 .transpose()?;
7005 let column = match (incoming.written.work_status(), status_target.as_ref()) {
7006 (Some((kind, status)), Some(target)) => {
7007 self.column_for(&board.fields, kind, status.category, target)?
7008 }
7009 _ => None,
7010 };
7011 // Resolved before anything is created, for the reason the column above is: a
7012 // priority this board has no option for is refused while nothing has been written.
7013 let priority_write = match incoming.priority {
7014 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
7015 None => None,
7016 };
7017 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
7018 if content_kind == ContentKind::DraftIssue {
7019 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
7020 (status_target.as_ref(), incoming.written.status())
7021 {
7022 return Err(self.closes_a_draft(status.category));
7023 }
7024 if incoming.parent.is_some() {
7025 return Err(SourceError::Refused {
7026 message: "GitHub draft items cannot be a project's sub-issue".into(),
7027 });
7028 }
7029 }
7030 match existing {
7031 Some(item) if content_kind == ContentKind::Issue => {
7032 if item.labels != incoming.labels {
7033 return Err(SourceError::Refused {
7034 message: "GitHub issue labels differ from the labels being written".into(),
7035 });
7036 }
7037 }
7038 _ => {
7039 if !incoming.labels.is_empty() {
7040 return Err(SourceError::Refused {
7041 message: "GitHub items created by this destination carry no labels".into(),
7042 });
7043 }
7044 }
7045 }
7046
7047 // The repository the issue really lives in is what the slot below is written against,
7048 // so a single entry that is where the issue is created travels as no key at all, and
7049 // the read side derives it back from the issue.
7050 let own_repository = match (existing, &creation_target) {
7051 (Some(item), _) => item.own_repository.clone(),
7052 (None, Some(target)) => Some(
7053 Repository::try_from(target.origin())
7054 .map_err(|message| SourceError::Config { message })?,
7055 ),
7056 (None, None) => None,
7057 };
7058 let (native, fallback) = self
7059 .partition_edges(
7060 incoming.written.kind(),
7061 content_kind,
7062 existing.and_then(|item| item.blocked_by.as_deref()),
7063 depends_on,
7064 )
7065 .await?;
7066 let mut slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
7067 let content = match incoming.assets {
7068 Some(assets) => {
7069 let uploads = self.upload_assets(own_repository.as_ref(), assets).await?;
7070 let rewritten = onetaskgraph_plugin_api::serve_asset_references(
7071 incoming.content.unwrap_or_default(),
7072 &mut slot,
7073 &uploads,
7074 );
7075 incoming.content.map(|_| rewritten)
7076 }
7077 None => incoming.content.map(str::to_owned),
7078 };
7079 let body = compose_body(content.as_deref(), &slot)?;
7080 // Read before anything is created, for the reason the field below is: a value
7081 // this destination cannot store has to refuse, and refusing after `createIssue`
7082 // would leave an issue behind that nothing asked for. The engine writes a
7083 // qualified id here; a caller handing this key anything else is told so rather
7084 // than having it silently stored as no origin at all.
7085 // 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.
7086 let origin = match incoming.metadata.get(ORIGIN_KEY) {
7087 None => "",
7088 Some(Value::String(origin)) => origin.as_str(),
7089 Some(other) => {
7090 return Err(SourceError::Refused {
7091 message: format!(
7092 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
7093 is {other}"
7094 ),
7095 });
7096 }
7097 };
7098 // Resolved before anything is created: a board that cannot carry the copy origin
7099 // has to refuse the write, and refusing it after `createIssue` would leave an
7100 // issue behind that nothing asked for.
7101 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
7102 Some(field) => {
7103 if required_str(field, "__typename")? != "ProjectV2Field" {
7104 return Err(SourceError::Refused {
7105 message: format!(
7106 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
7107 ),
7108 });
7109 }
7110 Some(required_str(field, "id")?.to_owned())
7111 }
7112 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
7113 return Err(SourceError::Refused {
7114 message: format!(
7115 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
7116 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
7117 the board"
7118 ),
7119 });
7120 }
7121 None => None,
7122 };
7123
7124 let Landed {
7125 content_id,
7126 item_id,
7127 url,
7128 number,
7129 } = match existing {
7130 // Its content is written last, below, once everything else has landed.
7131 Some(item) => Landed {
7132 content_id: item.id.clone(),
7133 item_id: item.item_id.clone(),
7134 url: item.url.clone(),
7135 number: item.number,
7136 },
7137 None => {
7138 let target = creation_target
7139 .as_ref()
7140 .ok_or_else(|| SourceError::Malformed {
7141 message: "a new item was decided without a repository to create it in"
7142 .into(),
7143 })?;
7144 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7145 .await?
7146 }
7147 };
7148
7149 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7150 let column = column
7151 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7152 .map(|(field, option, _)| (field, option));
7153 // Creating an item here is several calls — `createIssue`, which files it on the
7154 // board, then its board fields, the parent and the dependencies — and GitHub can fail
7155 // at any of them. Everything this source can refuse *before* the first of those is
7156 // already checked above, so what is left is GitHub itself failing part way. When it
7157 // does over an item this call created, the issue is taken back: a write that
7158 // refused must not leave an item behind that nobody asked for, and one that does
7159 // makes the retry create a second.
7160 // Whether the board-field write carrying a moved origin was answered as landing whole.
7161 // When it was refused, GitHub does not say which of its fields ran before the one that
7162 // failed, so the origin may or may not have moved.
7163 let mut origin_landed = false;
7164 let landed = self
7165 .finish_write(
7166 board.id.as_str(),
7167 incoming,
7168 &content_id,
7169 &item_id,
7170 content_kind,
7171 existing,
7172 origin_field.as_deref(),
7173 origin,
7174 column,
7175 status_target.as_ref(),
7176 priority_write.as_ref(),
7177 &native,
7178 &mut origin_landed,
7179 )
7180 .await;
7181 // An existing item's title, body and state go last, in one `updateIssue`, once its board
7182 // fields and its relationships have landed: a refusal of any of those then leaves its
7183 // body — and the metadata slot inside it — exactly as it stood.
7184 let landed = match (landed, existing) {
7185 (Ok(()), Some(item)) => {
7186 self.update_existing(item, incoming, &body, status_target.as_ref())
7187 .await
7188 }
7189 (landed, _) => landed,
7190 };
7191 if let Err(error) = landed {
7192 match existing {
7193 // Best effort, and the write's own failure is what the caller is told: a
7194 // refusal naming the tidy-up would hide why the write failed at all.
7195 None => {
7196 let _ = self.delete_issue(&content_id).await;
7197 }
7198 // The origin field is the one piece of an existing item's metadata written
7199 // before its body, so a write refused after it puts it back as it was. When
7200 // that is refused too, the write's own failure is still what the caller is
7201 // told — with what it left behind added, because the item's metadata is then
7202 // not as it stood and a caller retrying has to know which key moved.
7203 Some(item) => {
7204 let before = item.origin.as_deref().unwrap_or("");
7205 if let Some(field) = origin_field.as_deref()
7206 && before != origin
7207 && let Err(restore) = self
7208 .set_item_field(
7209 board.id.as_str(),
7210 &item.item_id,
7211 field,
7212 json!({"text": before}),
7213 )
7214 .await
7215 {
7216 let left = if origin_landed {
7217 format!(
7218 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7219 not be put back to {before:?} ({restore}), so item {} still \
7220 holds {origin:?} there",
7221 item.id.0
7222 )
7223 } else {
7224 format!(
7225 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7226 {origin:?}, GitHub does not say whether that part of it ran, \
7227 and putting it back to {before:?} was refused ({restore}), so \
7228 item {} holds {origin:?} or {before:?} there",
7229 item.id.0
7230 )
7231 };
7232 return Err(noting(
7233 error,
7234 &format!(
7235 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7236 run the write again"
7237 ),
7238 ));
7239 }
7240 }
7241 }
7242 return Err(error);
7243 }
7244
7245 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7246 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7247 self.statuses
7248 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7249 }
7250 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7251 self.statuses
7252 .status(kind, written_option.as_deref(), false, None)
7253 }
7254 (Some((_, status)), _) => status.clone(),
7255 (None, _) => Status {
7256 category: StatusCategory::Unknown,
7257 name: "Open".to_owned(),
7258 },
7259 };
7260
7261 // So the rest of this command reads what it just did rather than what the board
7262 // said before it. See `remember_written` for which half takes it.
7263 let remembered = Resolved {
7264 item_id,
7265 id: content_id.clone(),
7266 content_kind,
7267 kind: incoming.written.kind(),
7268 title: incoming.title.to_owned(),
7269 // The visible half of the body this write composed, split back off it the
7270 // way a read splits it — so what this record reports is what a read of the
7271 // same issue reports, rather than the person's text with the metadata slot
7272 // still on the end of it.
7273 body: metadata_body(body.clone())?.0,
7274 raw_body: body.clone(),
7275 // A document has no status of its own; what it reads back as is whatever
7276 // the issue's own state says, which is what a re-read reports.
7277 status: written_status,
7278 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7279 priority: match incoming.priority {
7280 Some(priority) => HeldPriority::Read(priority),
7281 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7282 item.priority.clone()
7283 }),
7284 },
7285 // What `state_input` asked for: closed for a terminal target, open for any other
7286 // status, and the issue's own state left as it was by a document write.
7287 closed: content_kind == ContentKind::Issue
7288 && match status_target.as_ref() {
7289 Some(StatusTarget::Terminal(_, _)) => true,
7290 Some(_) => false,
7291 None => existing.is_some_and(|item| item.closed),
7292 },
7293 delivers: incoming.delivers.to_vec(),
7294 delivered_by: incoming.delivered_by.to_vec(),
7295 labels: incoming.labels.to_vec(),
7296 parent: incoming.parent.cloned(),
7297 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7298 number,
7299 // In the update path this is the item's own url, read off `existing` where the
7300 // record above was bound, so one expression serves both halves.
7301 url,
7302 created_at: existing.and_then(|item| item.created_at),
7303 updated_at: existing.and_then(|item| item.updated_at),
7304 own_repository,
7305 repositories: incoming.repositories.to_vec(),
7306 slot,
7307 board_id: Some(board.id.as_str().to_owned()),
7308 fields: board
7309 .fields
7310 .get("nodes")
7311 .and_then(Value::as_array)
7312 .cloned()
7313 .unwrap_or_default(),
7314 board_fields: Some(board.fields.clone()),
7315 // What this write left the relationship holding is known by id alone, and a
7316 // later read of its edges needs each far end's kind, so it reads them again.
7317 blocked_by: None,
7318 };
7319 self.remember_written(remembered, existing.is_none())?;
7320 Ok(onetaskgraph_plugin_api::AssetsWritten {
7321 id: content_id,
7322 content,
7323 })
7324 }
7325
7326 /// Everything a write does after the item exists: its board fields, its parent, and
7327 /// its dependencies.
7328 ///
7329 /// Split out of `write_item` so there is one place a failure past the point of no
7330 /// return is caught, rather than a tidy-up repeated at each `?` above.
7331 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7332 // so there is one place a failure past the point of no return is caught, and its
7333 // arguments are exactly the values that tail already had in scope. Bundling them into a
7334 // struct would describe no concept — it would be "the arguments of this function" — and
7335 // would put the whole of `write_item`'s locals behind one more indirection.
7336 #[allow(clippy::too_many_arguments)]
7337 async fn finish_write(
7338 &self,
7339 board_id: &str,
7340 incoming: &Incoming<'_>,
7341 content_id: &NativeId,
7342 item_id: &str,
7343 content_kind: ContentKind,
7344 existing: Option<&Resolved>,
7345 origin_field: Option<&str>,
7346 origin: &str,
7347 column: Option<(String, String)>,
7348 status_target: Option<&StatusTarget>,
7349 priority: Option<&PriorityWrite>,
7350 native: &[String],
7351 origin_landed: &mut bool,
7352 ) -> Result<(), SourceError> {
7353 let mut fields = Vec::new();
7354 if let Some(field_id) = origin_field
7355 && existing.map_or(!origin.is_empty(), |item| {
7356 item.origin.as_deref().unwrap_or("") != origin
7357 })
7358 {
7359 fields.push((field_id.to_owned(), json!({"text":origin})));
7360 }
7361 if let Some((field_id, option_id)) = column {
7362 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7363 }
7364 let clear = match priority {
7365 Some(PriorityWrite::Select { field, option }) => {
7366 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7367 None
7368 }
7369 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7370 None => None,
7371 };
7372 self.set_item_fields(board_id, item_id, &fields, clear)
7373 .await?;
7374 *origin_landed = true;
7375
7376 // An existing issue closes in the `updateIssue` its write ends with; one created just
7377 // now closes here, once its option is selected.
7378 if existing.is_none()
7379 && content_kind == ContentKind::Issue
7380 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7381 {
7382 self.update_content(
7383 ContentKind::Issue,
7384 content_id,
7385 json!({"stateInput":state_input(status_target)}),
7386 )
7387 .await?;
7388 }
7389
7390 if content_kind == ContentKind::Issue {
7391 self.reparent(
7392 existing.and_then(|item| item.parent.clone()),
7393 content_id,
7394 incoming.parent,
7395 )
7396 .await?;
7397 // A document takes part in no dependency graph, so writing one neither reads
7398 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7399 // against the empty list a document write carries would *delete* whatever
7400 // relationships a person had made on that issue, which is a write nobody
7401 // asked for.
7402 if incoming.written.kind() != BoardKind::Document {
7403 let issue = match existing {
7404 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7405 None => Issue::Created,
7406 };
7407 self.reconcile_blocked_by(content_id, native, issue).await?;
7408 }
7409 }
7410 Ok(())
7411 }
7412
7413 /// Delete one issue, which takes its board item with it.
7414 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7415 let data = self
7416 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7417 .await?;
7418 data.pointer("/deleteIssue/repository")
7419 .filter(|value| !value.is_null())
7420 .ok_or_else(|| SourceError::Malformed {
7421 message: "GitHub issue deletion returned no repository".into(),
7422 })?;
7423 self.forget(id)?;
7424 Ok(())
7425 }
7426
7427 /// Remove one item this copy created, so a copy that could not finish leaves the board
7428 /// as it found it.
7429 ///
7430 /// Deleting the issue takes its board item with it, so there is no second mutation to
7431 /// keep in step. An id the board does not hold is not an error: the item is already
7432 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7433 /// by its own id — a listing of the board can still be missing an item it holds, and
7434 /// reading that as *already gone* would leave behind the very item this was asked to
7435 /// take back.
7436 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7437 let Some(item) = self.bound_item(id).await? else {
7438 return Ok(());
7439 };
7440 if item.content_kind == ContentKind::DraftIssue {
7441 return Err(SourceError::Refused {
7442 message: format!(
7443 "GitHub item {} is a draft, and this source removes an item by deleting \
7444 its issue; next: remove it from the board by hand",
7445 id.0
7446 ),
7447 });
7448 }
7449 let data = self
7450 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7451 .await?;
7452 data.pointer("/deleteIssue/repository")
7453 .filter(|value| !value.is_null())
7454 .ok_or_else(|| SourceError::Malformed {
7455 message: "GitHub issue deletion returned no repository".into(),
7456 })?;
7457 self.forget(id)?;
7458 Ok(())
7459 }
7460
7461 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7462 /// task.
7463 ///
7464 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7465 /// read of the task cannot disagree about which ids name one: a project or a document of
7466 /// this board is not a task here either.
7467 ///
7468 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7469 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7470 /// which would read as a task nobody has commented on yet.
7471 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7472 let cached = self.resolved_cache()?.get(task).cloned();
7473 let Some(item) = (match cached {
7474 Some(item) => Some(item),
7475 None => self.item_by_id(task).await?,
7476 })
7477 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7478 return Ok(None);
7479 };
7480 if item.content_kind == ContentKind::DraftIssue {
7481 return Err(self.draft_has_no_comments(task));
7482 }
7483 Ok(Some(item.id))
7484 }
7485
7486 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7487 /// issues, and a draft is not one.
7488 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7489 SourceError::Refused {
7490 message: format!(
7491 "task {} of source {} is a draft item on the board, and GitHub keeps \
7492 comments on issues alone, so a draft has none to read or write; next: \
7493 convert the draft to an issue on the board, then comment on the issue it \
7494 becomes",
7495 task.0, self.name
7496 ),
7497 }
7498 }
7499
7500 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7501 /// request — or `None` when this board holds no task by that id.
7502 ///
7503 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7504 /// is answered with the draft and the refusal, at the price of the draft's own read.
7505 async fn issue_detail(
7506 &self,
7507 id: &NativeId,
7508 page: &PageRequest,
7509 ) -> Result<Option<TaskDetailRead>, SourceError> {
7510 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7511 let asked = self
7512 .graphql(
7513 graphql::ISSUE_DETAIL,
7514 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7515 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7516 "duplicates":true}),
7517 )
7518 .await;
7519 let data = match asked {
7520 Ok(data) => data,
7521 Err(error) if unresolvable_node(&error) => return Ok(None),
7522 Err(error) => return Err(error),
7523 };
7524 // `node` is null for an id that names nothing, and absent only from an answer this
7525 // source cannot read — never the same thing.
7526 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7527 message: format!("GitHub answered the read of {} with no node", id.0),
7528 })?;
7529 self.detail_of(id, node, true, after).await
7530 }
7531
7532 /// Several tasks, each with the first page of its comments when `comments` is set, read
7533 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7534 /// order.
7535 ///
7536 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7537 /// one item at a time, so that id is answered as missing and the others as themselves; any
7538 /// other refusal is every id of that batch's answer.
7539 async fn issue_details(
7540 &self,
7541 ids: &[NativeId],
7542 comments: Option<&PageRequest>,
7543 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7544 let mut read = Vec::with_capacity(ids.len());
7545 for batch in ids.chunks(DETAIL_BATCH) {
7546 match self
7547 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7548 .await
7549 {
7550 Ok(data) => {
7551 for (slot, id) in batch.iter().enumerate() {
7552 // Every alias asked for is answered, null for an id naming nothing;
7553 // one missing is an answer this source cannot read.
7554 let read_one = match data.get(format!("i{slot}")) {
7555 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7556 None => Err(SourceError::Malformed {
7557 message: format!(
7558 "GitHub answered a batch read with no item for {}",
7559 id.0
7560 ),
7561 }),
7562 };
7563 read.push(read_one);
7564 }
7565 }
7566 Err(error) if unresolvable_node(&error) => {
7567 for id in batch {
7568 read.push(match comments {
7569 Some(page) => self.issue_detail(id, page).await,
7570 None => self.task_read(id).await,
7571 });
7572 }
7573 }
7574 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7575 }
7576 }
7577 read
7578 }
7579
7580 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7581 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7582 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7583 task,
7584 comments: None,
7585 }))
7586 }
7587
7588 /// What one node a detail read reached says: the task this board holds by `id`, with the
7589 /// page of comments the node carries when `commented` — or `None` for a node that is no
7590 /// task of this board.
7591 ///
7592 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7593 /// and an item this process created answers from this process's own record, which a node
7594 /// read taken moments after the write can still be behind.
7595 async fn detail_of(
7596 &self,
7597 id: &NativeId,
7598 node: &Value,
7599 commented: bool,
7600 after: Option<&str>,
7601 ) -> Result<Option<TaskDetailRead>, SourceError> {
7602 if node.is_null() {
7603 return Ok(None);
7604 }
7605 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7606 // An issue answered under one id is that id's, or the answer is not one this source
7607 // can report: reporting another issue's task and comments under the qualified id asked
7608 // for would be the one wrong answer here. A draft's own read checks the same.
7609 if !draft
7610 && optional_str(node, "__typename")? == Some("Issue")
7611 && required_str(node, "id")? != id.0
7612 {
7613 return Err(SourceError::Malformed {
7614 message: format!(
7615 "GitHub answered the read of {} with issue {}",
7616 id.0,
7617 required_str(node, "id")?
7618 ),
7619 });
7620 }
7621 let item = if draft {
7622 self.draft_by_id(id).await?
7623 } else {
7624 self.resolve_issue(node).await?
7625 };
7626 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7627 return Ok(None);
7628 };
7629 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7630 let task = own.unwrap_or(item).task()?;
7631 let comments = match (commented, draft) {
7632 (false, _) => None,
7633 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7634 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7635 };
7636 Ok(Some(TaskDetailRead { task, comments }))
7637 }
7638
7639 /// Whether the comment `comment` is one of `issue`'s own.
7640 ///
7641 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7642 /// comment's id and nothing else: a comment id given against the wrong task would
7643 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7644 /// names something that is not an issue comment, is a comment this task does not have —
7645 /// which is what GitHub refusing to resolve it means too.
7646 async fn comment_is_on(
7647 &self,
7648 issue: &NativeId,
7649 comment: &NativeId,
7650 ) -> Result<bool, SourceError> {
7651 let asked = self
7652 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7653 .await;
7654 let data = match asked {
7655 Ok(data) => data,
7656 Err(error) if unresolvable_node(&error) => return Ok(false),
7657 Err(error) => return Err(error),
7658 };
7659 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7660 return Ok(false);
7661 };
7662 if optional_str(node, "__typename")? != Some("IssueComment") {
7663 return Ok(false);
7664 }
7665 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7666 message: format!("GitHub issue comment {} names no issue", comment.0),
7667 })?;
7668 Ok(required_str(on, "id")? == issue.0)
7669 }
7670
7671 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7672 async fn partition_edges(
7673 &self,
7674 near_kind: BoardKind,
7675 near_content: ContentKind,
7676 carried: Option<&[Value]>,
7677 depends_on: &[DependencyEdge],
7678 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7679 let mut native = Vec::new();
7680 let mut fallback = Vec::new();
7681 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7682 .iter()
7683 .map(|edge| {
7684 let same_source = edge
7685 .to
7686 .source()
7687 .is_none_or(|source| source == self.name.as_str());
7688 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7689 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7690 // colons of its own, so the far end is everything after that one separator.
7691 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7692 let far_id = if edge.to.is_qualified() {
7693 edge.to
7694 .id()
7695 .split_once(':')
7696 .map_or(edge.to.id(), |(_, native)| native)
7697 } else {
7698 edge.to.id()
7699 };
7700 // One that already blocks the near issue was answered by that issue's own
7701 // read, which carried each of its blockers' kinds — an issue every one — so it
7702 // is not read again.
7703 let blocking = carried.and_then(|nodes| {
7704 nodes
7705 .iter()
7706 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7707 });
7708 (edge, far_id, same_source, blocking)
7709 })
7710 .collect();
7711 // Every other same-source far end is read by its own id, exactly as the item it is a
7712 // far end of is: whether this board holds it is that read's answer, never a listing's.
7713 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7714 let mut unread: Vec<NativeId> = Vec::new();
7715 for (_, far_id, same_source, blocking) in &far_ends {
7716 let id = NativeId((*far_id).to_owned());
7717 if *same_source && blocking.is_none() && !unread.contains(&id) {
7718 unread.push(id);
7719 }
7720 }
7721 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7722 .iter()
7723 .cloned()
7724 .zip(self.items_by_ids(&unread).await?)
7725 .collect();
7726 for (edge, far_id, same_source, blocking) in far_ends {
7727 let far = match (same_source, blocking) {
7728 (false, _) => None,
7729 (true, Some(node)) => Some(FarEnd {
7730 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7731 BoardKind::Document
7732 } else {
7733 BoardKind::Work(related_kind(node)?)
7734 },
7735 content_kind: ContentKind::Issue,
7736 }),
7737 (true, None) => {
7738 let read = read
7739 .get(&NativeId(far_id.to_owned()))
7740 .cloned()
7741 .flatten()
7742 .ok_or_else(|| SourceError::Refused {
7743 message: format!("GitHub dependency item {far_id} was not found"),
7744 })?;
7745 Some(FarEnd {
7746 kind: read.kind,
7747 content_kind: read.content_kind,
7748 })
7749 }
7750 };
7751 let far = far.as_ref();
7752 // The caller says which kind the far end is, and this board holds the far end
7753 // itself, so a disagreement is settled here rather than stored: recorded, the
7754 // wrong kind would read back as a cross-level edge that never existed; written
7755 // natively, it would name a relationship of a different level than the caller
7756 // asked for.
7757 //
7758 // A far end this board holds as a *document* fails the same comparison and is
7759 // refused by the same sentence: `ItemKind` has no document variant because
7760 // nothing may point at one, so no caller can name it correctly and the refusal
7761 // is the only honest answer.
7762 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7763 return Err(SourceError::Refused {
7764 message: format!(
7765 "GitHub dependency item {far_id} is a {} of this board, and this item \
7766 names it as a {}; record the kind it is",
7767 disagreeing.kind.describes(),
7768 edge.to.kind.marker()
7769 ),
7770 });
7771 }
7772 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7773 // however the far end is spelled — and one classified native here would be
7774 // written nowhere at all, because a draft's native reconciliation never runs.
7775 let native_here = near_content == ContentKind::Issue
7776 && far.is_some_and(|far| {
7777 far.content_kind == ContentKind::Issue
7778 && BoardKind::Work(edge.to.kind) == near_kind
7779 });
7780 if native_here {
7781 native.push(far_id.to_owned());
7782 } else {
7783 fallback.push(edge.clone());
7784 }
7785 }
7786 Ok((native, fallback))
7787 }
7788
7789 async fn update_existing(
7790 &self,
7791 item: &Resolved,
7792 incoming: &Incoming<'_>,
7793 body: &Option<String>,
7794 status_target: Option<&StatusTarget>,
7795 ) -> Result<(), SourceError> {
7796 let title = incoming.written_title();
7797 // A terminal status closes the issue here, in the same mutation as its body: its board
7798 // option was selected before this, so a close never lands on an item whose board cannot
7799 // show it.
7800 let fields = match item.content_kind {
7801 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7802 ContentKind::Issue => json!({"title":title,"body":body,
7803 "stateInput":state_input(status_target)}),
7804 };
7805 self.update_content(item.content_kind, &item.id, fields)
7806 .await
7807 }
7808
7809 /// Update one board item's content with exactly `fields` beside its id, through the
7810 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7811 /// a draft.
7812 ///
7813 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7814 /// is what lets a narrow write carry the one thing it changes and nothing else.
7815 async fn update_content(
7816 &self,
7817 kind: ContentKind,
7818 id: &NativeId,
7819 fields: Value,
7820 ) -> Result<(), SourceError> {
7821 let (operation, id_key, pointer) = match kind {
7822 ContentKind::DraftIssue => (
7823 graphql::UPDATE_DRAFT,
7824 "draftIssueId",
7825 "/updateProjectV2DraftIssue/draftIssue",
7826 ),
7827 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7828 };
7829 let mut input = fields;
7830 input[id_key] = json!(id.0);
7831 let data = self.graphql(operation, json!({"input":input})).await?;
7832 let returned = data
7833 .pointer(pointer)
7834 .ok_or_else(|| SourceError::Malformed {
7835 message: "GitHub item update returned no item".into(),
7836 })?;
7837 if required_str(returned, "id")? != id.0 {
7838 return Err(SourceError::Malformed {
7839 message: "GitHub item update returned the wrong item".into(),
7840 });
7841 }
7842 Ok(())
7843 }
7844
7845 /// Creates one issue, files it on the board, and reports what a read of it would say:
7846 /// its content id, its board item id, and the web address GitHub gave it.
7847 ///
7848 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7849 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7850 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7851 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7852 /// "Content already exists in this project". A terminal status is not written here:
7853 /// `finish_write` selects its option first and closes the issue after, so a close never
7854 /// lands on an item whose board cannot show it.
7855 ///
7856 /// The address and the number come back here because this is the only place either is
7857 /// known before GitHub's own board read catches up — an item this run created answers
7858 /// the reads that follow it out of the record below, and one remembered without them
7859 /// would report no location and no key for the rest of the run.
7860 async fn create_and_file_issue(
7861 &self,
7862 board_id: &str,
7863 repository: &RepositoryTarget,
7864 incoming: &Incoming<'_>,
7865 body: &Option<String>,
7866 ) -> Result<Landed, SourceError> {
7867 let repository_id = self.repository_id(repository, incoming).await?;
7868 let data = self
7869 .graphql(
7870 graphql::CREATE_ISSUE,
7871 json!({"input":{
7872 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7873 }}),
7874 )
7875 .await?;
7876 let created = data
7877 .pointer("/createIssue/issue")
7878 .filter(|value| !value.is_null())
7879 .ok_or_else(|| SourceError::Malformed {
7880 message: "GitHub issue creation returned no issue".into(),
7881 })?;
7882 let content_id = NativeId(required_str(created, "id")?.to_owned());
7883 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7884 // a response without it is not worth failing a landed write over — the item simply
7885 // reports no location until the board read catches up, which is what it did before.
7886 let url = optional_str(created, "url")?.map(str::to_owned);
7887 // The issue exists from here on, so an unreadable number and a refused board
7888 // filing below each try, best effort, to take it back: an issue in the repository
7889 // that is on no board is an item nobody asked for and nothing here would find again.
7890 //
7891 // Its number is optional on the same terms its address is — a landed write is not
7892 // worth failing over a member that came back missing, and such an item reports no
7893 // handle until a board read catches up. A number that is *present* and is not an
7894 // unsigned integer is still a response this source cannot read.
7895 let number = match created_issue_number(created) {
7896 Ok(number) => number,
7897 Err(error) => {
7898 let _ = self.delete_issue(&content_id).await;
7899 return Err(error);
7900 }
7901 };
7902 let added = match self
7903 .graphql(
7904 graphql::ADD_TO_BOARD,
7905 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7906 )
7907 .await
7908 {
7909 Ok(added) => added,
7910 Err(error) => {
7911 let _ = self.delete_issue(&content_id).await;
7912 return Err(error);
7913 }
7914 };
7915 let item = added
7916 .pointer("/addProjectV2ItemById/item")
7917 .filter(|value| !value.is_null())
7918 .ok_or_else(|| SourceError::Malformed {
7919 message: "GitHub board addition returned no project item".into(),
7920 })?;
7921 Ok(Landed {
7922 content_id,
7923 item_id: required_str(item, "id")?.to_owned(),
7924 url,
7925 number,
7926 })
7927 }
7928
7929 /// Move one issue under the project it now belongs to, or out of the one it left.
7930 async fn reparent(
7931 &self,
7932 held: Option<NativeId>,
7933 child: &NativeId,
7934 wanted: Option<&NativeId>,
7935 ) -> Result<(), SourceError> {
7936 if held.as_ref() == wanted {
7937 return Ok(());
7938 }
7939 if let Some(held) = &held {
7940 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7941 .await?;
7942 }
7943 if let Some(wanted) = wanted {
7944 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7945 .await?;
7946 }
7947 Ok(())
7948 }
7949
7950 async fn sub_issue(
7951 &self,
7952 operation: &str,
7953 parent: &NativeId,
7954 child: &NativeId,
7955 root: &str,
7956 ) -> Result<(), SourceError> {
7957 let data = self
7958 .graphql(
7959 operation,
7960 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7961 )
7962 .await?;
7963 let issue =
7964 data.pointer(&format!("/{root}/issue"))
7965 .ok_or_else(|| SourceError::Malformed {
7966 message: "GitHub sub-issue update returned no issue".into(),
7967 })?;
7968 let sub =
7969 data.pointer(&format!("/{root}/subIssue"))
7970 .ok_or_else(|| SourceError::Malformed {
7971 message: "GitHub sub-issue update returned no sub-issue".into(),
7972 })?;
7973 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7974 return Err(SourceError::Malformed {
7975 message: "GitHub sub-issue update returned the wrong issues".into(),
7976 });
7977 }
7978 Ok(())
7979 }
7980
7981 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7982 /// whether there was one.
7983 ///
7984 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7985 /// relationships are not read: there is nothing a read of them could find.
7986 async fn reconcile_blocked_by(
7987 &self,
7988 content_id: &NativeId,
7989 native: &[String],
7990 issue: Issue<'_>,
7991 ) -> Result<bool, SourceError> {
7992 let current = match issue {
7993 Issue::Created => Vec::new(),
7994 Issue::Existing(Some(held)) => held
7995 .iter()
7996 .map(|far| required_str(far, "id").map(str::to_owned))
7997 .collect::<Result<Vec<_>, _>>()?,
7998 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7999 };
8000 let mut changed = false;
8001 for (operation, far_id) in current
8002 .iter()
8003 .filter(|id| !native.contains(id))
8004 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
8005 .chain(
8006 native
8007 .iter()
8008 .filter(|id| !current.contains(id))
8009 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
8010 )
8011 {
8012 let data = self
8013 .graphql(
8014 operation,
8015 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
8016 )
8017 .await?;
8018 let root = if operation == graphql::ADD_BLOCKED_BY {
8019 "addBlockedBy"
8020 } else {
8021 "removeBlockedBy"
8022 };
8023 let issue =
8024 data.pointer(&format!("/{root}/issue"))
8025 .ok_or_else(|| SourceError::Malformed {
8026 message: "GitHub dependency update returned no issue".into(),
8027 })?;
8028 let blocker = data
8029 .pointer(&format!("/{root}/blockingIssue"))
8030 .ok_or_else(|| SourceError::Malformed {
8031 message: "GitHub dependency update returned no blocking issue".into(),
8032 })?;
8033 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
8034 {
8035 return Err(SourceError::Malformed {
8036 message: "GitHub dependency update returned the wrong issues".into(),
8037 });
8038 }
8039 changed = true;
8040 }
8041 Ok(changed)
8042 }
8043}
8044
8045/// What a write needs to know of one far end it names: which kind of item it is, and whether
8046/// it is an issue a native relationship can name.
8047struct FarEnd {
8048 kind: BoardKind,
8049 content_kind: ContentKind,
8050}
8051
8052/// Whether the issue one write reconciles was created by that write or was already there.
8053#[derive(Clone, Copy, PartialEq, Eq)]
8054enum Issue<'a> {
8055 /// Created by this write, so it holds no relationships yet.
8056 Created,
8057 /// On the board before this write, holding whatever relationships it holds — the far
8058 /// ends of its whole `blockedBy`, when the read that reached it carried them.
8059 Existing(Option<&'a [Value]>),
8060}
8061
8062/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
8063enum Reached {
8064 /// An issue this board holds, resolved into everything this source reports about it.
8065 Held(Box<Resolved>),
8066 /// Nothing this board holds: no such node, or a node on some other board.
8067 Nothing,
8068 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
8069 /// again by [`GitHubProjectsSource::draft_by_id`].
8070 Draft,
8071}
8072
8073/// What GitHub says when a string is not a node id it can resolve.
8074///
8075/// Matched because it is the ordinary answer to a project selector naming a project by its
8076/// *name*, and reporting that as a failure would make naming one impossible. It is read
8077/// off the refusal GitHub sent, never guessed from the shape of the string: this source
8078/// does not define the syntax of a GitHub node id and would be wrong about it.
8079const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
8080
8081/// `error` with `note` added to the end of what it says, its kind and every other member
8082/// unchanged — so a caller still branches on the failure that happened, and reads beside it
8083/// what that failure left behind.
8084fn noting(error: SourceError, note: &str) -> SourceError {
8085 match error {
8086 SourceError::Config { message } => SourceError::Config {
8087 message: message + note,
8088 },
8089 SourceError::Auth { message } => SourceError::Auth {
8090 message: message + note,
8091 },
8092 SourceError::Refused { message } => SourceError::Refused {
8093 message: message + note,
8094 },
8095 SourceError::RateLimited {
8096 retry_after_seconds,
8097 message,
8098 } => SourceError::RateLimited {
8099 retry_after_seconds,
8100 message: Some(message.unwrap_or_default() + note),
8101 },
8102 SourceError::Unavailable { message } => SourceError::Unavailable {
8103 message: message + note,
8104 },
8105 SourceError::Malformed { message } => SourceError::Malformed {
8106 message: message + note,
8107 },
8108 }
8109}
8110
8111/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
8112/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
8113/// for them.
8114///
8115/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
8116/// is read again at no added price.
8117fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
8118 let mut variables = serde_json::Map::new();
8119 for slot in 0..DETAIL_BATCH {
8120 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
8121 variables.insert(format!("id{slot}"), json!(id));
8122 }
8123 variables.insert(
8124 "first".to_owned(),
8125 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
8126 );
8127 variables.insert("comments".to_owned(), json!(comments.is_some()));
8128 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
8129 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
8130 variables.insert("duplicates".to_owned(), json!(true));
8131 Value::Object(variables)
8132}
8133
8134/// Whether this refusal is GitHub saying the id names no node at all.
8135fn unresolvable_node(error: &SourceError) -> bool {
8136 matches!(error, SourceError::Refused { message }
8137 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
8138}
8139
8140/// One project name, as a search qualifier which filters on it at the server.
8141///
8142/// Quoted so the whole title is one phrase rather than a bag of words, with the two
8143/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
8144/// the way it documents. A title matched here is still compared for equality afterwards:
8145/// the qualifier narrows what the server sends, and this source decides what it names.
8146fn title_qualifier(name: &str) -> String {
8147 format!("in:title {}", quoted(name))
8148}
8149
8150/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8151/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8152/// it documents — so a value holding a qualifier's spelling is searched for rather than
8153/// obeyed.
8154fn quoted(value: &str) -> String {
8155 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8156 format!("\"{escaped}\"")
8157}
8158
8159/// The search qualifier for the issues updated at or after `since`.
8160///
8161/// Written to the second, rounded down, which can only widen what the search returns.
8162fn updated_qualifier(since: DateTime<Utc>) -> String {
8163 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8164}
8165
8166/// The search terms that narrow a board-scoped issue search to a task query's text and
8167/// metadata predicates, or `None` when it carries neither.
8168///
8169/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8170/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8171/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8172/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8173/// a metadata value searches both fields for both — wider than asked, never narrower, and
8174/// every candidate is confirmed in process afterwards.
8175///
8176/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8177/// matches whole tokens where a substring rule would match inside a word, so an item holding
8178/// the text only inside a longer word is not returned. A text of nothing but whitespace
8179/// matches every item, so it narrows nothing and is not sent.
8180fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8181 let text = query
8182 .text
8183 .as_ref()
8184 .filter(|text| !text.terms.trim().is_empty());
8185 if text.is_none() && query.metadata.is_empty() {
8186 return None;
8187 }
8188 let (title, body) = match text.map(|text| text.fields) {
8189 None => (false, true),
8190 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8191 Some(TextFields::Content) => (false, true),
8192 Some(TextFields::TitleOrContent) => (true, true),
8193 };
8194 let fields = match (title, body) {
8195 (true, true) => "in:title,body",
8196 (true, false) => "in:title",
8197 _ => "in:body",
8198 };
8199 let phrases = text
8200 .map(|text| text.terms.clone())
8201 .into_iter()
8202 .chain(
8203 query
8204 .metadata
8205 .iter()
8206 .map(|wanted| as_stored(wanted.value())),
8207 )
8208 .map(|phrase| quoted(&phrase))
8209 .collect::<Vec<_>>();
8210 Some(format!("{fields} {}", phrases.join(" ")))
8211}
8212
8213/// The search terms that narrow a board-scoped issue search to a project or document query's
8214/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8215/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8216fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8217 narrowing_qualifiers(&TaskQuery {
8218 text: text.cloned(),
8219 ..TaskQuery::default()
8220 })
8221}
8222
8223/// Refuses a project or document query's text GitHub's issue search cannot find, before
8224/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8225/// query's.
8226fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8227 refuse_unsearchable(&TaskQuery {
8228 text: text.cloned(),
8229 ..TaskQuery::default()
8230 })
8231}
8232
8233/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8234/// before anything is asked of GitHub.
8235///
8236/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8237/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8238/// left out, the search is every issue of the board. So this source says it cannot answer
8239/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8240/// nothing GitHub could search for, and keeps the board read it always had.
8241fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8242 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8243 letter or digit with a bounded query";
8244 if let Some(text) = &query.text
8245 && !text.terms.trim().is_empty()
8246 && !has_words(&text.terms)
8247 {
8248 return Err(SourceError::Refused {
8249 message: format!(
8250 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8251 text.terms
8252 ),
8253 });
8254 }
8255 if let Some(wanted) = query
8256 .metadata
8257 .iter()
8258 .find(|wanted| !has_words(wanted.value()))
8259 {
8260 return Err(SourceError::Refused {
8261 message: format!(
8262 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8263 wanted.value(),
8264 std::iter::once(wanted.key())
8265 .chain(wanted.path().iter().map(String::as_str))
8266 .collect::<Vec<_>>()
8267 .join("/"),
8268 ),
8269 });
8270 }
8271 Ok(())
8272}
8273
8274/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8275fn has_words(phrase: &str) -> bool {
8276 phrase.chars().any(char::is_alphanumeric)
8277}
8278
8279/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8280///
8281/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8282/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8283/// which GitHub's word match would read as different words.
8284fn as_stored(value: &str) -> String {
8285 let encoded = Value::String(value.to_owned()).to_string();
8286 encoded[1..encoded.len() - 1].to_owned()
8287}
8288
8289/// The one narrower question a task query carrying a text, metadata or origin predicate is
8290/// sent as.
8291enum Narrowing {
8292 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8293 Origin(String),
8294 /// The board-scoped issue search narrowed by these qualifiers.
8295 Search(String),
8296}
8297
8298impl Narrowing {
8299 /// What this question is remembered under for the length of one command.
8300 fn key(&self) -> String {
8301 match self {
8302 Self::Origin(origin) => format!("origin {origin}"),
8303 Self::Search(also) => format!("search {also}"),
8304 }
8305 }
8306}
8307
8308/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8309enum Resumed {
8310 /// It reported another page, which starts after this cursor.
8311 More(String),
8312 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8313 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8314 /// can go on walking the other connection.
8315 Ended(Option<String>),
8316}
8317
8318impl Resumed {
8319 /// Whether the connection has another page.
8320 const fn has_more(&self) -> bool {
8321 matches!(self, Self::More(_))
8322 }
8323
8324 /// The cursor to send this connection next.
8325 fn cursor(self) -> Option<String> {
8326 match self {
8327 Self::More(next) => Some(next),
8328 Self::Ended(last) => last,
8329 }
8330 }
8331}
8332
8333/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8334/// with no cursor to it, or from a cursor that does not advance.
8335fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8336 let info = connection
8337 .get("pageInfo")
8338 .ok_or_else(|| SourceError::Malformed {
8339 message: "GitHub connection has no pageInfo".into(),
8340 })?;
8341 let end = optional_str(info, "endCursor")?;
8342 if required_bool(info, "hasNextPage")? {
8343 let next = end.ok_or_else(|| SourceError::Malformed {
8344 message: "GitHub connection reports another page and no endCursor".into(),
8345 })?;
8346 validate_cursor_progress(after, next)?;
8347 return Ok(Resumed::More(next.to_owned()));
8348 }
8349 Ok(Resumed::Ended(
8350 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8351 ))
8352}
8353
8354/// The board, and every item on it this source reports.
8355#[derive(Clone)]
8356struct Board {
8357 id: String,
8358 fields: Value,
8359 items: Vec<Resolved>,
8360}
8361
8362/// What a write needs of the board and nothing more: its node id and its field
8363/// definitions, in the shape a read of the board's own `fields` gives them.
8364///
8365/// Deliberately no items. A write decides which item it writes, which parent it files
8366/// under and which far ends it names by reading each of them by its own id; this is the
8367/// half of the board those reads cannot carry, and holding no item is what keeps it from
8368/// ever being asked whether an item is there.
8369#[derive(Clone)]
8370struct BoardFields {
8371 id: BoardId,
8372 fields: Value,
8373}
8374
8375/// A board's node id: what a field write and `addProjectV2ItemById` address.
8376///
8377/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8378/// refused where it is read, and one an item names blank is read as not named at all.
8379#[derive(Clone)]
8380struct BoardId(String);
8381
8382/// Where one write left its item, for the record the rest of the command reads it out of.
8383///
8384/// A named record rather than a tuple because the update arm and the create arm each fill
8385/// all four, and two `Option`s of different meaning side by side in a tuple are two
8386/// positions a reader has to count.
8387struct Landed {
8388 /// The issue's own node id, which is the [`NativeId`] this source reports.
8389 content_id: NativeId,
8390 /// The board item's id, which is what a field write addresses.
8391 // 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.
8392 item_id: String,
8393 /// The web address GitHub gave the issue, when it gave one.
8394 // 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.
8395 url: Option<String>,
8396 /// The issue's number on its repository, when GitHub reported one.
8397 number: Option<u64>,
8398}
8399
8400impl BoardId {
8401 fn parse(id: &str) -> Result<Self, SourceError> {
8402 if id.trim().is_empty() {
8403 return Err(SourceError::Malformed {
8404 message: "GitHub named a board with a blank node id".into(),
8405 });
8406 }
8407 Ok(Self(id.to_owned()))
8408 }
8409
8410 fn as_str(&self) -> &str {
8411 &self.0
8412 }
8413}
8414
8415impl Board {
8416 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8417 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8418 let nodes = fields
8419 .get("nodes")
8420 .and_then(Value::as_array)
8421 .ok_or_else(|| SourceError::Malformed {
8422 message: "GitHub project fields.nodes is not an array".into(),
8423 })?;
8424 Ok(nodes
8425 .iter()
8426 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8427 }
8428}
8429
8430/// One board item, resolved into everything this source reports about it.
8431#[derive(Clone)]
8432struct Resolved {
8433 item_id: String,
8434 id: NativeId,
8435 content_kind: ContentKind,
8436 kind: BoardKind,
8437 title: String,
8438 body: Option<String>,
8439 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8440 /// that changes the slot alone has to keep byte for byte outside it.
8441 raw_body: Option<String>,
8442 status: Status,
8443 /// The name of the board `Status` option this item sits in, as the board spells it.
8444 option: Option<String>,
8445 /// What its `Priority` field says, read through this instance's mapping.
8446 priority: HeldPriority,
8447 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8448 closed: bool,
8449 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8450 delivers: Vec<TaskRef>,
8451 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8452 /// task.
8453 delivered_by: Vec<TaskRef>,
8454 labels: Vec<Label>,
8455 parent: Option<NativeId>,
8456 // 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.
8457 origin: Option<String>,
8458 /// The issue's own number on its repository, as GitHub reports it.
8459 ///
8460 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8461 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8462 /// an issue this run created whose creating mutation answered without one, which is a
8463 /// response GitHub's own schema says cannot happen and which a landed write is not
8464 /// worth failing over. An `Issue` read off the board always has one.
8465 number: Option<u64>,
8466 url: Option<String>,
8467 created_at: Option<DateTime<Utc>>,
8468 updated_at: Option<DateTime<Utc>>,
8469 own_repository: Option<Repository>,
8470 repositories: Vec<Repository>,
8471 slot: BTreeMap<String, Value>,
8472 /// The node id of the board this item sits on, when the read that reached it said.
8473 board_id: Option<String>,
8474 /// The definition of every board field this item holds a value of, in the shape a read
8475 /// of the board's own `fields` gives one.
8476 ///
8477 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8478 /// which says nothing about whether the board has it.
8479 fields: Vec<Value>,
8480 /// Every field the board this item sits on defines, as its own read of the board's
8481 /// `fields` gives them — when the read that reached the item carried them, which a read
8482 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8483 /// of the board.
8484 board_fields: Option<Value>,
8485 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8486 /// selects one — when the read that reached it carried the connection to its end, which a
8487 /// read of it by its own id does for any issue blocked by no more than a page. What a
8488 /// write reconciles that relationship against, and what a read of its forward edges in
8489 /// the same command answers with.
8490 blocked_by: Option<Vec<Value>>,
8491}
8492
8493impl Resolved {
8494 /// The board this item's own read names it on, when that read named one this source can
8495 /// address.
8496 fn named_board(&self) -> Option<BoardId> {
8497 self.board_id
8498 .as_deref()
8499 .and_then(|id| BoardId::parse(id).ok())
8500 }
8501
8502 /// The board's id and every field it defines, when the read that reached this item
8503 /// carried both — which a read of it by its own id does.
8504 fn carried_board(&self) -> Option<BoardFields> {
8505 Some(BoardFields {
8506 id: self.named_board()?,
8507 fields: self.board_fields.clone()?,
8508 })
8509 }
8510
8511 /// Whether this item holds a value of the board field called `name`, and so carries
8512 /// that field's definition. `false` says nothing about whether the board has the field.
8513 fn defines(&self, name: &str) -> bool {
8514 self.fields
8515 .iter()
8516 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8517 }
8518
8519 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8520 /// in a field of its own, and none of the five keys that are only an encoding.
8521 ///
8522 /// The two delivery keys are left out for every kind, not only for a task: they are
8523 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8524 /// document carrying one holds nothing a caller's own metadata could mean by it.
8525 fn metadata(&self) -> BTreeMap<String, Value> {
8526 let mut metadata = self.slot.clone();
8527 metadata.remove(Repository::METADATA_KEY);
8528 metadata.remove(DependencyEdge::RECORDED_KEY);
8529 metadata.remove(ItemKind::METADATA_KEY);
8530 metadata.remove(TaskRef::DELIVERS_KEY);
8531 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8532 // The board field is the origin, and the body's copy of it is only a mirror for the
8533 // issue search to find: an item whose field holds none has none, whatever its body
8534 // says, so no reader ever sees two answers.
8535 metadata.remove(ORIGIN_KEY);
8536 if let Some(origin) = &self.origin {
8537 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8538 }
8539 metadata
8540 }
8541
8542 /// Where this item is, as a link a reader can open.
8543 ///
8544 /// A board is a hosted place and every issue on it has a web address, so that address
8545 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8546 /// of place it is, so a reader knows to open it rather than to read a file out. It
8547 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8548 /// reported before, and this says what that address *is*.
8549 ///
8550 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8551 /// rather than a third variant, which is the contract's "the source did not say". An
8552 /// issue this run created is not one of those: its address comes back from the
8553 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8554 /// rather than from whenever the board read catches up.
8555 fn location(&self) -> Option<Location> {
8556 self.url.clone().map(Location::Url)
8557 }
8558
8559 /// The short handle this board's backend shows people for a task: the issue's number
8560 /// alone, as a decimal string.
8561 ///
8562 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8563 /// value for this backend. A draft has no number and so no handle, which is the
8564 /// contract's *absent* rather than a handle of some other shape — and the native
8565 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8566 /// derives from.
8567 fn key(&self) -> Option<String> {
8568 self.number.map(|number| number.to_string())
8569 }
8570
8571 /// Whether its `Priority` field holds a value at all, mapped or not.
8572 fn holds_priority(&self) -> bool {
8573 self.priority != HeldPriority::Read(Priority::None)
8574 }
8575
8576 /// The task this item is.
8577 ///
8578 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8579 /// reading that as a level would be a guess, and reading it as `none` would let the next
8580 /// copy clear a priority a person set.
8581 fn task(&self) -> Result<Task, SourceError> {
8582 let priority = match &self.priority {
8583 HeldPriority::Read(priority) => *priority,
8584 HeldPriority::Unmapped(option) => {
8585 return Err(SourceError::Malformed {
8586 message: format!(
8587 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8588 this source's priority_mapping does not name, so its priority cannot be \
8589 read; next: name {option:?} under priority_mapping, or move the item to \
8590 a mapped option",
8591 self.id,
8592 self.number
8593 .map(|number| format!(" (#{number})"))
8594 .unwrap_or_default()
8595 ),
8596 });
8597 }
8598 };
8599 Ok(Task {
8600 id: self.id.clone(),
8601 key: self.key(),
8602 title: self.title.clone(),
8603 content: self.body.clone(),
8604 status: self.status.clone(),
8605 priority,
8606 labels: self.labels.clone(),
8607 project: self.parent.clone(),
8608 url: self.url.clone(),
8609 location: self.location(),
8610 created_at: self.created_at,
8611 updated_at: self.updated_at,
8612 metadata: self.metadata(),
8613 repositories: self.repositories.clone(),
8614 delivers: self.delivers.clone(),
8615 delivered_by: self.delivered_by.clone(),
8616 })
8617 }
8618
8619 fn project(&self) -> Project {
8620 Project {
8621 id: self.id.clone(),
8622 title: self.title.clone(),
8623 content: self.body.clone(),
8624 status: self.status.clone(),
8625 labels: self.labels.clone(),
8626 url: self.url.clone(),
8627 location: self.location(),
8628 created_at: self.created_at,
8629 updated_at: self.updated_at,
8630 metadata: self.metadata(),
8631 repositories: self.repositories.clone(),
8632 }
8633 }
8634
8635 /// The same issue as a document: the project it is filed under, and no status and no
8636 /// dependencies, because a document is not work.
8637 fn document(&self) -> Document {
8638 Document {
8639 id: self.id.clone(),
8640 title: self.title.clone(),
8641 content: self.body.clone(),
8642 project: self.parent.clone(),
8643 labels: self.labels.clone(),
8644 url: self.url.clone(),
8645 location: self.location(),
8646 created_at: self.created_at,
8647 updated_at: self.updated_at,
8648 metadata: self.metadata(),
8649 repositories: self.repositories.clone(),
8650 }
8651 }
8652}
8653
8654/// Where one targeted update moves an item's status, and which of its two halves move.
8655struct StatusMove {
8656 /// The board the item's `Status` field is on.
8657 board: BoardId,
8658 /// The `Status` field's id.
8659 field: String,
8660 /// The option's id.
8661 option: String,
8662 /// The option's name, as the board spells it.
8663 name: String,
8664 /// What the status asks of the issue's state.
8665 target: StatusTarget,
8666 /// The status the item reads as once it is there.
8667 landed: Status,
8668 /// Which of the status's two halves differ from what the item holds.
8669 moves: Moves,
8670}
8671
8672/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8673/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8674/// and is not a value of this type.
8675#[derive(Clone, Copy, PartialEq, Eq)]
8676enum Moves {
8677 /// The option alone.
8678 Option,
8679 /// The issue's state alone: open, closed, or closed with another reason.
8680 State,
8681 /// Both.
8682 Both,
8683}
8684
8685impl Moves {
8686 /// What differs, or `None` when nothing does.
8687 const fn of(option: bool, state: bool) -> Option<Self> {
8688 match (option, state) {
8689 (true, true) => Some(Self::Both),
8690 (true, false) => Some(Self::Option),
8691 (false, true) => Some(Self::State),
8692 (false, false) => None,
8693 }
8694 }
8695
8696 /// Whether the option moves.
8697 const fn option(self) -> bool {
8698 matches!(self, Self::Option | Self::Both)
8699 }
8700
8701 /// Whether the issue's state moves.
8702 const fn state(self) -> bool {
8703 matches!(self, Self::State | Self::Both)
8704 }
8705}
8706
8707/// What one write is, and the status that comes with being it.
8708///
8709/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8710/// status and a task or a project always has one, so "a document carrying a status" and
8711/// "a task carrying none" are states a write cannot be in rather than states every use
8712/// site below has to defend against.
8713enum Written<'a> {
8714 /// A document, which is not work and so has no status at all.
8715 Document,
8716 /// A task or a project, and the status it is being written with.
8717 Work(ItemKind, &'a Status),
8718}
8719
8720impl Written<'_> {
8721 /// Which of the board's three kinds this write is.
8722 const fn kind(&self) -> BoardKind {
8723 match self {
8724 Self::Document => BoardKind::Document,
8725 Self::Work(kind, _) => BoardKind::Work(*kind),
8726 }
8727 }
8728
8729 /// The status this write carries. A document carries none, so a write of one says
8730 /// nothing about the issue's open or closed state and selects no board `Status`
8731 /// option.
8732 const fn status(&self) -> Option<&Status> {
8733 match self {
8734 Self::Document => None,
8735 Self::Work(_, status) => Some(status),
8736 }
8737 }
8738
8739 /// The status this write carries with the kind whose half of `status_mapping` it is
8740 /// written through.
8741 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8742 match self {
8743 Self::Document => None,
8744 Self::Work(kind, status) => Some((*kind, status)),
8745 }
8746 }
8747}
8748
8749/// The item being written, in the one shape all three write methods reach.
8750struct Incoming<'a> {
8751 written: Written<'a>,
8752 /// The title a person wrote. A document's goes onto the issue with
8753 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8754 title: &'a str,
8755 content: Option<&'a str>,
8756 assets: Option<&'a onetaskgraph_plugin_api::AssetWrite>,
8757 labels: &'a [Label],
8758 metadata: &'a BTreeMap<String, Value>,
8759 repositories: &'a [Repository],
8760 parent: Option<&'a NativeId>,
8761 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8762 /// what keeps either key out of their slot.
8763 delivers: &'a [TaskRef],
8764 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8765 delivered_by: &'a [TaskRef],
8766 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8767 /// project, a document, and every write to an instance with no `priority_mapping` —
8768 /// which is what keeps such a write's requests exactly what they were before.
8769 priority: Option<Priority>,
8770}
8771
8772/// What one write does to an item's `Priority` field.
8773enum PriorityWrite {
8774 /// Select this option of this field.
8775 Select {
8776 /// The `Priority` field's id.
8777 field: String,
8778 /// The mapped option's id.
8779 option: String,
8780 },
8781 /// Clear the field's value, which is what `none` is.
8782 Clear {
8783 /// The `Priority` field's id.
8784 field: String,
8785 },
8786}
8787
8788impl Incoming<'_> {
8789 /// The title this write puts on the issue.
8790 fn written_title(&self) -> String {
8791 match self.written {
8792 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8793 Written::Work(..) => self.title.to_owned(),
8794 }
8795 }
8796}
8797
8798#[derive(Clone, Copy, PartialEq, Eq)]
8799enum ContentKind {
8800 DraftIssue,
8801 Issue,
8802}
8803
8804/// What one board issue is: a document, or the work an [`ItemKind`] names.
8805///
8806/// A type of this source's own rather than an `ItemKind` with a third variant, because
8807/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8808/// document — the contract keeps a document out of that enum deliberately. Holding the
8809/// board's three answers in one value is what makes every place that asks "which is this?"
8810/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8811/// two thirds of the board.
8812#[derive(Clone, Copy, PartialEq, Eq)]
8813enum BoardKind {
8814 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8815 Document,
8816 /// Every other issue, and every draft.
8817 Work(ItemKind),
8818}
8819
8820impl BoardKind {
8821 /// Whose half of `status_mapping` an item of this kind reads its status through. A
8822 /// document has no status of its own, so the task half stands in for whatever the issue
8823 /// holds; nothing reports it.
8824 const fn status_kind(self) -> ItemKind {
8825 match self {
8826 Self::Document => ItemKind::Task,
8827 Self::Work(kind) => kind,
8828 }
8829 }
8830
8831 /// How a refusal names this kind to the person reading it.
8832 const fn describes(self) -> &'static str {
8833 match self {
8834 Self::Document => "document",
8835 Self::Work(kind) => kind.marker(),
8836 }
8837 }
8838}
8839
8840/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8841///
8842/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8843/// the shared cross-source journeys assert one answer to one question, so two sources
8844/// that disagree about what "carries the label bug" means fail them.
8845fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8846 let holds = |name: &String| {
8847 labels
8848 .iter()
8849 .any(|label| label.name.eq_ignore_ascii_case(name))
8850 };
8851 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8852 && filter.all_of.iter().all(holds)
8853 && !filter.none_of.iter().any(holds)
8854}
8855
8856/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8857/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8858fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8859 statuses.is_empty() || statuses.contains(&category)
8860}
8861
8862/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8863///
8864/// `content` is the item's own prose — the body with this source's trailing metadata
8865/// comment already taken off — so a search never matches an encoding the author of the
8866/// issue never wrote.
8867fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8868 let terms = query.terms.to_lowercase();
8869 let in_title = title.to_lowercase().contains(&terms);
8870 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8871 match query.fields {
8872 TextFields::Title => in_title,
8873 TextFields::Content => in_content,
8874 TextFields::TitleOrContent => in_title || in_content,
8875 }
8876}
8877
8878/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8879///
8880/// The project predicate is passed separately because a read narrowed to one project has
8881/// already answered it by asking *that project* for its own items — and re-applying it
8882/// there would compare the caller's selector, which may be a project's **name**, against
8883/// the id of the project that name resolved to, and keep nothing. Every other read passes
8884/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8885/// source really does apply.
8886fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8887 labels_match(&task.labels, &query.labels)
8888 && status_matches(task.status.category, &query.statuses)
8889 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8890 && match project {
8891 ProjectFilter::Any => true,
8892 ProjectFilter::Orphans => task.project.is_none(),
8893 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8894 }
8895 && query
8896 .text
8897 .as_ref()
8898 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8899 // Against the parsed metadata slot, and against the origin field, which is where
8900 // `Resolved::metadata` reads each of them from.
8901 && query.metadata_matches(&task.metadata)
8902 && query.origin_matches(&task.metadata)
8903}
8904
8905fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8906 labels_match(&project.labels, &query.labels)
8907 && status_matches(project.status.category, &query.statuses)
8908 && query
8909 .text
8910 .as_ref()
8911 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8912}
8913
8914/// The same three predicates a task query carries, minus the status filter.
8915///
8916/// A document is not work, so it has no status for one to compare against and the query
8917/// type carries none. The project predicate is the same one — a design issue filed under a
8918/// project issue is in that project, and one filed under nothing is in none — so it is
8919/// spelled the same way here rather than answered differently.
8920fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8921 labels_match(&document.labels, &query.labels)
8922 && match project {
8923 ProjectFilter::Any => true,
8924 ProjectFilter::Orphans => document.project.is_none(),
8925 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8926 }
8927 && query
8928 .text
8929 .as_ref()
8930 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8931}
8932
8933#[async_trait::async_trait]
8934impl TaskSource for GitHubProjectsSource {
8935 fn kind(&self) -> &'static str {
8936 KIND
8937 }
8938 fn capabilities(&self) -> Capabilities {
8939 Capabilities {
8940 projects: Support::Native,
8941 documents: Support::Native,
8942 comments: Support::Native,
8943 assets: Support::Native,
8944 priority: if self.priorities.is_some() {
8945 Support::Native
8946 } else {
8947 Support::Unsupported
8948 },
8949 filter_by_priority: Support::Native,
8950 filter_by_comment_activity: Support::Native,
8951 filter_by_metadata: Support::Native,
8952 filter_by_origin: Support::Native,
8953 orphan_tasks: Support::Native,
8954 filter_by_label: Support::Native,
8955 filter_by_status: Support::Native,
8956 search_title: Support::Native,
8957 search_content: Support::Native,
8958 task_dependencies: DependencySupport::BothDirections,
8959 project_dependencies: DependencySupport::BothDirections,
8960 max_page_size: MAX_PAGE_SIZE,
8961 }
8962 }
8963 async fn health(&self) -> Result<Health, SourceError> {
8964 let board = self.board_page(None, 1).await?;
8965 Ok(Health {
8966 reachable: true,
8967 detail: Some(format!(
8968 "reading GitHub project {}/{} ({})",
8969 self.owner,
8970 self.project_number,
8971 required_str(&board, "title")?
8972 )),
8973 })
8974 }
8975 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8976 self.item_by_id(id)
8977 .await?
8978 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8979 .map(|item| item.task())
8980 .transpose()
8981 }
8982 async fn task_assets(
8983 &self,
8984 id: &NativeId,
8985 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
8986 self.held_assets(id, BoardKind::Work(ItemKind::Task)).await
8987 }
8988 async fn task_asset(
8989 &self,
8990 id: &NativeId,
8991 name: &onetaskgraph_plugin_api::AssetName,
8992 ) -> Result<Option<Vec<u8>>, SourceError> {
8993 self.held_asset(id, BoardKind::Work(ItemKind::Task), name)
8994 .await
8995 }
8996 async fn set_task_rendering_with_assets(
8997 &self,
8998 id: &NativeId,
8999 content: &str,
9000 provenance: &Value,
9001 _answers: &BTreeMap<String, Value>,
9002 assets: &onetaskgraph_plugin_api::AssetWrite,
9003 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9004 self.replace_rendering(
9005 id,
9006 BoardKind::Work(ItemKind::Task),
9007 content,
9008 provenance,
9009 Some(assets),
9010 )
9011 .await
9012 }
9013 async fn document_assets(
9014 &self,
9015 id: &NativeId,
9016 ) -> Result<Vec<onetaskgraph_plugin_api::Asset>, SourceError> {
9017 self.held_assets(id, BoardKind::Document).await
9018 }
9019 async fn document_asset(
9020 &self,
9021 id: &NativeId,
9022 name: &onetaskgraph_plugin_api::AssetName,
9023 ) -> Result<Option<Vec<u8>>, SourceError> {
9024 self.held_asset(id, BoardKind::Document, name).await
9025 }
9026 async fn set_document_rendering_with_assets(
9027 &self,
9028 id: &NativeId,
9029 content: &str,
9030 provenance: &Value,
9031 _answers: &BTreeMap<String, Value>,
9032 assets: &onetaskgraph_plugin_api::AssetWrite,
9033 ) -> Result<Option<onetaskgraph_plugin_api::AssetsWritten>, SourceError> {
9034 self.replace_rendering(id, BoardKind::Document, content, provenance, Some(assets))
9035 .await
9036 }
9037 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
9038 Ok(self
9039 .item_by_id(id)
9040 .await?
9041 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9042 .map(|item| item.project()))
9043 }
9044 async fn query_tasks(
9045 &self,
9046 query: &TaskQuery,
9047 page: &PageRequest,
9048 ) -> Result<Page<Task>, SourceError> {
9049 validate_page(page)?;
9050 refuse_unsearchable(query)?;
9051 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
9052 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
9053 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
9054 (Some(also), None) => Some(also),
9055 (None, Some(since)) => Some(updated_qualifier(since)),
9056 (None, None) => None,
9057 };
9058 if let Some(also) = qualifiers {
9059 return self.search_tasks(query, page, &also).await;
9060 }
9061 }
9062
9063 // A read narrowed to one project asks that project for its own tasks, so nothing
9064 // about it costs what the rest of the board holds. A read carrying a text, metadata
9065 // or origin predicate asks GitHub the narrower question those predicates are, and a
9066 // read narrowed to comment activity alone asks the board's own issue search for the
9067 // issues updated since, which is every issue a comment could have been written or
9068 // edited on since. Every other task read is a question about the whole board and is
9069 // answered by reading it.
9070 let (held, membership) = match (&query.project, query.commented_since) {
9071 (ProjectFilter::Is(project), _) => (
9072 self.project_children(project).await?,
9073 // Answered by where these items came from; see `task_matches`.
9074 &ProjectFilter::Any,
9075 ),
9076 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
9077 match (self.narrowed(query).await?, since) {
9078 (Some(narrowed), _) => (narrowed, &query.project),
9079 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
9080 (None, None) => (self.board().await?.items, &query.project),
9081 }
9082 }
9083 };
9084 // Filtered before paged: a page of a filtered result is a page of the survivors,
9085 // never the survivors of a page.
9086 let mut tasks = Vec::new();
9087 for item in held
9088 .iter()
9089 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
9090 {
9091 let task = item.task()?;
9092 if task_matches(&task, query, membership)
9093 && self.commented_since(item, query.commented_since).await?
9094 {
9095 tasks.push(task);
9096 }
9097 }
9098 Ok(offset_page(
9099 tasks,
9100 numeric_cursor(page.cursor.as_ref())?,
9101 page.limit.min(MAX_PAGE_SIZE) as usize,
9102 ))
9103 }
9104 async fn query_projects(
9105 &self,
9106 query: &ProjectQuery,
9107 page: &PageRequest,
9108 ) -> Result<Page<Project>, SourceError> {
9109 validate_page(page)?;
9110 refuse_unsearchable_text(query.text.as_ref())?;
9111 // The projects a board holds are found by an issue search scoped to that board,
9112 // never by walking the board's own item connection: what tells a project from a
9113 // task is the `parent` each issue carries, which costs nothing to read. A query
9114 // carrying a text asks that search for the text too, so it reads the issues that
9115 // hold it rather than every issue of the board.
9116 let held = match self.text_searched(query.text.as_ref()).await? {
9117 Some(searched) => searched,
9118 None => self.board_issues().await?,
9119 };
9120 let projects = held
9121 .iter()
9122 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
9123 .map(Resolved::project)
9124 .filter(|project| project_matches(project, query))
9125 .collect();
9126 Ok(offset_page(
9127 projects,
9128 numeric_cursor(page.cursor.as_ref())?,
9129 page.limit.min(MAX_PAGE_SIZE) as usize,
9130 ))
9131 }
9132 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
9133 Ok(self
9134 .item_by_id(id)
9135 .await?
9136 .filter(|item| item.kind == BoardKind::Document)
9137 .map(|item| item.document()))
9138 }
9139 async fn query_documents(
9140 &self,
9141 query: &DocumentQuery,
9142 page: &PageRequest,
9143 ) -> Result<Page<Document>, SourceError> {
9144 validate_page(page)?;
9145 // Narrowed to one project, this is the same sub-issue read a task list scoped to
9146 // that project makes — a document filed under a project is a sub-issue of it too,
9147 // and which of them come back is the kind this caller asked for. Unscoped, a query
9148 // carrying a text asks the board-scoped issue search for it, as a task query does,
9149 // and only one carrying none reads the board.
9150 let (held, membership) = match &query.project {
9151 ProjectFilter::Is(project) => (
9152 self.project_children(project).await?,
9153 // Answered by where these items came from; see `task_matches`.
9154 &ProjectFilter::Any,
9155 ),
9156 ProjectFilter::Any | ProjectFilter::Orphans => {
9157 refuse_unsearchable_text(query.text.as_ref())?;
9158 match self.text_searched(query.text.as_ref()).await? {
9159 Some(searched) => (searched, &query.project),
9160 None => (self.board().await?.items, &query.project),
9161 }
9162 }
9163 };
9164 // Filtered before paged, exactly as a task read is: a page of a filtered result is
9165 // a page of the survivors, never the survivors of a page.
9166 let documents = held
9167 .iter()
9168 .filter(|item| item.kind == BoardKind::Document)
9169 .map(Resolved::document)
9170 .filter(|document| document_matches(document, query, membership))
9171 .collect();
9172 Ok(offset_page(
9173 documents,
9174 numeric_cursor(page.cursor.as_ref())?,
9175 page.limit.min(MAX_PAGE_SIZE) as usize,
9176 ))
9177 }
9178 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
9179 validate_page(page)?;
9180 let offset = numeric_cursor(page.cursor.as_ref())?;
9181 let mut labels = self
9182 .board()
9183 .await?
9184 .items
9185 .into_iter()
9186 .flat_map(|item| item.labels)
9187 .fold(Vec::new(), |mut all, label| {
9188 if !all.iter().any(|x: &Label| x.id == label.id) {
9189 all.push(label);
9190 }
9191 all
9192 });
9193 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
9194 Ok(offset_page(
9195 labels,
9196 offset,
9197 page.limit.min(MAX_PAGE_SIZE) as usize,
9198 ))
9199 }
9200 async fn task_dependencies(
9201 &self,
9202 id: &NativeId,
9203 direction: Direction,
9204 page: &PageRequest,
9205 ) -> Result<Page<DependencyEdge>, SourceError> {
9206 self.dependencies(id, ItemKind::Task, direction, page).await
9207 }
9208 async fn project_dependencies(
9209 &self,
9210 id: &NativeId,
9211 direction: Direction,
9212 page: &PageRequest,
9213 ) -> Result<Page<DependencyEdge>, SourceError> {
9214 self.dependencies(id, ItemKind::Project, direction, page)
9215 .await
9216 }
9217
9218 fn writes(&self) -> WriteSupport {
9219 WriteSupport::Supported
9220 }
9221
9222 /// Create or update one task.
9223 ///
9224 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9225 /// neither may name the task itself or name one task twice — and land in the body's
9226 /// metadata slot under their reserved keys, in place of any caller metadata of those
9227 /// names.
9228 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9229 self.write_task_assets(write, None)
9230 .await
9231 .map(|written| written.id)
9232 }
9233
9234 async fn write_task_with_assets(
9235 &self,
9236 write: &ItemWrite<Task>,
9237 _answers: Option<&BTreeMap<String, Value>>,
9238 assets: &onetaskgraph_plugin_api::AssetWrite,
9239 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9240 self.write_task_assets(write, Some(assets)).await
9241 }
9242
9243 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9244 self.write_item(
9245 &Incoming {
9246 written: Written::Work(ItemKind::Project, &write.item.status),
9247 title: &write.item.title,
9248 content: write.item.content.as_deref(),
9249 assets: None,
9250 labels: &write.item.labels,
9251 metadata: &write.item.metadata,
9252 repositories: &write.item.repositories,
9253 parent: None,
9254 delivers: &[],
9255 delivered_by: &[],
9256 priority: None,
9257 },
9258 write.target.as_ref(),
9259 &write.depends_on,
9260 )
9261 .await
9262 .map(|written| written.id)
9263 }
9264
9265 /// Create or update one document, which is one issue titled the way this board spells
9266 /// a document.
9267 ///
9268 /// Everything else is exactly a task write: caller metadata goes to the same canonical
9269 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9270 /// or a field this board cannot carry is refused by name rather than dropped, a target
9271 /// naming an issue this board does not hold is refused rather than created, and an
9272 /// issue this call created is taken back when the rest of the write fails.
9273 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9274 self.write_document_assets(write, None)
9275 .await
9276 .map(|written| written.id)
9277 }
9278
9279 async fn write_document_with_assets(
9280 &self,
9281 write: &ItemWrite<Document>,
9282 _answers: Option<&BTreeMap<String, Value>>,
9283 assets: &onetaskgraph_plugin_api::AssetWrite,
9284 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
9285 self.write_document_assets(write, Some(assets)).await
9286 }
9287
9288 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9289 /// which reads nothing; then the board's `Status` option. Over an existing item that is
9290 /// read off the item, as the write reads it, and the item is held among this command's
9291 /// resolved records so the write that follows reuses that read rather than repeating it;
9292 /// an item that does not carry the field takes the board's fields, which are held once
9293 /// read. A create is checked against the board's fields only when this command already
9294 /// holds them, because a create reads them together with its repository, in one request,
9295 /// and refuses a missing option before it writes anything.
9296 async fn check_status_write(
9297 &self,
9298 kind: ItemKind,
9299 category: StatusCategory,
9300 target: Option<&NativeId>,
9301 ) -> Result<(), SourceError> {
9302 let status = self.resolved_target(kind, category)?;
9303 if status.option().is_none() {
9304 return Ok(());
9305 }
9306 let fields = match target {
9307 Some(target) => {
9308 // A target this board does not hold is the write's own refusal to make.
9309 let Some(item) = self.bound_item(target).await? else {
9310 return Ok(());
9311 };
9312 self.resolved_cache()?.insert(target.clone(), item.clone());
9313 self.fields_for(Some(&item), true, false).await?.fields
9314 }
9315 None => {
9316 let held = self
9317 .board_cache()?
9318 .as_ref()
9319 .map(|board| board.fields.clone());
9320 match held.or_else(|| {
9321 self.fields_cache()
9322 .ok()
9323 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9324 }) {
9325 Some(fields) => fields,
9326 None => return Ok(()),
9327 }
9328 }
9329 };
9330 self.column_for(&fields, kind, category, &status)
9331 .map(|_| ())
9332 }
9333
9334 /// Set one task's status alone.
9335 ///
9336 /// An open target reopens a closed issue with an `updateIssue` carrying only its
9337 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9338 /// terminal target selects its mapped option, then closes with its fixed reason. No
9339 /// request carries a title, a body or a label. The status
9340 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9341 /// what a re-read reports.
9342 async fn set_task_status(
9343 &self,
9344 id: &NativeId,
9345 category: StatusCategory,
9346 ) -> Result<Option<Status>, SourceError> {
9347 self.set_status(id, category).await
9348 }
9349
9350 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9351 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9352 /// for `none`. Refused by an instance with no `priority_mapping`.
9353 async fn set_task_priority(
9354 &self,
9355 id: &NativeId,
9356 priority: Priority,
9357 ) -> Result<Option<Priority>, SourceError> {
9358 self.set_priority(id, priority).await
9359 }
9360
9361 /// Replace one task's content with a single body update that keeps the metadata slot
9362 /// byte for byte.
9363 async fn set_task_content(
9364 &self,
9365 id: &NativeId,
9366 content: &str,
9367 ) -> Result<Option<()>, SourceError> {
9368 self.replace_content(id, content).await
9369 }
9370
9371 /// Replace one task issue's content and its provenance slot entry with a single body
9372 /// update. The answers are not kept: see `replace_rendering`.
9373 async fn set_task_rendering(
9374 &self,
9375 id: &NativeId,
9376 content: &str,
9377 provenance: &Value,
9378 _answers: &BTreeMap<String, Value>,
9379 ) -> Result<Option<()>, SourceError> {
9380 self.replace_rendering(
9381 id,
9382 BoardKind::Work(ItemKind::Task),
9383 content,
9384 provenance,
9385 None,
9386 )
9387 .await
9388 .map(|written| written.map(|_| ()))
9389 }
9390
9391 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9392 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9393 async fn set_document_rendering(
9394 &self,
9395 id: &NativeId,
9396 content: &str,
9397 provenance: &Value,
9398 _answers: &BTreeMap<String, Value>,
9399 ) -> Result<Option<()>, SourceError> {
9400 self.replace_rendering(id, BoardKind::Document, content, provenance, None)
9401 .await
9402 .map(|written| written.map(|_| ()))
9403 }
9404
9405 /// Replace one project issue's content and its provenance slot entry, on exactly the
9406 /// terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9407 async fn set_project_rendering(
9408 &self,
9409 id: &NativeId,
9410 content: &str,
9411 provenance: &Value,
9412 _answers: &BTreeMap<String, Value>,
9413 ) -> Result<Option<()>, SourceError> {
9414 self.replace_rendering(
9415 id,
9416 BoardKind::Work(ItemKind::Project),
9417 content,
9418 provenance,
9419 None,
9420 )
9421 .await
9422 .map(|written| written.map(|_| ()))
9423 }
9424
9425 /// Apply a targeted update with one read of the item and a write only for what differs:
9426 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9427 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9428 async fn update_task(
9429 &self,
9430 id: &NativeId,
9431 update: &TaskUpdate,
9432 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9433 self.targeted_update(id, update).await
9434 }
9435
9436 /// Replace one task's `delivered_by` with a single body update that changes the
9437 /// metadata slot and nothing outside it.
9438 async fn set_delivered_by(
9439 &self,
9440 id: &NativeId,
9441 delivered_by: &[TaskRef],
9442 ) -> Result<Option<()>, SourceError> {
9443 self.replace_delivered_by(id, delivered_by).await
9444 }
9445
9446 /// Set one key of one task issue's metadata with a single body update that changes the
9447 /// metadata slot and nothing outside it — no title, label, state or board field request —
9448 /// and sends nothing when the task already holds that value under the key.
9449 async fn set_task_metadata(
9450 &self,
9451 id: &NativeId,
9452 key: &MetadataKey,
9453 value: &Value,
9454 ) -> Result<Option<Task>, SourceError> {
9455 Ok(self
9456 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9457 .await?
9458 .map(|item| item.task())
9459 .transpose()?)
9460 }
9461
9462 /// Set one key of one project issue's metadata, on exactly the terms of
9463 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9464 async fn set_project_metadata(
9465 &self,
9466 id: &NativeId,
9467 key: &MetadataKey,
9468 value: &Value,
9469 ) -> Result<Option<Project>, SourceError> {
9470 Ok(self
9471 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9472 .await?
9473 .map(|item| item.project()))
9474 }
9475
9476 /// Set one key of one design-document issue's metadata, on exactly the terms of
9477 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9478 async fn set_document_metadata(
9479 &self,
9480 id: &NativeId,
9481 key: &MetadataKey,
9482 value: &Value,
9483 ) -> Result<Option<Document>, SourceError> {
9484 Ok(self
9485 .set_slot_key(id, BoardKind::Document, key, value)
9486 .await?
9487 .map(|item| item.document()))
9488 }
9489
9490 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9491 self.delete_item(id).await
9492 }
9493
9494 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9495 self.delete_item(id).await
9496 }
9497
9498 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9499 self.delete_item(id).await
9500 }
9501
9502 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9503 ///
9504 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9505 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9506 ///
9507 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9508 /// board is the read of its comments. A draft this process already resolved is refused
9509 /// without one.
9510 async fn task_comments(
9511 &self,
9512 task: &NativeId,
9513 page: &PageRequest,
9514 ) -> Result<Option<Page<Comment>>, SourceError> {
9515 validate_page(page)?;
9516 let cached = self.resolved_cache()?.get(task).cloned();
9517 if let Some(item) = cached {
9518 if item.kind != BoardKind::Work(ItemKind::Task) {
9519 return Ok(None);
9520 }
9521 if item.content_kind == ContentKind::DraftIssue {
9522 return Err(self.draft_has_no_comments(task));
9523 }
9524 }
9525 match self.issue_detail(task, page).await? {
9526 Some(TaskDetailRead {
9527 comments: Some(comments),
9528 ..
9529 }) => comments,
9530 _ => Ok(None),
9531 }
9532 }
9533
9534 /// Every id's task, with the first page of its comments when `comments` names it:
9535 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9536 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9537 async fn get_task_details(
9538 &self,
9539 ids: &[NativeId],
9540 comments: Option<&PageRequest>,
9541 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9542 if let Some(page) = comments
9543 && let Err(error) = validate_page(page)
9544 {
9545 return ids.iter().map(|_| Err(error.clone())).collect();
9546 }
9547 match (ids, comments) {
9548 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9549 ([id], None) => vec![self.task_read(id).await],
9550 _ => self.issue_details(ids, comments).await,
9551 }
9552 }
9553
9554 /// Add one comment to the task's issue, as the account the token belongs to.
9555 ///
9556 /// The author is refused before anything is sent — not even the task is read — because
9557 /// no answer GitHub could give would make posting under another name than the one asked
9558 /// for the right outcome.
9559 async fn add_comment(
9560 &self,
9561 task: &NativeId,
9562 comment: &NewComment,
9563 ) -> Result<Option<Comment>, SourceError> {
9564 if let Some(author) = &comment.author {
9565 return Err(SourceError::Refused {
9566 message: format!(
9567 "source {} cannot post a comment as {author:?}: GitHub records the account \
9568 the token signs in as the author of every comment; next: leave --author \
9569 out, and the comment is posted as that account",
9570 self.name
9571 ),
9572 });
9573 }
9574 let Some(issue) = self.commented_issue(task).await? else {
9575 return Ok(None);
9576 };
9577 let data = self
9578 .graphql(
9579 graphql::ADD_COMMENT,
9580 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9581 )
9582 .await?;
9583 let subject = data
9584 .pointer("/addComment/subject")
9585 .filter(|value| !value.is_null())
9586 .ok_or_else(|| SourceError::Malformed {
9587 message: "GitHub comment addition returned no subject".into(),
9588 })?;
9589 if required_str(subject, "id")? != issue.0 {
9590 return Err(SourceError::Malformed {
9591 message: "GitHub comment addition answered about another issue".into(),
9592 });
9593 }
9594 let added = data
9595 .pointer("/addComment/commentEdge/node")
9596 .filter(|value| !value.is_null())
9597 .ok_or_else(|| SourceError::Malformed {
9598 message: "GitHub comment addition returned no comment".into(),
9599 })?;
9600 let added = comment_from(added)?;
9601 self.remember_commented(&issue)?;
9602 Ok(Some(added))
9603 }
9604
9605 async fn edit_comment(
9606 &self,
9607 task: &NativeId,
9608 comment: &NativeId,
9609 body: &CommentBody,
9610 ) -> Result<Option<Comment>, SourceError> {
9611 let Some(issue) = self.commented_issue(task).await? else {
9612 return Ok(None);
9613 };
9614 if !self.comment_is_on(&issue, comment).await? {
9615 return Ok(None);
9616 }
9617 let data = self
9618 .graphql(
9619 graphql::UPDATE_COMMENT,
9620 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9621 )
9622 .await?;
9623 let edited = data
9624 .pointer("/updateIssueComment/issueComment")
9625 .filter(|value| !value.is_null())
9626 .ok_or_else(|| SourceError::Malformed {
9627 message: "GitHub comment update returned no comment".into(),
9628 })?;
9629 let edited = comment_from(edited)?;
9630 if edited.id != *comment {
9631 return Err(SourceError::Malformed {
9632 message: "GitHub comment update returned the wrong comment".into(),
9633 });
9634 }
9635 self.remember_commented(&issue)?;
9636 Ok(Some(edited))
9637 }
9638
9639 async fn delete_comment(
9640 &self,
9641 task: &NativeId,
9642 comment: &NativeId,
9643 ) -> Result<Option<NativeId>, SourceError> {
9644 let Some(issue) = self.commented_issue(task).await? else {
9645 return Ok(None);
9646 };
9647 if !self.comment_is_on(&issue, comment).await? {
9648 return Ok(None);
9649 }
9650 let data = self
9651 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9652 .await?;
9653 // The payload says nothing about the comment it removed, so what is checked is that
9654 // GitHub answered the mutation at all rather than leaving it unanswered.
9655 data.get("deleteIssueComment")
9656 .filter(|value| !value.is_null())
9657 .ok_or_else(|| SourceError::Malformed {
9658 message: "GitHub comment deletion returned no payload".into(),
9659 })?;
9660 Ok(Some(comment.clone()))
9661 }
9662
9663 /// Every request this source has recorded, and what each of GitHub's two budgets was
9664 /// attributed — read off the same accounting the session report is rendered from, so
9665 /// the two cannot count one request two ways.
9666 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9667 Ok(Some(self.ledger.snapshot().metering()))
9668 }
9669
9670 /// Drop every item, search answer and board read this source holds, so the next command
9671 /// reads the board as a person has since left it.
9672 ///
9673 /// Every one of those is held on the assumption that nothing but this source writes the
9674 /// board while a command runs, which stops being true the moment the command is over: a
9675 /// body a person edited would be overwritten from the record held here, and a card they
9676 /// moved would be read as still where this source left it. The board's own field
9677 /// definitions go too, because a person can add or delete a `Status` option and a write
9678 /// resolved against the held list would not re-read on a miss. What stays is what stays
9679 /// valid in normal use: each repository's node id, which a miss re-reads, the pacing of
9680 /// mutations, which is about GitHub's limiter rather than anybody's work, and the running
9681 /// accounting [`metering`](TaskSource::metering) answers from.
9682 ///
9683 /// Infallible in practice: a lock an earlier failure poisoned is cleared rather than
9684 /// refused, because clearing it is what puts it right.
9685 async fn end_command(&self) -> Result<(), SourceError> {
9686 fn clear<T: Default>(held: &Mutex<T>) {
9687 *held
9688 .lock()
9689 .unwrap_or_else(std::sync::PoisonError::into_inner) = T::default();
9690 held.clear_poison();
9691 }
9692 clear(&self.created);
9693 clear(&self.updated);
9694 clear(&self.commented);
9695 clear(&self.board_cache);
9696 clear(&self.search_cache);
9697 clear(&self.narrowed_cache);
9698 clear(&self.search_next);
9699 clear(&self.resolved_cache);
9700 clear(&self.fields_cache);
9701 Ok(())
9702 }
9703}
9704
9705/// One issue comment as the contract carries it.
9706///
9707/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9708/// and when it answers an actor with no login, because either way the source did not say who
9709/// wrote it — which is what an absent author means, rather than an author called nothing.
9710fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9711 Ok(Comment {
9712 id: NativeId(required_str(value, "id")?.to_owned()),
9713 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9714 .map(str::to_owned),
9715 created_at: optional_time(value, "createdAt")?,
9716 updated_at: optional_time(value, "updatedAt")?,
9717 body: required_str(value, "body")?.to_owned(),
9718 url: optional_str(value, "url")?.map(str::to_owned),
9719 })
9720}
9721
9722/// The page of comments one issue node carries, resumed from `after`.
9723fn comment_page(
9724 node: &Value,
9725 issue: &str,
9726 after: Option<&str>,
9727) -> Result<Page<Comment>, SourceError> {
9728 let connection = node
9729 .get("comments")
9730 .filter(|value| !value.is_null())
9731 .ok_or_else(|| SourceError::Malformed {
9732 message: format!("GitHub issue {issue} answered with no comments connection"),
9733 })?;
9734 let items = optional_nodes(Some(connection), "issue comments")?
9735 .into_iter()
9736 .flatten()
9737 .map(comment_from)
9738 .collect::<Result<Vec<_>, _>>()?;
9739 let next = next_cursor(connection)?;
9740 if let Some(next) = &next {
9741 validate_cursor_progress(after, &next.0)?;
9742 }
9743 Ok(Page { items, next })
9744}
9745
9746/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9747/// end — `None` when it carried none, or a page with more past it.
9748fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9749 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9750 return Ok(None);
9751 };
9752 if next_cursor(connection)?.is_some() {
9753 return Ok(None);
9754 }
9755 Ok(Some(
9756 optional_nodes(Some(connection), "blocked-by issues")?
9757 .into_iter()
9758 .flatten()
9759 .cloned()
9760 .collect(),
9761 ))
9762}
9763
9764/// Where the recorded tail of a dependency walk resumes; see
9765/// [`GitHubProjectsSource::recorded_edges`].
9766const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9767
9768/// The board text field this source keeps a copy's origin in.
9769///
9770/// Named after the key it holds, and held to that name by the guard below rather than by
9771/// a reader noticing.
9772const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9773
9774/// The metadata key that field holds.
9775///
9776/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9777/// constructs or interprets the qualified id it carries. This source names it only to
9778/// route it — a short, typed value belongs in a typed field rather than in the body slot
9779/// a caller's own prose shares.
9780///
9781/// Restated rather than imported, because no plugin crate may depend on the engine. What
9782/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9783/// target in `check`: it reads the engine's own literal and fails naming the file and the
9784/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9785/// that creates a second item every run instead of finding the one it wrote — and that is
9786/// too late to learn it.
9787const ORIGIN_KEY: &str = "onetaskgraph.origin";
9788
9789/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9790///
9791/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9792/// is derived from the far end, never written down on the near item — so only a forward
9793/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9794/// it did not come from, and it is told so rather than answered with an empty page that
9795/// reads as a walk which ended.
9796fn recorded_offset(
9797 cursor: Option<&str>,
9798 direction: Direction,
9799) -> Result<Option<usize>, SourceError> {
9800 cursor
9801 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9802 .map(|offset| {
9803 if direction != Direction::DependsOn {
9804 return Err(SourceError::Config {
9805 message: format!(
9806 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9807 reverse dependency read never issues; resume it in the direction \
9808 that reported it"
9809 ),
9810 });
9811 }
9812 offset.parse().map_err(|_| SourceError::Config {
9813 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9814 })
9815 })
9816 .transpose()
9817}
9818
9819fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9820 let mut page = offset_page(edges, offset, limit.max(1));
9821 page.next = page
9822 .next
9823 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9824 page
9825}
9826
9827/// The kind of one issue reached through a dependency connection.
9828///
9829/// The same questions the board scan asks, over the fields the dependency document
9830/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9831/// then anything with sub-issues or the marker is a project.
9832///
9833/// # Errors
9834///
9835/// A far end this board holds as a document is refused rather than reported. The two
9836/// answers that are not refusals would both be wrong: reporting it as a task names an id
9837/// no task read of this source can find, and reporting it as a project names one no
9838/// project read can. There is no third value to return — `ItemKind` has no document
9839/// variant, because nothing may point at a document — so the relationship itself is what
9840/// the person is told about.
9841fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9842 let id = required_str(value, "id")?;
9843 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9844 return Err(SourceError::Refused {
9845 message: format!(
9846 "GitHub issue {id} is a document of this board — its title begins \
9847 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9848 on by one; next: remove that issue's blocking relationship on this board"
9849 ),
9850 });
9851 }
9852 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9853 if parent.is_some() {
9854 return Ok(ItemKind::Task);
9855 }
9856 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9857 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9858 message: format!("GitHub issue {id}: {message}"),
9859 })?;
9860 let sub_issues = sub_issue_total(value)?;
9861 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9862 ItemKind::Project
9863 } else {
9864 ItemKind::Task
9865 })
9866}
9867
9868/// The `IssueStateUpdateInput` one status target asks for.
9869///
9870/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9871/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9872/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9873/// would report a change forever. A document has no status at all, and asks for neither.
9874fn state_input(target: Option<&StatusTarget>) -> Value {
9875 match target {
9876 Some(StatusTarget::Terminal(_, reason)) => {
9877 json!({"value":"CLOSED","stateReason":reason.reason()})
9878 }
9879 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9880 // A document has no status, so a write of one says nothing about the issue's open
9881 // or closed state rather than forcing it open: `stateInput` is what carries that
9882 // instruction, and an explicit null asks for no change to it.
9883 None => Value::Null,
9884 }
9885}
9886
9887/// The metadata one write stores in the item's body slot.
9888///
9889/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9890/// rather than carried: the kind marker so an empty project stays readable, the
9891/// repository list only when it is not exactly the issue's own repository, and the far
9892/// ends no relationship here can name.
9893///
9894/// The copy origin is the one typed field that is also mirrored here, and only as a
9895/// mirror: it lands in the board's origin field as well, which stays the one every reader
9896/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9897/// and catches up with a write in seconds rather than minutes — can find the item by it.
9898/// A reader of the release before this one drops the slot's copy and reads the field, so an
9899/// item written here still reads with exactly one origin there.
9900fn slot_metadata(
9901 incoming: &Incoming<'_>,
9902 own_repository: Option<&Repository>,
9903 fallback: &[DependencyEdge],
9904) -> BTreeMap<String, Value> {
9905 let mut metadata = incoming.metadata.clone();
9906 match metadata.remove(ORIGIN_KEY) {
9907 Some(Value::String(origin)) if !origin.is_empty() => {
9908 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9909 }
9910 _ => {}
9911 }
9912 match incoming.written.kind() {
9913 BoardKind::Work(kind) => metadata.insert(
9914 ItemKind::METADATA_KEY.to_owned(),
9915 Value::String(kind.marker().to_owned()),
9916 ),
9917 // A document is told by its title, so it carries no kind marker: that key names
9918 // what a dependency endpoint points at, and nothing may point at a document.
9919 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9920 };
9921 let derivable = own_repository
9922 .map(|own| incoming.repositories == [own.clone()])
9923 .unwrap_or(incoming.repositories.is_empty());
9924 if derivable {
9925 metadata.remove(Repository::METADATA_KEY);
9926 } else {
9927 metadata.insert(
9928 Repository::METADATA_KEY.to_owned(),
9929 Value::Array(
9930 incoming
9931 .repositories
9932 .iter()
9933 .map(|repository| Value::String(repository.as_str().to_owned()))
9934 .collect(),
9935 ),
9936 );
9937 }
9938 // The typed lists are what land, whatever the caller's own metadata held under their
9939 // keys: a key of either name travelling beside the field would otherwise be a second
9940 // answer to the same question, and the field is the one the contract names.
9941 for (key, entries) in [
9942 (TaskRef::DELIVERS_KEY, incoming.delivers),
9943 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9944 ] {
9945 set_task_list(&mut metadata, key, entries);
9946 }
9947 record_edges(&mut metadata, fallback);
9948 metadata
9949}
9950
9951/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9952/// one slot's metadata, or no such key when there are none.
9953fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9954 if fallback.is_empty() {
9955 metadata.remove(DependencyEdge::RECORDED_KEY);
9956 } else {
9957 metadata.insert(
9958 DependencyEdge::RECORDED_KEY.to_owned(),
9959 Value::Array(
9960 fallback
9961 .iter()
9962 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9963 .collect(),
9964 ),
9965 );
9966 }
9967}
9968
9969/// Every label one item carries, from its content's own connection and nowhere else.
9970///
9971/// There is no second place to read one from: no document this source sends selects the
9972/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9973/// cannot carry one at all. The module documentation records the three schema facts that
9974/// settle it.
9975fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9976 optional_nodes(content.get("labels"), "content labels")?
9977 .into_iter()
9978 .flatten()
9979 .map(|v| {
9980 Ok(Label {
9981 id: NativeId(required_str(v, "id")?.to_owned()),
9982 name: required_str(v, "name")?.to_owned(),
9983 color: optional_str(v, "color")?.map(str::to_owned),
9984 })
9985 })
9986 .collect()
9987}
9988
9989/// The definition of each board field one item's values are values of, in the shape a read
9990/// of the board's own `fields` gives one.
9991///
9992/// A value names its field through a fragment on that field's own type, so the type is
9993/// known from which kind of value it is: a single-select value's field is a
9994/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9995/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9996fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9997 field_values
9998 .iter()
9999 .filter_map(|value| {
10000 let field = value.get("field")?.as_object()?;
10001 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
10002 let typename = if value.get("text").is_some() {
10003 "ProjectV2Field"
10004 } else if value.get("name").is_some() {
10005 "ProjectV2SingleSelectField"
10006 } else {
10007 return None;
10008 };
10009 let mut defined = field.clone();
10010 defined.insert("__typename".to_owned(), json!(typename));
10011 Some(Value::Object(defined))
10012 })
10013 .collect()
10014}
10015
10016fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
10017 let Some(node) = field_values
10018 .iter()
10019 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
10020 else {
10021 return Ok(None);
10022 };
10023 Ok(optional_str(node, "text")?.map(str::to_owned))
10024}
10025
10026fn valid_github_owner(owner: &str) -> bool {
10027 !owner.is_empty()
10028 && owner.len() <= 39
10029 && !owner.starts_with('-')
10030 && !owner.ends_with('-')
10031 && !owner.contains("--")
10032 && owner
10033 .bytes()
10034 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
10035}
10036
10037/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
10038/// neither of the two names a path segment already means.
10039fn valid_github_repository_name(name: &str) -> bool {
10040 !name.is_empty()
10041 && name.len() <= 100
10042 && name != "."
10043 && name != ".."
10044 && name
10045 .bytes()
10046 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
10047}
10048
10049fn valid_environment_name(name: &str) -> bool {
10050 let mut bytes = name.bytes();
10051 bytes
10052 .next()
10053 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
10054 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
10055}
10056
10057/// How many sub-issues one issue has.
10058///
10059/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
10060/// absent or non-integer one is a response this source cannot read — and reading it as
10061/// zero would classify a project as a task, which is exactly the mistake the marker
10062/// exists to keep from happening quietly.
10063fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
10064 let summary = issue
10065 .get("subIssuesSummary")
10066 .ok_or_else(|| SourceError::Malformed {
10067 message: "GitHub issue is missing subIssuesSummary".into(),
10068 })?;
10069 summary
10070 .get("total")
10071 .and_then(Value::as_u64)
10072 .ok_or_else(|| SourceError::Malformed {
10073 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
10074 })
10075}
10076
10077/// One issue's own `number`.
10078///
10079/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
10080/// an issue in this module asks for it. So a read of one that comes back without it, or
10081/// with something that is not an unsigned integer, is a response this source cannot read —
10082/// absence here is **not** "this issue has no number". A draft is the content that has
10083/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
10084/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
10085fn issue_number(issue: &Value) -> Result<u64, SourceError> {
10086 issue
10087 .get("number")
10088 .and_then(Value::as_u64)
10089 .ok_or_else(|| SourceError::Malformed {
10090 message: "GitHub issue number is missing or is not an unsigned integer".into(),
10091 })
10092}
10093
10094/// The `number` a creating mutation answered with, and `None` when it answered without one;
10095/// why a missing one is tolerated is at the call in `create_and_file_issue`.
10096fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
10097 match created.get("number") {
10098 None | Some(Value::Null) => Ok(None),
10099 Some(value) => value
10100 .as_u64()
10101 .map(Some)
10102 .ok_or_else(|| SourceError::Malformed {
10103 message: "GitHub created issue number is not an unsigned integer".into(),
10104 }),
10105 }
10106}
10107
10108fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10109 value
10110 .get(field)
10111 .and_then(Value::as_str)
10112 .ok_or_else(|| SourceError::Malformed {
10113 message: format!("GitHub response is missing string field {field}"),
10114 })
10115}
10116
10117fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
10118 let found = required_str(value, field)?;
10119 if found.trim().is_empty() {
10120 return Err(SourceError::Malformed {
10121 message: format!("GitHub response has blank string field {field}"),
10122 });
10123 }
10124 Ok(found)
10125}
10126
10127/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
10128/// needs one — Linear spells them too, in its own description field.
10129///
10130/// Restated rather than shared, because a plugin crate depends on the contract crate and
10131/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
10132/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
10133/// source round-trips its own writes perfectly well under its own spelling.
10134const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
10135const METADATA_CLOSE: &str = "\n-->";
10136
10137/// What the composer puts between a non-empty visible body and the slot, and the one thing
10138/// the parser takes off the visible body when it takes the slot off — exactly once, so every
10139/// other trailing byte of the body comes back as it was written.
10140// 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.
10141const METADATA_SEPARATOR: &str = "\n\n";
10142
10143/// The visible body and the metadata slot at the end of it.
10144///
10145/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
10146/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
10147/// own content and is left alone. The visible body is everything before the slot less the
10148/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
10149fn metadata_body(
10150 body: Option<String>,
10151) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
10152 let Some(body) = body else {
10153 return Ok((None, BTreeMap::new()));
10154 };
10155 let Some(slot) = slot_span(&body)? else {
10156 return Ok((Some(body), BTreeMap::new()));
10157 };
10158 let metadata =
10159 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
10160 SourceError::Malformed {
10161 message: format!(
10162 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
10163 ),
10164 }
10165 })?;
10166 let before = &body[..slot.start];
10167 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
10168 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
10169}
10170
10171/// Where the metadata slot sits in one body, as byte offsets into it.
10172struct SlotSpan {
10173 /// Where [`METADATA_OPEN`] begins.
10174 start: usize,
10175 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
10176 encoded_start: usize,
10177 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
10178 encoded_end: usize,
10179 /// Just past [`METADATA_CLOSE`].
10180 end: usize,
10181}
10182
10183/// The slot at the very end of `body`, or `None` when it has none.
10184///
10185/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
10186/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
10187/// slot.
10188fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
10189 let Some(start) = body.rfind(METADATA_OPEN) else {
10190 return Ok(None);
10191 };
10192 let encoded_start = start + METADATA_OPEN.len();
10193 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
10194 return Err(SourceError::Malformed {
10195 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
10196 });
10197 };
10198 let encoded_end = encoded_start + relative_end;
10199 let end = encoded_end + METADATA_CLOSE.len();
10200 if !body[end..].trim().is_empty() {
10201 return Ok(None);
10202 }
10203 Ok(Some(SlotSpan {
10204 start,
10205 encoded_start,
10206 encoded_end,
10207 end,
10208 }))
10209}
10210
10211/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
10212/// slot as it was.
10213///
10214/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
10215/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
10216/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
10217/// or alone in an empty body — and a body with no slot that is given no metadata is
10218/// returned as it is.
10219fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
10220 let encoded = if metadata.is_empty() {
10221 None
10222 } else {
10223 Some(
10224 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10225 message: error.to_string(),
10226 })?,
10227 )
10228 };
10229 Ok(match (slot_span(body)?, encoded) {
10230 (Some(slot), Some(encoded)) => format!(
10231 "{}{encoded}{}",
10232 &body[..slot.encoded_start],
10233 &body[slot.encoded_end..]
10234 ),
10235 (Some(slot), None) => {
10236 let before = &body[..slot.start];
10237 format!(
10238 "{}{}",
10239 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10240 &body[slot.end..]
10241 )
10242 }
10243 (None, None) => body.to_owned(),
10244 (None, Some(encoded)) if body.is_empty() => {
10245 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10246 }
10247 (None, Some(encoded)) => {
10248 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10249 }
10250 })
10251}
10252
10253/// `body` with everything before its metadata slot replaced by `content`, and the slot
10254/// itself kept byte for byte.
10255///
10256/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10257/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10258/// `content` is empty — so a read of the result reports `content` as the visible body and
10259/// the slot's metadata exactly as it was.
10260fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10261 let Some(slot) = slot_span(body)? else {
10262 return Ok(content.to_owned());
10263 };
10264 let kept = &body[slot.start..];
10265 Ok(if content.is_empty() {
10266 kept.to_owned()
10267 } else {
10268 format!("{content}{METADATA_SEPARATOR}{kept}")
10269 })
10270}
10271
10272/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10273fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10274 if entries.is_empty() {
10275 metadata.remove(key);
10276 } else {
10277 metadata.insert(
10278 key.to_owned(),
10279 Value::Array(
10280 entries
10281 .iter()
10282 .map(|entry| Value::String(entry.as_str().to_owned()))
10283 .collect(),
10284 ),
10285 );
10286 }
10287}
10288
10289fn compose_body(
10290 content: Option<&str>,
10291 metadata: &BTreeMap<String, Value>,
10292) -> Result<Option<String>, SourceError> {
10293 let visible = content.unwrap_or_default();
10294 if metadata.is_empty() {
10295 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10296 }
10297 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10298 message: error.to_string(),
10299 })?;
10300 Ok(Some(if visible.is_empty() {
10301 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10302 } else {
10303 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10304 }))
10305}
10306
10307fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10308 value
10309 .get(field)
10310 .and_then(Value::as_bool)
10311 .ok_or_else(|| SourceError::Malformed {
10312 message: format!("GitHub response is missing boolean field {field}"),
10313 })
10314}
10315fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10316 match value.get(field) {
10317 None | Some(Value::Null) => Ok(None),
10318 Some(value) => value
10319 .as_str()
10320 .map(Some)
10321 .ok_or_else(|| SourceError::Malformed {
10322 message: format!("GitHub response field {field} is not a string or null"),
10323 }),
10324 }
10325}
10326fn optional_nodes<'a>(
10327 connection: Option<&'a Value>,
10328 name: &str,
10329) -> Result<Option<&'a Vec<Value>>, SourceError> {
10330 match connection {
10331 None | Some(Value::Null) => Ok(None),
10332 Some(value) => value
10333 .get("nodes")
10334 .and_then(Value::as_array)
10335 .map(Some)
10336 .ok_or_else(|| SourceError::Malformed {
10337 message: format!("GitHub {name}.nodes is not an array"),
10338 }),
10339 }
10340}
10341fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10342 let page_info = connection
10343 .get("pageInfo")
10344 .ok_or_else(|| SourceError::Malformed {
10345 message: format!("GitHub {name} has no pageInfo"),
10346 })?;
10347 if required_bool(page_info, "hasNextPage")? {
10348 return Err(SourceError::Malformed {
10349 message: format!(
10350 "GitHub {name} exceeds the supported nested connection size of {size}"
10351 ),
10352 });
10353 }
10354 Ok(())
10355}
10356fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10357 optional_str(value, field)?
10358 .map(|timestamp| {
10359 timestamp.parse().map_err(|error| SourceError::Malformed {
10360 message: format!("GitHub response field {field} is not a timestamp: {error}"),
10361 })
10362 })
10363 .transpose()
10364}
10365fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10366 if page.limit == 0 {
10367 Err(SourceError::Config {
10368 message: "page limit must be at least 1".into(),
10369 })
10370 } else {
10371 Ok(())
10372 }
10373}
10374fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10375 let page = connection
10376 .get("pageInfo")
10377 .filter(|value| value.is_object())
10378 .ok_or_else(|| SourceError::Malformed {
10379 message: "GitHub connection is missing pageInfo".into(),
10380 })?;
10381 if required_bool(page, "hasNextPage")? {
10382 let cursor = required_str(page, "endCursor")?;
10383 validate_cursor_progress(None, cursor)?;
10384 Ok(Some(Cursor(cursor.into())))
10385 } else {
10386 Ok(None)
10387 }
10388}
10389fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10390 if next.is_empty() || previous == Some(next) {
10391 Err(SourceError::Malformed {
10392 message: "GitHub pagination cursor is empty or did not advance".into(),
10393 })
10394 } else {
10395 Ok(())
10396 }
10397}
10398/// The version of this plugin's opaque narrowing-search cursor.
10399pub const SEARCH_CURSOR_VERSION: u32 = 4;
10400
10401#[derive(Serialize, Deserialize)]
10402#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10403enum SearchConnection {
10404 Initial {},
10405 Continuing { after: Cursor },
10406 Exhausted {},
10407}
10408impl SearchConnection {
10409 fn after(&self) -> Option<&str> {
10410 match self {
10411 Self::Continuing { after } => Some(&after.0),
10412 _ => None,
10413 }
10414 }
10415 fn exhausted(&self) -> bool {
10416 matches!(self, Self::Exhausted { .. })
10417 }
10418 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10419 /// plugin could have handed out: a page is resumed only part of the way through it — an
10420 /// offset of a whole page or more would skip rows nobody was given — an initial page
10421 /// only once some of it was handed out, and an exhausted connection has no page to be
10422 /// part of the way through.
10423 fn valid_resume(&self, offset: usize) -> bool {
10424 let within = offset < SEARCH_PAGE_SIZE as usize;
10425 match self {
10426 Self::Initial { .. } => offset > 0 && within,
10427 Self::Continuing { after } => !after.0.is_empty() && within,
10428 Self::Exhausted { .. } => offset == 0,
10429 }
10430 }
10431}
10432
10433/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10434#[derive(Serialize, Deserialize)]
10435#[serde(deny_unknown_fields)]
10436struct SearchPosition {
10437 version: u32,
10438 connection: SearchConnection,
10439 /// How many rows of the page `connection` starts were already handed out.
10440 #[serde(default, skip_serializing_if = "is_zero")]
10441 offset: usize,
10442 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10443 seen: Vec<NativeId>,
10444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10445 own: Vec<NativeId>,
10446}
10447impl Default for SearchPosition {
10448 fn default() -> Self {
10449 Self {
10450 version: SEARCH_CURSOR_VERSION,
10451 connection: SearchConnection::Initial {},
10452 offset: 0,
10453 seen: Vec::new(),
10454 own: Vec::new(),
10455 }
10456 }
10457}
10458
10459fn is_zero(offset: &usize) -> bool {
10460 *offset == 0
10461}
10462
10463fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10464 cursor.map_or(Ok(0), |c| {
10465 c.0.parse().map_err(|_| SourceError::Config {
10466 message: "page cursor is invalid".into(),
10467 })
10468 })
10469}
10470fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10471 if offset > items.len() {
10472 return Page::last(vec![]);
10473 }
10474 let tail = items.split_off(offset);
10475 let mut selected = tail;
10476 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10477 selected.truncate(limit);
10478 Page {
10479 items: selected,
10480 next,
10481 }
10482}
10483
10484impl GitHubProjectsSource {
10485 async fn write_task_assets(
10486 &self,
10487 write: &ItemWrite<Task>,
10488 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10489 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10490 let near = write.target.as_ref().unwrap_or(&write.item.id);
10491 for (key, entries) in [
10492 (TaskRef::DELIVERS_KEY, &write.item.delivers),
10493 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
10494 ] {
10495 TaskRef::listed(key, near, Some(&self.name), entries.clone())
10496 .map_err(|message| SourceError::Refused { message })?;
10497 }
10498 if self.priorities.is_none() && write.item.priority != Priority::None {
10499 return Err(self.holds_no_priority());
10500 }
10501 self.write_item(
10502 &Incoming {
10503 written: Written::Work(ItemKind::Task, &write.item.status),
10504 title: &write.item.title,
10505 content: write.item.content.as_deref(),
10506 assets,
10507 labels: &write.item.labels,
10508 metadata: &write.item.metadata,
10509 repositories: &write.item.repositories,
10510 parent: write.item.project.as_ref(),
10511 delivers: &write.item.delivers,
10512 delivered_by: &write.item.delivered_by,
10513 priority: self.priorities.as_ref().map(|_| write.item.priority),
10514 },
10515 write.target.as_ref(),
10516 &write.depends_on,
10517 )
10518 .await
10519 }
10520 async fn write_document_assets(
10521 &self,
10522 write: &ItemWrite<Document>,
10523 assets: Option<&onetaskgraph_plugin_api::AssetWrite>,
10524 ) -> Result<onetaskgraph_plugin_api::AssetsWritten, SourceError> {
10525 // A document takes part in no dependency graph, so there is no far end to write
10526 // natively and none to record: a caller naming one is told so rather than having it
10527 // stored under the reserved key, where a later read would report an edge the
10528 // contract says cannot exist.
10529 if !write.depends_on.is_empty() {
10530 return Err(SourceError::Refused {
10531 message: format!(
10532 "this write names {} dependencies for a document, and a document takes \
10533 part in no dependency graph; next: put the dependency on the task or \
10534 project the document is about",
10535 write.depends_on.len()
10536 ),
10537 });
10538 }
10539 self.write_item(
10540 &Incoming {
10541 written: Written::Document,
10542 title: &write.item.title,
10543 content: write.item.content.as_deref(),
10544 assets,
10545 labels: &write.item.labels,
10546 metadata: &write.item.metadata,
10547 repositories: &write.item.repositories,
10548 parent: write.item.project.as_ref(),
10549 delivers: &[],
10550 delivered_by: &[],
10551 priority: None,
10552 },
10553 write.target.as_ref(),
10554 &[],
10555 )
10556 .await
10557 }
10558}
10559
10560/// What [`TaskSource::end_command`] leaves of this source's held state, asserted on the state
10561/// itself, for the two things no journey can observe.
10562///
10563/// The journeys in `crates/onetaskgraph-e2e/tests/e2e/end_command.rs` prove through the engine,
10564/// with and without the call, that a settlement, a board listing and a metadata search each
10565/// read afresh after it — the resolved records, the written-item overlay, the board and its
10566/// search, and the narrowed searches. What they cannot reach is the held field definitions,
10567/// because a status write naming an option a person deleted is refused the same whether or
10568/// not the list is held, and a poisoned lock, because nothing outside the source can panic
10569/// while one of its locks is held. So these assert those directly, and every other holder
10570/// beside them so a holder added later without a clear in the call fails here.
10571#[cfg(test)]
10572mod end_command_tests {
10573 use super::*;
10574
10575 struct Token;
10576
10577 impl SecretResolver for Token {
10578 fn get(&self, var: &str) -> Option<SecretString> {
10579 (var == "GH_PROJECTS_TOKEN").then(|| "test-token".into())
10580 }
10581 }
10582
10583 fn source() -> GitHubProjectsSource {
10584 let config = serde_json::from_value(json!({
10585 "owner": "octo-org", "project_number": 7, "repository": "acme/work",
10586 // Nothing here is sent: the source is only built and its state inspected.
10587 "endpoint": "http://127.0.0.1:9/graphql",
10588 }))
10589 .expect("a usable configuration");
10590 GitHubProjectsSource::new(&SourceName::new("work").unwrap(), config, &Token)
10591 .expect("the source builds")
10592 }
10593
10594 /// One issue as a board read answers it.
10595 fn resolved(source: &GitHubProjectsSource) -> Resolved {
10596 source
10597 .resolve(&json!({
10598 "id": "ITEM-1",
10599 "content": {"__typename": "Issue", "id": "I_1", "title": "Held",
10600 "body": "what a person may since have edited", "state": "OPEN",
10601 "stateReason": null, "url": null, "number": 1,
10602 "subIssuesSummary": {"total": 0},
10603 "labels": {"nodes": [], "pageInfo": {"hasNextPage": false}}},
10604 "fieldValues": {"nodes": [], "pageInfo": {"hasNextPage": false}},
10605 }))
10606 .expect("the item reads")
10607 .expect("an issue")
10608 }
10609
10610 /// Hold something in every holder the call clears, and the repository id it keeps.
10611 fn fill(source: &GitHubProjectsSource) {
10612 let item = resolved(source);
10613 source.created.lock().unwrap().push(item.clone());
10614 source.updated.lock().unwrap().push(item.clone());
10615 *source.board_cache.lock().unwrap() = Some(Board {
10616 id: "PVT-board".into(),
10617 fields: json!({"nodes": []}),
10618 items: vec![item.clone()],
10619 });
10620 *source.search_cache.lock().unwrap() = Some(vec![item.clone()]);
10621 source
10622 .narrowed_cache
10623 .lock()
10624 .unwrap()
10625 .insert("status:todo".into(), vec![item.clone()]);
10626 source
10627 .search_next
10628 .lock()
10629 .unwrap()
10630 .insert("status:todo".into(), Some("cursor".into()));
10631 source
10632 .resolved_cache
10633 .lock()
10634 .unwrap()
10635 .insert(item.id.clone(), item);
10636 *source.fields_cache.lock().unwrap() = Some(BoardFields {
10637 id: BoardId::parse("PVT-board").unwrap(),
10638 fields: json!({"nodes": []}),
10639 });
10640 source
10641 .repository_cache
10642 .lock()
10643 .unwrap()
10644 .insert(RepositoryTarget::parse("acme/work").unwrap(), "R_1".into());
10645 }
10646
10647 fn assert_dropped(source: &GitHubProjectsSource) {
10648 assert!(source.created().unwrap().is_empty(), "created");
10649 assert!(source.updated().unwrap().is_empty(), "updated");
10650 assert!(source.board_cache().unwrap().is_none(), "board");
10651 assert!(source.search_cache.lock().unwrap().is_none(), "search");
10652 assert!(source.narrowed_cache.lock().unwrap().is_empty(), "narrowed");
10653 assert!(
10654 source.search_next.lock().unwrap().is_empty(),
10655 "search paging"
10656 );
10657 assert!(
10658 source.resolved_cache().unwrap().is_empty(),
10659 "resolved records"
10660 );
10661 assert!(source.fields_cache().unwrap().is_none(), "board fields");
10662 assert_eq!(
10663 source.repository_cache().unwrap().len(),
10664 1,
10665 "a repository's node id stays valid and is kept"
10666 );
10667 }
10668
10669 fn end(source: &GitHubProjectsSource) {
10670 tokio::runtime::Builder::new_current_thread()
10671 .build()
10672 .unwrap()
10673 .block_on(source.end_command())
10674 .expect("the command ends");
10675 }
10676
10677 #[test]
10678 fn the_call_drops_every_item_search_and_board_read_and_keeps_repository_ids() {
10679 let source = source();
10680 fill(&source);
10681 end(&source);
10682 assert_dropped(&source);
10683 }
10684
10685 #[test]
10686 fn the_call_clears_a_lock_an_earlier_failure_poisoned() {
10687 fn poison<T: Send>(held: &Mutex<T>) {
10688 std::thread::scope(|scope| {
10689 let _ = scope
10690 .spawn(|| {
10691 let _guard = held.lock().unwrap();
10692 panic!("a failure while the lock is held");
10693 })
10694 .join();
10695 });
10696 assert!(held.is_poisoned());
10697 }
10698 let source = source();
10699 fill(&source);
10700 poison(&source.created);
10701 poison(&source.updated);
10702 poison(&source.board_cache);
10703 poison(&source.search_cache);
10704 poison(&source.narrowed_cache);
10705 poison(&source.search_next);
10706 poison(&source.resolved_cache);
10707 poison(&source.fields_cache);
10708 assert!(
10709 source.resolved_cache().is_err(),
10710 "a poisoned lock is refused before the call"
10711 );
10712 end(&source);
10713 assert_dropped(&source);
10714 }
10715}