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/tests/e2e/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//! | `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. |
138//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
139//! | `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 neither `ProjectV2.items` nor any issue the search did not name is 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 by a second or two, so a caller asking again from its last instant should overlap the two by more than that. |
140//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
141//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
142//! | `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. |
143//! | `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. |
144//! | `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. |
145//! | `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. |
146//! | `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. |
147//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
148//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
149//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
150//!
151//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
152//! source has documents and that its tasks have comments, both of which hold — and the three
153//! facts behind the uniform `Native` on the
154//! predicates beside it are recorded below rather than re-derived, because a reader who
155//! takes `Native` to mean *the remote service filters* will read that uniformity as a
156//! lie.
157//!
158//! First, the plugin contract defines `Support::Native` as *the source applies this
159//! predicate itself*, and says nothing about where it applies it. What the declaration
160//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
161//! — so that the engine may push it down and apply nothing of its own.
162//!
163//! Second, this source can keep that promise for every predicate at no additional API
164//! cost, because whichever of the reads below answers a query has already read every
165//! candidate that query will return before it filters anything. Filtering those items is
166//! in-process work over data already in hand.
167//!
168//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
169//! applied in process over what that question returned. A project filter has a relationship — a
170//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
171//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
172//! a search for metadata values, is the board-scoped issue search carrying the text and each
173//! value as quoted phrases; an origin is the board's own field filter over its origin field
174//! beside the same search for the id. **The text search narrows, and that is this source's
175//! declared semantics:** GitHub matches whole words where the substring rule this source and
176//! the local Markdown source confirm with would match inside one, so an item holding the text
177//! only inside a longer word is never a candidate. Every item returned does contain the text.
178//! A project query's text, and a document query's scoped to no project, is that same search
179//! and narrows on the same terms, its candidates confirmed by their kind as well.
180//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
181//! so those three are applied in process over the candidates, and a query carrying none of
182//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
183//! engine compensate for work this source has already done, and declaring `projects` native
184//! while ignoring the filter (which this source once did) silently returns another project's
185//! tasks, because the engine trusts the declaration and applies nothing locally.
186//!
187//! # The three ways this source reaches an item, and what each costs
188//!
189//! A board read is charged for what its *nested* connections could return rather than for
190//! what was asked, so one whole-board read costs the same whether the question was about
191//! one project or about all of them. That is why a question about one project is never
192//! answered by reading the board:
193//!
194//! | The question | What is sent | What it costs |
195//! | --- | --- | --- |
196//! | 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 |
197//! | 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 |
198//! | 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 |
199//! | 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 |
200//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
201//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
202//! | 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 |
203//! | 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 |
204//! | 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 |
205//! | 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 |
206//! | 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 |
207//! | 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 |
208//!
209//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
210//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
211//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
212//! equal to declared points equal to the row. They include the origin lookup and the
213//! field/repository discovery a create needs. A bound re-copy changes status, priority,
214//! content and metadata; comment recount means a subsequent detail read. Each request here
215//! costs one declared point. A membership beyond the embedded page can additionally require
216//! the one-point membership recovery described above. A bound re-copy of a task filed under a
217//! project adds one read, the engine confirming that project's link by its own id once per
218//! command; and the same-source far ends a write newly names — those that do not already block
219//! the item, whose own read answered for them — are read together by their own ids,
220//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
221//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
222//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
223//!
224//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
225//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
226//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
227//! holds both halves.
228//!
229//! **An existing item is written body last.** A bound re-copy and a `task update` send its
230//! board fields first — the `Status` option and the `Priority` together, in one request — then
231//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
232//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
233//! undoing an earlier field when a later one fails, so that order is what makes a write
234//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
235//! the one piece of metadata written before the body, an origin a copy re-points, is put back
236//! when a later write is refused — and when putting it back is refused too, the write's own
237//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
238//! of those writes in turn, whole and as one aliased field failing after the one before it.
239//!
240//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
241//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
242//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
243//!
244//! - **A board is accepted at creation but its item is not answered, so a create still files
245//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
246//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
247//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
248//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
249//! against a real board on 2026-10-01 with a create sending the board there and reading the
250//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
251//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
252//! refused "Content already exists in this project" — GitHub had filed the issue after
253//! answering, and refuses a second filing rather than answering with the item it holds. A
254//! create therefore sends no `projectV2Ids` and files the issue with
255//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
256//! real is the read before it: the board's fields and the repository's id together, in
257//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
258//! - **A comment still reads its target first, so a comment is 2 requests.**
259//! `AddCommentInput.subjectId: ID!` is declared there with
260//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
261//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
262//! project's issue, a document's issue, an issue on no board of this source and a pull
263//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
264//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
265//! refuses those by name.
266//!
267//! | Verb | Requests / points | Documents |
268//! | --- | --- | --- |
269//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
270//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
271//! | 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 |
272//! | 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 |
273//! | 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 |
274//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
275//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
276//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
277//! | recount | 1 | ISSUE_DETAIL |
278//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
279//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
280//! | content | 2 | ISSUE, UPDATE_ISSUE |
281//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
282//! | 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 |
283//! | record only | 1 | ISSUE |
284//!
285//! <!-- github-search-paging:start -->
286//! Board-scoped text, metadata, project-name and comment-activity searches send every
287//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
288//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
289//! needs rows. A page is never resized to the rows still needed: GitHub orders one
290//! search differently at different page sizes, so one fixed size makes a paged walk
291//! send exactly the requests one whole read sends, and the answer's order is the order
292//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
293//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
294//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
295//! returned and fetched pages: a limit is sliced from the pages it needs, and local
296//! confirmation can require more candidates than matching rows. Walking all pages
297//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
298//! cursor and how far into that page the last answer stopped, and resumes in the same
299//! process or a new one, without duplicates or gaps. It carries no rows: one process
300//! sends each page's search once, and a new process re-reads only the page it resumes
301//! in, then sends a further page once, never as a re-read, only when its limit still
302//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
303//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
304//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
305//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
306//! not required to include the original process's writes still omitted by the index.
307//! <!-- github-search-paging:end -->
308//!
309//! The board half of an issue — its board item's id, its `Status` option and this
310//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
311//! an item reached any of those ways resolves through the same
312//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
313//! same status, the same labels and the same qualified id. That connection comes back a
314//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
315//! on the page in hand and — only if that page reports more of the connection — in the
316//! last row's read of that one issue's memberships, resumed from the page's own cursor and
317//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
318//! report, which is what keeps an id naming another repository's issue from being answered
319//! as an item of this board; and because the page is where the search starts rather than
320//! where it ends, that answer is one about a connection read to exhaustion and never about
321//! an unread page. Nothing costs the extra read but an issue on more boards than a page
322//! holds: an issue this board really does not hold reports no next page, so its
323//! memberships are already exhausted where they arrived.
324//!
325//! **No document here selects the board's own `Labels` field, and nothing is lost by
326//! that.** An item's labels are read from its content alone, wherever that content is
327//! reached: the three documents above select `Issue.labels` on the fragment, and
328//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
329//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
330//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
331//! create one, and `ProjectV2FieldValue` — the whole of what
332//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
333//! it from the content, for every content type it exists on, and there is nothing it can
334//! hold that the content does not already say: for an `Issue` it *is* that issue's own
335//! labels, so selecting it beside them unions a set with itself.
336//!
337//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
338//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
339//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
340//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
341//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
342//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
343//! label set, and that set is the fixture issue's own, by
344//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
345//! item whose content is a draft reports an empty set, by
346//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
347//! selection is held over [`graphql::DOCUMENTS`] by
348//! `no_document_selects_the_boards_own_labels_field`.
349//!
350//! The whole-board row is still the board's own item connection, and deliberately: a
351//! **draft** board item is not an issue, so no search can list one, and the reads that have
352//! to answer for the whole board are the ones whose cost is the board's size anyway.
353//!
354//! **A question about one item this source already names by id never lists the board.**
355//! Whether that item is on this board, and what its board fields are, is answered by reading
356//! that item — its own `Issue.projectItems`, walked to exhaustion by
357//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
358//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
359//! holds. That covers a write's destination, the project a new item is filed under, a
360//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
361//! and the delete that takes back an item a copy made. What such a write needs of the board
362//! and the item does not carry — the board's id, the `Status` and origin field definitions —
363//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
364//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
365//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
366//! minutes. Scanning this host's 842-item board has refused a document copy and an update
367//! even though the items' own reads named that board. A scan there gives the wrong answer
368//! as well as paying for every page. So a `board.items` lookup does not belong on any of
369//! those paths.
370//!
371//! **What a read may return is capped too, and that cap is on the document rather than on
372//! the board.** GitHub limits the number of nodes **one query may return** to
373//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
374//! an error naming the connection the count crossed at, not a slow or a partial result.
375//! Every board this source reads is refused the same way, so no board is too big for these
376//! documents and none is small enough to save one that is over.
377//!
378//! The count is arithmetic over the document's own text: each connection contributes the
379//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
380//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
381//! restate them — `github-graphql-node-count` implements them, and
382//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
383//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
384//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
385//! same text on every run and fails naming any that reaches the limit — so a connection
386//! added to a shared fragment is caught there rather than by GitHub.
387//!
388//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
389//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
390//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
391//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
392//! effectively squared there, which is why it is the one the limit is most sensitive to.
393//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
394//! page of memberships misses is recovered by one further read rather than refused, so it
395//! buys a bound every read pays for at the price of a request only a multi-board issue
396//! pays.
397//!
398//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
399//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
400//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
401//! `cost` is rate-limit points, metered per hour across everything one credential does; it
402//! is what the two limiters [`Limiter`] tells apart meter, and a document under
403//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
404//! that second number, and `tests/point_cost.rs` pins every document in
405//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
406//! one under, the pin itself is the check. The credentialed lane reconciles both figures
407//! against GitHub's own, off a probe it already sends.
408//!
409//! **What is pinned that way is a per-document price and never a session's.** The record in
410//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
411//! **requests** and **worst-case nodes** — and neither is points. What one whole session
412//! consumes of the hourly point allowance is observable only from a credentialed run's own
413//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
414//! and what `tests/live.rs` prints at the end of every run.
415//!
416//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
417//!
418//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
419//! enumerations of a board can supply one alone.** Resolving a node id is strongly
420//! consistent, so a read by id and a project's own sub-issues are already current. The
421//! other two are not, and they are behind by different amounts and in different directions:
422//!
423//! - GitHub's **issue search** is an index and answers a write made moments ago with the
424//! value from before it — usually for a second or two.
425//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
426//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
427//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
428//! for *minutes*, while `Issue.projectItems` names the same membership at once.
429//!
430//! That second one is a measurement rather than a caution. This repository's own
431//! credentialed journey writes a project and waits for the board to report it, then writes a
432//! task and waits for the same thing seconds later on the same board: the project wait is
433//! answered through the search and converged in two or three attempts in each of three runs,
434//! and the task wait is answered through `ProjectV2.items` and converged in none of them
435//! inside thirty. Separately, an item added to a second and larger board was read back by
436//! `Issue.projectItems` on that board's own id while every one of that connection's nine
437//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
438//! through the lagging one alone is what had a board read deny an issue that had certainly
439//! landed on it.
440//!
441//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
442//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
443//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
444//! search reports what the projection is behind on. What closes the last
445//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
446//! source answers is completed with what this process itself wrote, so an item created
447//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
448//! nothing is written down, and the record dies with the process. **A wait that has to
449//! observe GitHub's own data cannot be answered from that record** — which is why the
450//! credentialed journey asks through a source built afresh, and why the union above rather
451//! than a longer wait is what makes such a wait converge.
452//!
453//! **A narrowed read is the same bargain, stated for each of the three predicates it
454//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
455//! than walking the board, and every such answer is completed with what this process wrote —
456//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
457//! each filtered by the same predicates as the rest — so an item this command wrote a moment
458//! ago is returned by a query that matches it whether or not the index has caught up. An item
459//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
460//! consistent. What is left is stated rather than papered over:
461//!
462//! | Read | Finds | Behind by |
463//! | --- | --- | --- |
464//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
465//! | 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 |
466//! | 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 |
467//! | origin, third read | this process's own writes | nothing |
468//!
469//! So an origin carrier another process added within the last second or two, before either
470//! index has it, can be missing from an origin query, and one written by the release before
471//! this one — its origin in the field alone — can be missing for as long as the board's own
472//! item connection is behind on it. A copy that must not duplicate its own earlier write
473//! relies on the link it records, not on either index. **A board draft is not an issue**, so
474//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
475//! lists one, the origin lookup drops any the board's own field filter names, and one this
476//! process wrote is not added back either.
477//!
478//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
479//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
480//! body's metadata slot, so the issue search can find it in seconds. The field is
481//! authoritative: this source reads an item's origin from the field alone, so a slot that
482//! disagrees with it, or holds one where the field holds none, is never read as a second
483//! origin — and the release before this one reads the slot, drops that key's copy for the
484//! field's, and sees the same one origin.
485//!
486//! Filtering happens before paging, so a page of a filtered result is a page of the
487//! survivors rather than the survivors of a page. Label matching and the substring rule a
488//! text candidate is confirmed by answer the same question the same way the local Markdown
489//! source's do; which candidates a text search has to confirm is GitHub's word match, which
490//! is the one place the two sources can answer the same text differently.
491//!
492//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
493//! has one source, `capabilities`, and the note above is the reasoning behind it rather
494//! than a second copy of it: without the three facts recorded here a reader takes the
495//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
496//! crate's own capabilities test, which pins every field of it against a fully spelled-out
497//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
498//! fails to compile there rather than going unasserted. -->
499//! The fixture-server tests above run wherever this crate is selected; the credentialed
500//! lane runs in the same required check, beside them, and can fail it — it verifies the
501//! 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,
502//! one filed under neither, a label on one of the three and a closed status on another —
503//! because that shape is what tells an honoured predicate from an ignored one: a board
504//! holding a single project answers a project filter the same way whether or not this
505//! source applies it, which is exactly how the defect above went unseen.
506//!
507//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
508//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
509//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
510//! nominated is what keeps a credentialed write lane off a board and a repository nobody
511//! nominated; it never asks GitHub which project was updated most recently. Before it
512//! starts, the lane also clears any item titled — and any repository label named — the way
513//! it titles and names its own artifacts, which is self-healing after an interrupted run:
514//! a process killed between its writes and its cleanup leaves artifacts the next run
515//! removes.
516//!
517//! # What a session of requests costs, and where the report is
518//!
519//! This source records **every** request it sends into [`accounting::Accounting`], at
520//! `send_once` — the one place a request leaves this crate, which is why a read path added
521//! later is counted without anybody remembering to count it. That is the whole of what this
522//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
523//! session's spend is arrived at, and what it deliberately does not know are set out.
524//!
525//! What one whole session of the live journey costs, counted that way against this crate's
526//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
527//! reduction it came out of, and with what it does and does not say about rate-limit points.
528//!
529//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
530//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
531//! code path — no environment variable, no feature, no build configuration — because an
532//! instrument nobody switches on measures nothing, and
533//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
534//! source's counts the whole session rather than this source's share. The credentialed lane
535//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
536//! passed or failed.
537//!
538//! **A live session refuses to start unless the account can afford it.** Before it does any
539//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
540//! GitHub documents as not counting against the REST rate limit and which answers both of
541//! its budgets at once — and starts only if, for each of them, what remains minus this
542//! session's estimated cost is still at least
543//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
544//! allowance. A session that cannot **declines**: it did not run, so it is
545//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
546//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
547//! waiting for the budget to come back. The estimate is derived offline from
548//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
549//! which is also where the published rule that model rests on is cited; the accounting
550//! above records the gate's own read like any other request, and
551//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
552//!
553//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
554//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
555//! text, which is what lets it run on every platform and on a pull request from a fork with
556//! no credential — and that is what actually stops a regression merging. But an offline
557//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
558//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
559//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
560//! number of nodes this query may return"* and whose `cost` is what that document would
561//! spend, both for a document **without executing it**, and the lane asks it for every query
562//! document this source sends, under the largest bindings this source sends, and fails when
563//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
564//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
565//! one; the offline pins still cover it. It records what those calls reported about the
566//! account's own allowance, because whether asking is free is a thing to observe rather than
567//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
568//! and `cost` is metered against an hourly allowance the accounting above reads off a
569//! credentialed run's own response headers.
570//!
571//! **GitHub has two rate limiters and this source is refused by both, so nothing here
572//! treats them as one thing.** The primary budget is the hourly allowance `gh api
573//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
574//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
575//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
576//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
577//! one part of.
578#![deny(missing_docs)]
579
580use std::collections::BTreeMap;
581use std::sync::{Arc, Mutex};
582use std::time::{Duration, Instant};
583
584use chrono::{DateTime, Utc};
585use onetaskgraph_plugin_api::{
586 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
587 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
588 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
589 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
590 SourceName, SourcePlugin, Status, StatusCategory, StatusMapping, Support, Task, TaskDetailRead,
591 TaskQuery, TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery,
592 UnmappedStatus, UpdatedField, WriteSupport,
593};
594use reqwest::{Client, StatusCode, Url};
595use schemars::{Schema, schema_for};
596use secrecy::{ExposeSecret, SecretString};
597use serde::{Deserialize, Serialize};
598use serde_json::{Value, json};
599
600pub mod accounting;
601
602use accounting::Accounting;
603
604/// The registry name for this plugin.
605pub const KIND: &str = "github-projects";
606/// GitHub's maximum connection page size.
607pub const MAX_PAGE_SIZE: u32 = 100;
608/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
609/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
610/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
611pub const SEARCH_PAGE_SIZE: u32 = 20;
612/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
613/// its comments: the largest batch the node-count model prices at one point.
614///
615/// Each aliased item is resolved once, and what GitHub charges for it is the connections
616/// under it — its labels, its page of board memberships, the field values of each of those
617/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
618/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
619/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
620/// one point and fails if one item more would still be priced at one.
621pub const DETAIL_BATCH: usize = 24;
622
623/// The most nodes any one document this source sends may be asked to return.
624///
625/// GitHub's own published per-query ceiling, taken from
626/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
627/// workspace cannot hold a stale copy of somebody else's number. A query above it is
628/// **refused before it is executed**, whoever is asking and whatever board they are
629/// asking about — so this is a bound on the documents rather than a budget that runs out.
630///
631/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
632/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
633/// everything the credential does — two numbers against two limits, and this constant
634/// bounds only the first. The second is computed offline too, per document:
635/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
636/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
637/// lane. There is no constant like this one to hold a price under, because points are an
638/// hourly allowance rather than a per-call bound.
639///
640/// Neither is a session's price. What `session-cost.md` records of a whole session is its
641/// **requests** and its **worst-case nodes**; what a whole session spends in points is
642/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
643/// [`accounting`]. The module section on the three ways this source reaches an item says how
644/// the count is arrived at, and which of the page sizes below decide it.
645pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
646
647/// Nested connection size for the connections that hang off one item.
648///
649/// It multiplies through every document that reaches an item under a page — the count
650/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
651/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
652/// every document under these constants and fails naming any that reaches the limit, so
653/// raising this is caught there rather than by GitHub.
654const NESTED_PAGE_SIZE: u32 = 50;
655/// How many of one issue's board memberships are read when an issue is reached directly.
656///
657/// An issue reached through a search or through its own node id carries its board half in
658/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
659/// under a page of issues, so every point of it multiplies through the whole document and
660/// is paid for whether or not any issue is on a second board — which is why it is
661/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
662///
663/// **Three, because what a page misses is now recovered rather than refused**, and the
664/// recovery is what the value is chosen against. An issue whose entry for this board sits
665/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
666/// that page's own cursor — so the value trades a bound every read pays for a request only
667/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
668/// boards would pay that request *per issue*, which is order N against the one page per
669/// hundred issues a read costs today. At three it is only reached by an issue on four or
670/// more boards at once, which keeps the recovery path exceptional rather than routine for
671/// a plausible deployment.
672const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
673/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
674/// its two connections for.
675///
676/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
677/// second is a duplicate a copy already takes the first of. Both connections are walked to
678/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
679/// never what the answer is. It is small because every point of it is paid on every lookup,
680/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
681/// worst-case nodes than the one whole-board read they replaced.
682const ORIGIN_PAGE_SIZE: u32 = 3;
683
684pub use github_graphql_node_count::{NodeCountError, Variables};
685
686/// The largest value this source can bind to each page-size variable its documents name.
687///
688/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
689/// constant above it wherever a caller's own limit could reach it — `$first` at
690/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
691/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
692/// not one configuration of it, which is what makes a bound computed under it a bound on
693/// every read.
694pub fn largest_page_sizes() -> Variables {
695 Variables::from([
696 ("first".to_owned(), MAX_PAGE_SIZE),
697 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
698 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
699 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
700 ])
701}
702
703/// The most nodes `document` could be asked to return, by GitHub's published rules.
704///
705/// Computed offline from the document's own text under [`largest_page_sizes`] — no
706/// network, no credential and no schema — by
707/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
708/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
709/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
710///
711/// # Errors
712///
713/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
714/// no single operation, or binds a page size this source does not name — each of which is
715/// a defect in the document rather than a number.
716pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
717 node_count(document, &largest_page_sizes())
718}
719
720/// The most rate-limit points one call of `document` could spend, by GitHub's published
721/// rules.
722///
723/// Computed offline from the document's own text under [`largest_page_sizes`] — no
724/// network, no credential and no schema — by
725/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
726/// This is `cost`, metered **per hour** against the allowance one credential shares across
727/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
728/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
729/// under, so what `tests/point_cost.rs` does with it is pin every document in
730/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
731/// figures against GitHub's own reported `cost`.
732///
733/// # Errors
734///
735/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
736/// no single operation, or binds a page size this source does not name — each of which is
737/// a defect in the document rather than a number.
738pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
739 github_graphql_node_count::point_cost(document, &largest_page_sizes())
740}
741
742/// The most nodes `document` could be asked to return under `variables`.
743///
744/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
745/// [`accounting`] is this under the bindings one request really sent — one spelling of the
746/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
747/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
748///
749/// # Errors
750///
751/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
752/// no single operation, or binds a page size `variables` does not name.
753pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
754 github_graphql_node_count::node_count(document, variables)
755}
756
757/// The issue-title prefix that makes a board issue a document.
758///
759/// A GitHub Projects board has no document type — it holds issues — so the discriminator
760/// is the title, and this is the whole of it: an issue whose title begins with these bytes
761/// is a document and every other issue is the task or project the sub-issue rule makes it.
762///
763/// It is spelled **once**, here, and read rather than restated everywhere else — including
764/// by the shared journeys, which take it from this constant so a board fixture cannot
765/// drift from what this source reads. `docs/metadata.md` records the two consequences that
766/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
767/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
768/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
769pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
770
771/// Exact GraphQL query documents issued by this plugin.
772///
773/// Keeping the production documents here lets the pinned-schema test validate the same
774/// bytes that are sent to GitHub, rather than a test-only copy which could drift
775/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
776/// field, and its guarded caller always supplies the complete existing option set with ids.
777pub mod graphql {
778 /// The board half of one item: the field values every document here reads it from.
779 ///
780 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
781 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
782 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
783 /// *the same value*, because
784 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
785 /// one path. Three spellings of it is what would drift, so there is one.
786 ///
787 /// The `Status` option and this source's own origin text field are the whole of it. It
788 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
789 /// content, so it holds nothing the content's own `labels` do not already say, and it
790 /// would sit a label connection two page sizes deep.
791 macro_rules! board_item_values {
792 () => {
793 r#"fieldValues(first:$nestedFirst){nodes{
794 ... on ProjectV2ItemFieldSingleSelectValue{name field{
795 ... on ProjectV2SingleSelectField{id name options{id name}}
796 }}
797 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
798 }pageInfo{hasNextPage}}"#
799 };
800 }
801
802 /// Everything this source reads about one issue, wherever it reaches that issue.
803 ///
804 /// A macro rather than a constant so the three documents below can `concat!` it: one
805 /// spelling of these fields is what makes an issue read through the board-scoped
806 /// search, through its own node id, and through its project's sub-issue relationship
807 /// resolve to *the same* item, which is the whole of what
808 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
809 ///
810 /// `projectItems` is what carries the board half of an issue: the board item's own id
811 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
812 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
813 /// issue rather than on the board, which is what makes the cost of a read proportional
814 /// to what was asked for instead of to the board's size.
815 ///
816 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
817 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
818 /// not on that page: a page here is where the search for the entry starts rather than
819 /// where it ends.
820 ///
821 /// It does **not** select the board's `Labels` field value, and that is the whole of
822 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
823 /// a label connection there sits under `fieldValues` under `projectItems` under a page
824 /// of issues, spending `$nestedFirst` twice down one path, and took
825 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
826 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
827 /// above, and that connection is where every label this source reports comes from. No
828 /// document in this module selects the board field any longer, [`BOARD`] included; the
829 /// module documentation records why nothing it could have held is lost.
830 macro_rules! board_issue {
831 () => {
832 concat!(
833 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
834 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
835 projectItems(first:$boardItems){nodes{id project{id number}
836 "#,
837 board_item_values!(),
838 r#"}pageInfo{hasNextPage endCursor}}}"#
839 )
840 };
841 }
842
843 /// Every issue of one board, found by a search scoped to that board.
844 ///
845 /// This is how the projects a board holds are listed, and it selects no `items`
846 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
847 /// container walked page by page, so nothing nested inside a board item is paid for.
848 /// Which of the issues it returns is a project is then read off `parent` — GitHub
849 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
850 /// discriminator has to be applied to the field, which is a scalar on the issue and
851 /// costs nothing.
852 pub const SEARCH_ISSUES: &str = concat!(
853 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
854 search(query:$search,type:$type,first:$first,after:$after){
855 pageInfo{hasNextPage endCursor}
856 nodes{__typename ...BoardIssue}
857 }
858 }"#,
859 board_issue!()
860 );
861
862 /// What a dependency read selects of each far end: enough to say which kind of item it
863 /// is, its body included for the kind marker.
864 macro_rules! related_issue {
865 () => {
866 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
867 };
868 }
869
870 /// One issue by its own node id, which is what a qualified id names here — with what a
871 /// write of it needs and the issue does not carry in `board_issue!`: the field
872 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
873 ///
874 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
875 /// answers a write made moments ago with the value from before it, and resolving a node
876 /// id does not.
877 ///
878 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
879 /// reads it by its own id, and with them that one read answers everything the write
880 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
881 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
882 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
883 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
884 /// they sit under one item, and this read is still one point.
885 pub const ISSUE: &str = concat!(
886 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
887 node(id:$id){__typename ...BoardIssue ... on Issue{
888 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
889 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
890 ... on ProjectV2Field{__typename id name}
891 }pageInfo{hasNextPage}}}}}
892 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
893 }}
894 }"#,
895 board_issue!(),
896 related_issue!()
897 );
898
899 /// One project's tasks: the sub-issues of the issue that project is.
900 ///
901 /// The work this costs is the project's own size. Nothing about it grows as the board
902 /// gains projects, or as those projects gain tasks.
903 pub const SUB_ISSUES: &str = concat!(
904 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
905 node(id:$id){__typename
906 ... on Issue{subIssues(first:$first,after:$after){
907 pageInfo{hasNextPage endCursor}
908 nodes{__typename ...BoardIssue}
909 }}}
910 }"#,
911 board_issue!()
912 );
913
914 /// What a read of the board's own `items` selects of each item's content.
915 ///
916 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
917 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
918 /// content by one spelling.
919 macro_rules! board_item_content {
920 () => {
921 r#" content{
922 ... 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}}}
923 ... on PullRequest{__typename id}
924 ... on DraftIssue{__typename id title body createdAt updatedAt}
925 }"#
926 };
927 }
928
929 /// Reads the board's fields and one page of its items.
930 pub const BOARD: &str = concat!(
931 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
932 owner:repositoryOwner(login:$owner){
933 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
934 }
935 } fragment Board on ProjectV2 { id title
936 fields(first:$nestedFirst){nodes{
937 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
938 ... on ProjectV2Field{__typename id name}
939 }pageInfo{hasNextPage}}
940 items(first:$first,after:$after){nodes{id "#,
941 board_item_values!(),
942 board_item_content!(),
943 r#"} pageInfo{hasNextPage endCursor}}
944 }"#
945 );
946
947 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
948 /// the board.
949 ///
950 /// **`originItems`** is the board's own items narrowed by its own field filter —
951 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
952 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
953 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
954 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
955 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
956 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
957 /// fresh `addProjectV2ItemById` the way that connection does.
958 ///
959 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
960 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
961 /// indexes that comment, and the index catches up with a write in a second or two rather
962 /// than in minutes, so it finds a carrier another process wrote that the first read is
963 /// still behind on.
964 ///
965 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
966 /// — and resumes from its own cursor; a connection already walked to its end is resumed
967 /// from its last cursor, which answers an empty page. Every candidate either read returns
968 /// is confirmed against its own origin field before it is reported, so a token match of
969 /// the search or anything else the filter admits never is.
970 ///
971 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
972 /// own whole reads counts this one among them.
973 pub const ORIGIN_LOOKUP: &str = concat!(
974 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
975 originItems:repositoryOwner(login:$owner){
976 ... on ProjectV2Owner{projectV2(number:$number){
977 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
978 board_item_values!(),
979 board_item_content!(),
980 r#"} pageInfo{hasNextPage endCursor}}
981 }}
982 }
983 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
984 pageInfo{hasNextPage endCursor}
985 nodes{__typename ...BoardIssue}
986 }
987 }"#,
988 board_issue!()
989 );
990
991 /// The board's own id and field definitions, and not one of its items.
992 ///
993 /// What a write needs of the board when the item it writes does not say: the id a field
994 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
995 /// origin fields. It selects no `items`, so what it costs is the board's field list
996 /// however many items the board holds — and it decides nothing about which items those
997 /// are, which is the question a read of one item by its own id answers instead.
998 ///
999 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
1000 /// board's item reads by their root counts this one among them.
1001 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
1002 boardFields:repositoryOwner(login:$owner){
1003 ... on ProjectV2Owner{projectV2(number:$number){id
1004 fields(first:$nestedFirst){nodes{
1005 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1006 ... on ProjectV2Field{__typename id name}
1007 }pageInfo{hasNextPage}}
1008 }}
1009 }
1010 }"#;
1011
1012 /// One board draft by its own node id, with the board item it sits in.
1013 ///
1014 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1015 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1016 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1017 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1018 /// board listing hands it to, and nothing has to list the board to find one.
1019 pub const DRAFT: &str = concat!(
1020 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1021 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1022 projectV2Items(first:$boardItems){nodes{id project{id number}
1023 "#,
1024 board_item_values!(),
1025 r#"}pageInfo{hasNextPage endCursor}}}}
1026 }"#
1027 );
1028
1029 /// One issue's board memberships alone, walked past the page a read of it carried.
1030 ///
1031 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1032 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1033 /// boards than that page holds may have this board's entry past its end. This asks that
1034 /// one issue for its memberships and nothing else — the caller already holds the issue —
1035 /// so an answer of "this board does not hold it" is only ever given about a connection
1036 /// read to exhaustion.
1037 ///
1038 /// It selects the board item's id, its project number and the same
1039 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1040 /// very same resolver: an issue recovered this way reports the same title, the same
1041 /// status, the same labels and the same qualified id as one whose entry was on the
1042 /// page.
1043 ///
1044 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1045 /// multiplies through it and the membership connection can be walked at
1046 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1047 /// further request for any issue a person really keeps.
1048 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1049 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1050 node(id:$id){
1051 ... on Issue{projectItems(first:$first,after:$after){
1052 nodes{id project{id number}
1053 "#,
1054 board_item_values!(),
1055 r#"}
1056 pageInfo{hasNextPage endCursor}}}
1057 }
1058 }"#
1059 );
1060 /// Resolves the configured repository's node id, which creating an issue requires.
1061 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1062 /// What creating an issue needs and has not read yet: the board's own id and field
1063 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1064 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1065 ///
1066 /// Sent at the point a create knows which repository it is for, when neither half is
1067 /// already known to this process; a create needing only one of them sends that one's own
1068 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1069 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1070 /// status.
1071 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1072 boardFields:repositoryOwner(login:$owner){
1073 ... on ProjectV2Owner{projectV2(number:$number){id
1074 fields(first:$nestedFirst){nodes{
1075 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1076 ... on ProjectV2Field{__typename id name}
1077 }pageInfo{hasNextPage}}
1078 }}
1079 }
1080 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1081 }"#;
1082 /// Reads both dependency directions for one issue, with each far end's own kind — and
1083 /// the issue's own body, which is where an edge to another source is recorded, so that
1084 /// half of a dependency read needs no second read of the issue or of the board.
1085 pub const ISSUE_DEPENDENCIES: &str = concat!(
1086 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1087 ... on Issue{body
1088 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1089 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1090 }}}"#,
1091 related_issue!()
1092 );
1093 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1094 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1095 /// answered when it was.
1096 pub const CREATE_ISSUE: &str =
1097 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1098 /// Puts an existing issue on the configured board.
1099 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1100 /// Updates an issue's visible fields and its open or closed state in one call.
1101 pub const UPDATE_ISSUE: &str =
1102 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1103 /// Updates an existing draft's user-visible fields.
1104 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1105 /// Updates a text or single-select value on one project item.
1106 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}}}}}}}}"#;
1107 /// Writes up to three board fields and an optional clear in one ordered mutation.
1108 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}}}"#;
1109 /// Clears one project item's value of one field, which is what a `none` priority is.
1110 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}}}}}}}}"#;
1111 /// Creates one single-select field with its options. Only the guarded field setup may use
1112 /// this document, and only for a field the board lacks.
1113 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1114 /// Replaces a single-select field's options. Only the guarded field setup — the
1115 /// `status-options` and `fields` operations — may use this document, because GitHub
1116 /// treats the input as the complete option list.
1117 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1118 /// A fresh snapshot of the Status field and every board item's assignment.
1119 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}}}}}}"#;
1120 /// Files one issue under another as a sub-issue, which is what project membership is.
1121 pub const ADD_SUB_ISSUE: &str =
1122 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1123 /// Takes one issue back out of its parent.
1124 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1125 /// Adds GitHub's native issue blocked-by relationship.
1126 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1127 /// Removes one native issue blocked-by relationship.
1128 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1129 /// Deletes one issue, which takes its board item with it.
1130 ///
1131 /// The engine sends this in one situation only: undoing a copy that could not finish,
1132 /// over the items that same copy created. Deleting the issue removes the board item
1133 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1134 pub const DELETE_ISSUE: &str =
1135 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1136
1137 /// Everything this source reads about one issue comment, wherever it reaches one.
1138 ///
1139 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1140 /// and a comment just edited are handed to one mapper, so they are selected by one
1141 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1142 /// longer exists, and `login` is the one member every kind of actor carries.
1143 macro_rules! issue_comment {
1144 () => {
1145 "id author{login} createdAt updatedAt body url"
1146 };
1147 }
1148
1149 /// One task's comments: a page of its issue's own `comments` connection.
1150 ///
1151 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1152 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1153 /// list every time somebody edited it; left unordered the connection answers in the order
1154 /// the comments were written, which is the order GitHub documents for the same collection
1155 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1156 /// node count and the caller's own page size is pushed straight down.
1157 pub const ISSUE_COMMENTS: &str = concat!(
1158 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1159 issue_comment!(),
1160 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1161 );
1162 /// One issue by its own node id, with a page of its comments: what `task show` and a
1163 /// comment listing read, in one request.
1164 ///
1165 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1166 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1167 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1168 /// connection there would multiply through both of those documents' price, and neither
1169 /// needs one.
1170 pub const ISSUE_DETAIL: &str = concat!(
1171 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1172 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1173 issue_comment!(),
1174 r#"}pageInfo{hasNextPage endCursor}}}}
1175 }"#,
1176 board_issue!()
1177 );
1178
1179 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1180 /// page of its comments when `$comments` asks for them.
1181 macro_rules! issue_details_alias {
1182 ($n:literal) => {
1183 concat!(
1184 "\n i",
1185 stringify!($n),
1186 ":node(id:$id",
1187 stringify!($n),
1188 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1189 issue_comment!(),
1190 "}pageInfo{hasNextPage endCursor}}}}"
1191 )
1192 };
1193 }
1194
1195 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1196 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1197 ///
1198 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1199 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1200 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1201 /// neither — so every connection under it would be priced at nothing and the pin in
1202 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1203 /// one-item read the model already prices, so the batch costs what its aliases cost.
1204 ///
1205 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1206 /// slots it has no item for to the last item it does, and reads that item again; the
1207 /// price is the document's, whatever its variables, so a short batch costs what a full
1208 /// one does and nothing more.
1209 pub const ISSUE_DETAILS: &str = concat!(
1210 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!){"#,
1211 issue_details_alias!(0),
1212 issue_details_alias!(1),
1213 issue_details_alias!(2),
1214 issue_details_alias!(3),
1215 issue_details_alias!(4),
1216 issue_details_alias!(5),
1217 issue_details_alias!(6),
1218 issue_details_alias!(7),
1219 issue_details_alias!(8),
1220 issue_details_alias!(9),
1221 issue_details_alias!(10),
1222 issue_details_alias!(11),
1223 issue_details_alias!(12),
1224 issue_details_alias!(13),
1225 issue_details_alias!(14),
1226 issue_details_alias!(15),
1227 issue_details_alias!(16),
1228 issue_details_alias!(17),
1229 issue_details_alias!(18),
1230 issue_details_alias!(19),
1231 issue_details_alias!(20),
1232 issue_details_alias!(21),
1233 issue_details_alias!(22),
1234 issue_details_alias!(23),
1235 "\n }",
1236 board_issue!()
1237 );
1238
1239 /// Which issue one comment is on, read before that comment is edited or removed.
1240 ///
1241 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1242 /// comment id given against the wrong task would change a comment on another issue.
1243 pub const COMMENT_ISSUE: &str =
1244 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1245 /// Adds one comment to an issue, signed as the account the token belongs to.
1246 pub const ADD_COMMENT: &str = concat!(
1247 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1248 issue_comment!(),
1249 r#"}}}}"#
1250 );
1251 /// Replaces the body of one issue comment.
1252 pub const UPDATE_COMMENT: &str = concat!(
1253 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1254 issue_comment!(),
1255 r#"}}}"#
1256 );
1257 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1258 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1259
1260 /// Every document above, with what this source is doing when it sends one.
1261 ///
1262 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1263 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1264 /// document added later with "talking to GitHub" and never say so.
1265 ///
1266 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1267 /// const` here that this list omits, so the two cannot part — which is the same guard
1268 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1269 pub const DOCUMENTS: [(&str, &str); 33] = [
1270 (SEARCH_ISSUES, "searching this board's issues"),
1271 (ISSUE, "reading one issue"),
1272 (
1273 ISSUE_BOARD_ITEMS,
1274 "reading one issue's board memberships past the page it came with",
1275 ),
1276 (SUB_ISSUES, "reading a project's tasks"),
1277 (BOARD, "reading the board"),
1278 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1279 (BOARD_FIELDS, "reading the board's fields"),
1280 (DRAFT, "reading one draft"),
1281 (REPOSITORY, "reading the destination repository"),
1282 (
1283 CREATION_CONTEXT,
1284 "reading the board's fields and the destination repository",
1285 ),
1286 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1287 (CREATE_ISSUE, "creating an issue"),
1288 (ADD_TO_BOARD, "adding an issue to the board"),
1289 (UPDATE_ISSUE, "updating an issue"),
1290 (UPDATE_DRAFT, "updating a draft item"),
1291 (UPDATE_FIELD, "writing a board field"),
1292 (UPDATE_FIELDS, "writing board fields together"),
1293 (CLEAR_FIELD, "clearing a board field"),
1294 (
1295 CREATE_FIELD,
1296 "creating a board single-select field with its options",
1297 ),
1298 (
1299 STATUS_OPTIONS_SNAPSHOT,
1300 "snapshotting board Status options and assignments",
1301 ),
1302 (
1303 STATUS_OPTIONS_UPDATE,
1304 "safely replacing the board Status option list",
1305 ),
1306 (ADD_SUB_ISSUE, "filing an issue under its project"),
1307 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1308 (ADD_BLOCKED_BY, "recording a dependency"),
1309 (REMOVE_BLOCKED_BY, "removing a dependency"),
1310 (DELETE_ISSUE, "deleting an issue"),
1311 (ISSUE_COMMENTS, "reading a task's comments"),
1312 (ISSUE_DETAIL, "reading one issue with its comments"),
1313 (
1314 ISSUE_DETAILS,
1315 "reading a batch of issues with their comments",
1316 ),
1317 (COMMENT_ISSUE, "reading which issue a comment is on"),
1318 (ADD_COMMENT, "adding a comment"),
1319 (UPDATE_COMMENT, "editing a comment"),
1320 (DELETE_COMMENT, "deleting a comment"),
1321 ];
1322}
1323
1324/// Which of GitHub's two rate limiters refused a request.
1325///
1326/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1327/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1328/// the whole reason this is carried rather than collapsed into "rate limited".
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum Limiter {
1331 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1332 Primary,
1333 /// The burst limiter over content-generating requests, which nothing reports.
1334 Secondary,
1335}
1336
1337/// The wordings GitHub answers a secondary rate limit with.
1338///
1339/// It sends them under a forbidden status, under a too-many-requests status, and inside
1340/// the `errors` of a *successful* response, which is why the text is what this matches on
1341/// rather than the status. `abuse detection` is the wording GitHub used before the
1342/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1343/// what a burst of content creation is refused with.
1344///
1345/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1346/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1347/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1348/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1349pub const SECONDARY_WORDINGS: [&str; 5] = [
1350 "secondary rate limit",
1351 "temporarily blocked from content creation",
1352 "abuse detection",
1353 "submitted too quickly",
1354 "exceeded a secondary",
1355];
1356
1357/// The wordings GitHub answers an exhausted primary budget with.
1358///
1359/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1360/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1361/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1362/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1363/// two phrases is a substring of it, so without it that answer read as a refusal that will
1364/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1365/// one reason.
1366pub const PRIMARY_WORDINGS: [&str; 4] = [
1367 "api rate limit exceeded",
1368 "api rate limit already exceeded",
1369 "rate limit exceeded",
1370 "rate_limited",
1371];
1372
1373/// What a response *says about itself*, which is the only place a refusal can be read.
1374///
1375/// Deliberately not the whole response body. A board is a place people write about their
1376/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1377/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1378/// reported. So the item data is never read: what is read is GitHub's own REST-style
1379/// `message` envelope, which is what a forbidden status carries, and the `message` and
1380/// `type` of each GraphQL error, which is where a *successful* response says it.
1381///
1382/// A body that is not JSON at all has nothing structured to read, so only a failing
1383/// response's own text is taken — a successful response that is not JSON is malformed
1384/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1385fn refusal_wording(status: StatusCode, body: &str) -> String {
1386 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1387 return if status.is_success() {
1388 String::new()
1389 } else {
1390 body.to_owned()
1391 };
1392 };
1393 let mut said: Vec<&str> = parsed
1394 .get("message")
1395 .and_then(Value::as_str)
1396 .into_iter()
1397 .collect();
1398 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1399 for error in errors {
1400 said.extend(
1401 ["message", "type"]
1402 .into_iter()
1403 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1404 );
1405 }
1406 }
1407 said.join("; ")
1408}
1409
1410impl Limiter {
1411 /// Which limiter refused this response, or `None` when none of them did.
1412 ///
1413 /// The wording is read first and the status only decides what carries none of it,
1414 /// because GitHub answers a secondary limit with a forbidden status far more often
1415 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1416 /// really is a credential this token lacks.
1417 ///
1418 /// A response is a refusal because of its status or its own wording. A spent budget
1419 /// only ever explains one; it never turns an answer into a refusal.
1420 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1421 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1422 if SECONDARY_WORDINGS
1423 .iter()
1424 .any(|wording| normalized.contains(wording))
1425 {
1426 return Some(Self::Secondary);
1427 }
1428 if status == StatusCode::TOO_MANY_REQUESTS {
1429 return Some(Self::Primary);
1430 }
1431 // An exhausted budget *explains* a response that failed; it does not make one that
1432 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1433 // request the budget allowed as well as on the ones it then refuses, so reading
1434 // the header alone threw away a good answer — and, once refusals were retried,
1435 // replayed a request that had already taken effect.
1436 if !status.is_success() && budget_exhausted {
1437 return Some(Self::Primary);
1438 }
1439 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1440 // `errors` of an HTTP 200, where nothing about the status says so at all.
1441 if status.is_success()
1442 && PRIMARY_WORDINGS
1443 .iter()
1444 .any(|wording| normalized.contains(wording))
1445 {
1446 return Some(Self::Primary);
1447 }
1448 None
1449 }
1450
1451 /// What this limiter is called where an operator can look it up.
1452 const fn name(self) -> &'static str {
1453 match self {
1454 Self::Primary => "GitHub's primary API rate limit",
1455 Self::Secondary => "GitHub's secondary rate limit",
1456 }
1457 }
1458
1459 /// What the endpoint an operator would go and check says about this limiter.
1460 const fn where_to_look(self) -> &'static str {
1461 match self {
1462 Self::Primary => {
1463 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1464 comes back."
1465 }
1466 Self::Secondary => {
1467 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1468 primary budget and does not report this one, so budget showing there says \
1469 nothing about this refusal, and every further attempt extends it."
1470 }
1471 }
1472 }
1473
1474 /// The next step this limiter actually calls for.
1475 const fn what_to_do(self) -> &'static str {
1476 match self {
1477 Self::Primary => {
1478 "wait for the reset `gh api rate_limit` reports, then run the command again."
1479 }
1480 Self::Secondary => {
1481 "leave this board alone for a few minutes, then run the command again — or \
1482 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1483 }
1484 }
1485 }
1486}
1487
1488/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1489#[derive(Debug, Clone, Copy)]
1490struct Limited {
1491 limiter: Limiter,
1492 hint: Option<u64>,
1493}
1494
1495impl Limited {
1496 /// What the caller is told once this source has waited as long as it may.
1497 ///
1498 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1499 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1500 /// about *which* limiter it was makes it a different kind of failure. What differs is
1501 /// the operator's next step, and that is what the message carries — a secondary
1502 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1503 /// budget looks fine, and then back to retry the very burst that was refused.
1504 fn exhausted(
1505 self,
1506 doing: &str,
1507 waits: u32,
1508 waited: Duration,
1509 needed: Duration,
1510 budget: Duration,
1511 ) -> SourceError {
1512 SourceError::RateLimited {
1513 retry_after_seconds: self.hint,
1514 message: Some(format!(
1515 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1516 again, and the next wait of {} would take it past the {} one call may spend \
1517 waiting. {} next: {}",
1518 self.limiter.name(),
1519 plural(waits, "refusal"),
1520 seconds(waited),
1521 seconds(needed),
1522 seconds(budget),
1523 self.limiter.where_to_look(),
1524 self.limiter.what_to_do(),
1525 )),
1526 }
1527 }
1528}
1529
1530/// One HTTP attempt's result, with what its response said about the rate limit.
1531///
1532/// The two travel together so the record and the outcome are written from the same place:
1533/// what a response said about the budget is only readable while that response is in hand,
1534/// and what the attempt *meant* is only decidable once its body has been read.
1535struct Attempted {
1536 result: Result<Value, Attempt>,
1537 limits: accounting::RateLimit,
1538 /// GitHub's own reported cost for this call, for a document that asked for it.
1539 reported_cost: Option<u64>,
1540}
1541
1542/// One attempt's outcome: an error to report, or a rate limit to wait out.
1543enum Attempt {
1544 Failed(SourceError),
1545 Limited(Limited),
1546}
1547
1548fn plural(count: u32, thing: &str) -> String {
1549 if count == 1 {
1550 format!("{count} {thing}")
1551 } else {
1552 format!("{count} {thing}s")
1553 }
1554}
1555
1556fn seconds(duration: Duration) -> String {
1557 format!("{:.1}s", duration.as_secs_f64())
1558}
1559
1560/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1561///
1562/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1563/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1564/// header, and neither is what makes a response a refusal — so the whole cost of one this
1565/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1566/// instead. Refusing the response over the header would turn a readable refusal into an
1567/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1568fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1569 value
1570 .and_then(|value| value.to_str().ok())
1571 .and_then(|value| value.trim().parse::<u64>().ok())
1572}
1573
1574/// Every mutation this source sends creates content — an issue, a board item, a field of
1575/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1576/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1577/// and what the keyword says are the same set. That is what makes the keyword a sound test
1578/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1579/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1580fn is_mutation(query: &str) -> bool {
1581 query.trim_start().starts_with("mutation")
1582}
1583
1584/// What this source was doing, for a diagnostic that has to say so.
1585///
1586/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1587/// a document added without a description is caught by that list's own gate instead of
1588/// falling through to the vague arm below.
1589fn operation_description(query: &str) -> &'static str {
1590 graphql::DOCUMENTS
1591 .iter()
1592 .find(|(document, _)| *document == query)
1593 .map_or("talking to GitHub", |(_, doing)| *doing)
1594}
1595
1596/// GitHub's published ceiling on content-generating requests, per minute.
1597///
1598/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1599/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1600/// from it, so a pacing value checked only against itself cannot go stale here.
1601pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1602/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1603/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1604/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1605pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1606/// Shortest interval between two content-creating mutations, in milliseconds.
1607///
1608/// GitHub documents two secondary limits on content-generating requests:
1609/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1610/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1611/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1612/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1613/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1614/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1615/// deliberately *not* what this paces at. An installation that wants the hourly bound
1616/// honoured for a long sequence of copies says so through
1617/// `pacing.min_mutation_interval_ms`.
1618pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1619/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1620///
1621/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1622/// own advice for a secondary limit — wait, and wait longer each time — without spending
1623/// the first minute of a transient refusal doing nothing.
1624pub const RETRY_BACKOFF_MS: u64 = 1_000;
1625/// Total time one call may spend waiting out rate limits before it reports a failure.
1626///
1627/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1628/// short enough that a command an operator is watching returns. The bound is what makes
1629/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1630/// the limiter, not in a process nobody can tell from a wedged one.
1631pub const RETRY_BUDGET_MS: u64 = 120_000;
1632
1633fn default_token_env() -> String {
1634 "GH_PROJECTS_TOKEN".to_owned()
1635}
1636fn default_endpoint() -> String {
1637 "https://api.github.com/graphql".to_owned()
1638}
1639
1640/// The name of a `Status` single-select option on the board.
1641///
1642/// Validated on the way in rather than checked later, so a blank option name — which
1643/// nothing on a board can be — is a state this type cannot hold.
1644#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1645#[serde(try_from = "String")]
1646#[schemars(extend("minLength" = 1))]
1647pub struct ColumnName(String);
1648
1649impl ColumnName {
1650 /// The option name, as the board spells it.
1651 fn as_str(&self) -> &str {
1652 &self.0
1653 }
1654}
1655
1656impl TryFrom<String> for ColumnName {
1657 type Error = String;
1658
1659 fn try_from(name: String) -> Result<Self, Self::Error> {
1660 if name.trim().is_empty() {
1661 return Err("a status_mapping option name cannot be blank".to_owned());
1662 }
1663 Ok(Self(name))
1664 }
1665}
1666
1667/// The two closed states this product can mean.
1668///
1669/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1670/// work nor abandoned work, so nothing here ever writes it.
1671#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1672#[serde(rename_all = "kebab-case")]
1673pub enum ClosedState {
1674 /// `COMPLETED` — precisely done.
1675 Completed,
1676 /// `NOT_PLANNED` — precisely cancelled.
1677 NotPlanned,
1678}
1679
1680impl ClosedState {
1681 const fn reason(self) -> &'static str {
1682 match self {
1683 Self::Completed => "COMPLETED",
1684 Self::NotPlanned => "NOT_PLANNED",
1685 }
1686 }
1687}
1688
1689/// Configuration for one GitHub Projects v2 board.
1690#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1691#[serde(default, deny_unknown_fields)]
1692pub struct GitHubProjectsConfig {
1693 /// Login of the user or organization which owns the board.
1694 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1695 /// The project number shown in the board's GitHub URL.
1696 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1697 // 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.
1698 /// `owner/name` of the repository this source creates an issue in when the item's own
1699 /// `repositories` field does not decide it.
1700 ///
1701 /// An item naming exactly one repository is created there; a task or a document naming
1702 /// none or several is created in its parent project's repository; and a project, or a
1703 /// task or document with no parent, naming none or several is created here. A board
1704 /// has no repository of its own and `createIssue` requires one, so a write without
1705 /// this is refused naming the field. Reads never need it.
1706 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1707 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1708 /// Environment variable containing a fine-grained token with Projects and Issues
1709 /// read/write plus Pull requests read-only access for every repository represented on
1710 /// the board.
1711 #[serde(default = "default_token_env")]
1712 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1713 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1714 #[serde(default = "default_endpoint")]
1715 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1716 /// Per-instance mapping from a status category to the option of the board's one
1717 /// `Status` field it lands on, for a task and for a project.
1718 ///
1719 /// The shared `StatusMapping` grammar: each value is one option name for both kinds,
1720 /// `null` to disable the category for both, or `{task, project}` naming it per kind,
1721 /// where a kind left out leaves the category unmapped for that kind. A category this
1722 /// does not mention keeps its shipped default for both kinds: `backlog` to "Backlog",
1723 /// `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress", `done` to
1724 /// "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed as not
1725 /// planned, and `draft` and `unknown` unmapped. A category it does mention gets no
1726 /// shipped default for a kind it leaves out. `done` and `cancelled` close the issue for
1727 /// either kind. No two categories may name one option for the same kind, ignoring case.
1728 /// `unknown` may name one existing option; every unknown word then lands on it and
1729 /// reads back as `unknown` under its name. Unlike `local-md`, this source cannot keep
1730 /// each unknown word because it never creates board options.
1731 #[serde(default)]
1732 pub status_mapping: StatusMapping,
1733 /// Per-instance mapping from a task's priority to an option of this board's
1734 /// single-select field named `Priority`.
1735 ///
1736 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1737 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1738 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1739 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1740 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1741 /// no two levels may name one option. Reads and writes never create the field or an
1742 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1743 /// the board lacks is refused pointing there.
1744 #[serde(default)]
1745 pub priority_mapping: Option<PriorityMappingConfig>,
1746 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1747 ///
1748 /// Every field keeps its shipped default when it is absent, and the defaults are
1749 /// GitHub's own published limits rather than taste. See [`Pacing`].
1750 #[serde(default)]
1751 pub pacing: PacingConfig,
1752}
1753
1754/// Which option of the board's `Priority` field each priority lands on.
1755///
1756/// One member per level rather than a map, so a key that is not a level is refused where
1757/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1758/// value in the field, not an option of it.
1759#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1760#[serde(default, deny_unknown_fields)]
1761pub struct PriorityMappingConfig {
1762 /// The option `urgent` lands on; `Urgent` when absent.
1763 pub urgent: Option<PriorityOptionName>,
1764 /// The option `high` lands on; `High` when absent.
1765 pub high: Option<PriorityOptionName>,
1766 /// The option `medium` lands on; `Medium` when absent.
1767 pub medium: Option<PriorityOptionName>,
1768 /// The option `low` lands on; `Low` when absent.
1769 pub low: Option<PriorityOptionName>,
1770}
1771
1772/// The name of an option of the board's `Priority` single-select field.
1773///
1774/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1775/// blank name.
1776#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1777#[serde(try_from = "String")]
1778#[schemars(extend("minLength" = 1))]
1779pub struct PriorityOptionName(String);
1780
1781impl PriorityOptionName {
1782 /// The option name, as the board spells it.
1783 fn as_str(&self) -> &str {
1784 &self.0
1785 }
1786}
1787
1788impl TryFrom<String> for PriorityOptionName {
1789 type Error = String;
1790
1791 fn try_from(name: String) -> Result<Self, Self::Error> {
1792 if name.trim().is_empty() {
1793 return Err("a priority_mapping option name cannot be blank".to_owned());
1794 }
1795 Ok(Self(name))
1796 }
1797}
1798
1799/// The name of the board field a priority is held in.
1800pub const PRIORITY_FIELD: &str = "Priority";
1801
1802/// The four priorities a board option can hold, in the order a new `Priority` field lists
1803/// them. `none` is not among them: it is the field holding no value.
1804///
1805/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1806/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1807/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1808/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1809pub const PRIORITY_LEVELS: [Priority; 4] = [
1810 Priority::Urgent,
1811 Priority::High,
1812 Priority::Medium,
1813 Priority::Low,
1814];
1815
1816/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1817/// see that list for what this pins.
1818#[must_use]
1819pub const fn level_position(priority: Priority) -> Option<usize> {
1820 match priority {
1821 Priority::None => None,
1822 Priority::Urgent => Some(0),
1823 Priority::High => Some(1),
1824 Priority::Medium => Some(2),
1825 Priority::Low => Some(3),
1826 }
1827}
1828
1829/// This instance's complete priority-to-option mapping, read in both directions.
1830///
1831/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1832/// two levels name one option.
1833#[derive(Debug, Clone)]
1834struct PriorityMapping {
1835 options: [PriorityOptionName; 4],
1836}
1837
1838impl PriorityMapping {
1839 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1840 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1841 let mapping = Self {
1842 options: [
1843 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1844 config.high.unwrap_or_else(|| shipped("High")),
1845 config.medium.unwrap_or_else(|| shipped("Medium")),
1846 config.low.unwrap_or_else(|| shipped("Low")),
1847 ],
1848 };
1849 for (index, option) in mapping.options.iter().enumerate() {
1850 if let Some(earlier) = mapping.options[..index]
1851 .iter()
1852 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1853 {
1854 return Err(SourceError::Config {
1855 message: format!(
1856 "priority_mapping of source {instance} sends both {} and {} to the board \
1857 option {:?}; one option cannot read back as two priorities",
1858 PRIORITY_LEVELS[earlier],
1859 PRIORITY_LEVELS[index],
1860 option.as_str()
1861 ),
1862 });
1863 }
1864 }
1865 Ok(mapping)
1866 }
1867
1868 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1869 fn option(&self, priority: Priority) -> Option<&str> {
1870 level_position(priority).map(|index| self.options[index].as_str())
1871 }
1872
1873 /// The priority a board option name reports, or `None` when nothing maps to it.
1874 fn priority_of(&self, option: &str) -> Option<Priority> {
1875 self.options
1876 .iter()
1877 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1878 .map(|index| PRIORITY_LEVELS[index])
1879 }
1880
1881 /// Every mapped option name, in the order a new `Priority` field lists them.
1882 fn names(&self) -> impl Iterator<Item = &str> {
1883 self.options.iter().map(PriorityOptionName::as_str)
1884 }
1885}
1886
1887/// What one item's `Priority` field says, read through this instance's mapping.
1888#[derive(Debug, Clone, PartialEq, Eq)]
1889enum HeldPriority {
1890 /// A priority this source reports: an option the mapping names, or no value (`none`).
1891 Read(Priority),
1892 /// An option the mapping does not name, which is never read as a level or as `none`.
1893 Unmapped(String),
1894}
1895
1896/// How fast this source writes, and how long it waits out a rate-limit refusal.
1897///
1898/// Configurable because a GitHub Enterprise installation sets its own limits and an
1899/// operator who has already been refused may want to go slower still — not because the
1900/// defaults are guesses.
1901#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1902#[serde(default, deny_unknown_fields)]
1903pub struct PacingConfig {
1904 /// Shortest interval between two content-creating mutations, in milliseconds.
1905 ///
1906 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1907 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1908 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.
1909 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1910 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1911 /// zero while there is a budget to spend, because a schedule of zero-length waits
1912 /// consumes none of it and so never ends.
1913 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.
1914 /// Total time one call may spend waiting out rate limits, in milliseconds.
1915 ///
1916 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1917 /// the bound is what makes this a wait rather than a hang.
1918 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.
1919}
1920
1921/// The largest any pacing setting may be, in milliseconds.
1922///
1923/// One hour. GitHub's own harshest published bound on content-generating requests works
1924/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1925/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1926/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1927/// and an interval beyond it is a command that never sends its second mutation. It also
1928/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1929/// what an `Instant` can hold on every platform.
1930pub const MAX_PACING_MS: u64 = 3_600_000;
1931
1932/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1933/// source holds.
1934#[derive(Debug, Clone, Copy)]
1935struct Pacing {
1936 min_mutation_interval: Duration,
1937 retry_backoff: Duration,
1938 retry_budget: Duration,
1939}
1940
1941impl Pacing {
1942 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1943 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1944 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1945 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1946 message: format!(
1947 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1948 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1949 GitHub's own harshest published limit"
1950 ),
1951 }),
1952 Some(value) => Ok(Duration::from_millis(value)),
1953 None => Ok(Duration::from_millis(default)),
1954 };
1955 let retry_backoff = bounded(
1956 config.retry_backoff_ms,
1957 RETRY_BACKOFF_MS,
1958 "retry_backoff_ms",
1959 )?;
1960 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1961 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1962 return Err(SourceError::Config {
1963 message: format!(
1964 "pacing.retry_backoff_ms of source {instance} is 0 while \
1965 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1966 none of that budget, so it would retry a refusal forever. Set a backoff of \
1967 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1968 waiting at all",
1969 retry_budget.as_millis()
1970 ),
1971 });
1972 }
1973 Ok(Self {
1974 min_mutation_interval: bounded(
1975 config.min_mutation_interval_ms,
1976 MIN_MUTATION_INTERVAL_MS,
1977 "min_mutation_interval_ms",
1978 )?,
1979 retry_backoff,
1980 retry_budget,
1981 })
1982 }
1983}
1984
1985/// Factory for [`GitHubProjectsSource`].
1986#[derive(Debug, Clone, Copy, Default)]
1987pub struct Plugin;
1988
1989impl SourcePlugin for Plugin {
1990 fn kind(&self) -> &'static str {
1991 KIND
1992 }
1993 fn config_schema(&self) -> Schema {
1994 schema_for!(GitHubProjectsConfig)
1995 }
1996 fn build(
1997 &self,
1998 name: &SourceName,
1999 config: &Value,
2000 secrets: &dyn SecretResolver,
2001 ) -> Result<Box<dyn TaskSource>, SourceError> {
2002 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
2003 }
2004}
2005
2006impl Plugin {
2007 /// Build a source recording every request it sends into an accounting the caller holds.
2008 ///
2009 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2010 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2011 /// session total rather than two — see [`accounting`] and
2012 /// [`GitHubProjectsSource::recording_into`].
2013 ///
2014 /// # Errors
2015 ///
2016 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2017 /// [`SourceError::Config`] for configuration this plugin cannot use and
2018 /// [`SourceError::Auth`] for a credential it cannot find.
2019 pub fn build_recording_into(
2020 &self,
2021 name: &SourceName,
2022 config: &Value,
2023 secrets: &dyn SecretResolver,
2024 ledger: Arc<Accounting>,
2025 ) -> Result<Box<dyn TaskSource>, SourceError> {
2026 let config: GitHubProjectsConfig =
2027 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2028 message: format!("source {name}: {e}"),
2029 })?;
2030 let prefix = format!("source {name}: ");
2031 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2032 |error| match error {
2033 // The shared `StatusMapping::distinct` names the source itself.
2034 SourceError::Config { message } if message.starts_with(&prefix) => {
2035 SourceError::Config { message }
2036 }
2037 SourceError::Config { message } => SourceError::Config {
2038 message: format!("{prefix}{message}"),
2039 },
2040 SourceError::Auth { message } => SourceError::Auth {
2041 message: format!("source {name}: {message}"),
2042 },
2043 other => other,
2044 },
2045 )?;
2046 Ok(Box::new(source))
2047 }
2048}
2049
2050/// Where a status category lands on this board, once configuration is resolved.
2051#[derive(Debug, Clone, PartialEq, Eq)]
2052enum StatusTarget {
2053 /// Not usable against this instance for this kind, and why.
2054 Disabled(UnmappedStatus),
2055 /// The board's `Status` option of this name.
2056 Column(ColumnName),
2057 /// A closed issue, with both its board option and the reason that says which closed it means.
2058 // 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.
2059 Terminal(ColumnName, ClosedState),
2060}
2061
2062/// Every status category, in the order the vocabulary declares them.
2063///
2064/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2065/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2066/// added to the shared vocabulary fails to compile until it is named there, and this
2067/// crate's suite reconciles this list against that enum's own derived schema, which is
2068/// generated from the variants rather than written beside them. The schema is what
2069/// catches a list left one short — a list checking only the positions it already holds
2070/// would pass while every mapping indexed by the new position panicked.
2071pub const CATEGORIES: [StatusCategory; 8] = [
2072 StatusCategory::Draft,
2073 StatusCategory::Backlog,
2074 StatusCategory::Todo,
2075 StatusCategory::Queued,
2076 StatusCategory::InProgress,
2077 StatusCategory::Done,
2078 StatusCategory::Cancelled,
2079 StatusCategory::Unknown,
2080];
2081
2082/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2083#[must_use]
2084pub const fn category_position(category: StatusCategory) -> usize {
2085 match category {
2086 StatusCategory::Draft => 0,
2087 StatusCategory::Backlog => 1,
2088 StatusCategory::Todo => 2,
2089 StatusCategory::Queued => 3,
2090 StatusCategory::InProgress => 4,
2091 StatusCategory::Done => 5,
2092 StatusCategory::Cancelled => 6,
2093 StatusCategory::Unknown => 7,
2094 }
2095}
2096
2097/// The spelling a status category is configured and reported under.
2098fn category_name(category: StatusCategory) -> &'static str {
2099 match category {
2100 StatusCategory::Draft => "draft",
2101 StatusCategory::Backlog => "backlog",
2102 StatusCategory::Todo => "todo",
2103 StatusCategory::Queued => "queued",
2104 StatusCategory::InProgress => "in-progress",
2105 StatusCategory::Done => "done",
2106 StatusCategory::Cancelled => "cancelled",
2107 StatusCategory::Unknown => "unknown",
2108 }
2109}
2110
2111/// A shipped default's option name.
2112///
2113/// The literals below are this file's own and non-blank, and they are validated by the
2114/// one constructor a configured name goes through rather than beside it.
2115fn shipped_column(name: &'static str) -> ColumnName {
2116 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2117}
2118
2119/// The shipped default for one category this instance's `status_mapping` does not mention,
2120/// for either kind.
2121fn shipped_default(category: StatusCategory) -> StatusTarget {
2122 match category {
2123 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2124 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2125 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2126 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2127 StatusCategory::Done => {
2128 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2129 }
2130 StatusCategory::Cancelled => {
2131 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2132 }
2133 StatusCategory::Draft | StatusCategory::Unknown => {
2134 StatusTarget::Disabled(UnmappedStatus::Unconfigured)
2135 }
2136 }
2137}
2138
2139/// The two kinds a status is written and read for, each with its own half of the mapping.
2140const STATUS_KINDS: [ItemKind; 2] = [ItemKind::Task, ItemKind::Project];
2141
2142/// This instance's complete category-to-target mapping for each kind, read in both
2143/// directions.
2144///
2145/// One target per category per kind, held at that category's own [`category_position`], so
2146/// a category missing from the mapping, named twice in it, or filed out of order is a state
2147/// this type cannot hold rather than one [`Self::target`] has to defend against. Both kinds'
2148/// targets are options of the board's one `Status` field.
2149#[derive(Debug, Clone)]
2150struct BoardStatuses {
2151 tasks: [StatusTarget; CATEGORIES.len()],
2152 projects: [StatusTarget; CATEGORIES.len()],
2153}
2154
2155impl BoardStatuses {
2156 /// Resolve `configured` against the shipped defaults, refusing two categories one kind
2157 /// would read back from one option.
2158 ///
2159 /// A category the mapping does not mention keeps its shipped default for both kinds; one
2160 /// it does mention is exactly what it configures, so a per-kind object leaves the kind it
2161 /// omits unmapped rather than defaulted.
2162 fn resolve(configured: &StatusMapping, instance: &SourceName) -> Result<Self, SourceError> {
2163 let resolve_kind =
2164 |kind: ItemKind| -> Result<[StatusTarget; CATEGORIES.len()], SourceError> {
2165 // `CATEGORIES[position] == category` for every category — the crate's suite
2166 // asserts it — so mapping the list in order fills each category's own slot.
2167 let mut targets = CATEGORIES.map(shipped_default);
2168 for (slot, category) in targets.iter_mut().zip(CATEGORIES) {
2169 if !configured.mentions(category) {
2170 continue;
2171 }
2172 *slot = match configured.name_for(category, kind) {
2173 Err(why) => StatusTarget::Disabled(why),
2174 Ok(name) => {
2175 let option = ColumnName::try_from(name.as_str().to_owned())
2176 .map_err(|message| SourceError::Config { message })?;
2177 match category {
2178 StatusCategory::Done => {
2179 StatusTarget::Terminal(option, ClosedState::Completed)
2180 }
2181 StatusCategory::Cancelled => {
2182 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2183 }
2184 _ => StatusTarget::Column(option),
2185 }
2186 }
2187 };
2188 }
2189 StatusMapping::distinct(
2190 instance,
2191 kind,
2192 CATEGORIES
2193 .iter()
2194 .zip(&targets)
2195 .filter_map(|(category, target)| target.option().map(|o| (*category, o))),
2196 )?;
2197 Ok(targets)
2198 };
2199 Ok(Self {
2200 tasks: resolve_kind(ItemKind::Task)?,
2201 projects: resolve_kind(ItemKind::Project)?,
2202 })
2203 }
2204
2205 /// Every category's target for `kind`, in category order.
2206 const fn targets(&self, kind: ItemKind) -> &[StatusTarget; CATEGORIES.len()] {
2207 match kind {
2208 ItemKind::Task => &self.tasks,
2209 ItemKind::Project => &self.projects,
2210 }
2211 }
2212
2213 fn target(&self, kind: ItemKind, category: StatusCategory) -> &StatusTarget {
2214 &self.targets(kind)[category_position(category)]
2215 }
2216
2217 /// The category a board option name reports for `kind`, or `None` when nothing of that
2218 /// kind maps to it.
2219 fn category_of(&self, kind: ItemKind, option: &str) -> Option<StatusCategory> {
2220 CATEGORIES.into_iter().find(|category| {
2221 self.target(kind, *category)
2222 .option()
2223 .is_some_and(|name| name.eq_ignore_ascii_case(option))
2224 })
2225 }
2226
2227 /// Every option name either kind maps a category to, each once ignoring case, in
2228 /// category order with a task's name before a project's — what the guarded setup asks
2229 /// the `Status` field to hold.
2230 fn wanted(&self) -> Vec<String> {
2231 let mut wanted: Vec<String> = Vec::new();
2232 for category in CATEGORIES {
2233 for kind in STATUS_KINDS {
2234 if let Some(name) = self.target(kind, category).option()
2235 && !wanted.iter().any(|held| held.eq_ignore_ascii_case(name))
2236 {
2237 wanted.push(name.to_owned());
2238 }
2239 }
2240 }
2241 wanted
2242 }
2243
2244 /// The status an item of `kind` reports, from the three things a read of it says: its
2245 /// board `Status` option, whether its issue is closed, and the reason it was closed with.
2246 ///
2247 /// The closed state decides the category and the `Status` option decides the name, so
2248 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`, whatever
2249 /// its kind. A closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`:
2250 /// a duplicate is not finished work, and calling it done is a lie the next copy would
2251 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2252 /// it is read permissively rather than refused — reads are faithful, and refusals
2253 /// belong on writes. An open item's option reads through its own kind's mapping, and an
2254 /// option that mapping does not name reads as `Unknown` under its own name.
2255 ///
2256 /// One function of those three rather than of a response, so a narrow status write can
2257 /// answer what a re-read would report by applying it to the state it has just written.
2258 fn status(
2259 &self,
2260 kind: ItemKind,
2261 option: Option<&str>,
2262 closed: bool,
2263 reason: Option<&str>,
2264 ) -> Status {
2265 if closed {
2266 let category = match reason {
2267 None | Some("COMPLETED") => StatusCategory::Done,
2268 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2269 Some(_) => StatusCategory::Unknown,
2270 };
2271 let fallback = match category {
2272 StatusCategory::Done => "Done",
2273 StatusCategory::Cancelled => "Cancelled",
2274 _ => "Closed",
2275 };
2276 return Status {
2277 category,
2278 name: option.unwrap_or(fallback).to_owned(),
2279 };
2280 }
2281 let name = option.unwrap_or("Open").to_owned();
2282 Status {
2283 category: self
2284 .category_of(kind, &name)
2285 .unwrap_or(StatusCategory::Unknown),
2286 name,
2287 }
2288 }
2289}
2290
2291impl BoardStatuses {
2292 /// For each kind, the option names it maps a category to that `existing` lacks, ignoring
2293 /// case; a kind lacking none is left out.
2294 fn missing_by_kind(&self, existing: &[StatusOption]) -> Vec<KindMissing> {
2295 STATUS_KINDS
2296 .into_iter()
2297 .filter_map(|kind| {
2298 let missing: Vec<String> = self
2299 .targets(kind)
2300 .iter()
2301 .filter_map(StatusTarget::option)
2302 .filter(|wanted| {
2303 !existing
2304 .iter()
2305 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2306 })
2307 .map(str::to_owned)
2308 .collect();
2309 (!missing.is_empty()).then_some(KindMissing { kind, missing })
2310 })
2311 .collect()
2312 }
2313}
2314
2315impl StatusTarget {
2316 /// The board option this target selects, or `None` for an unmapped one.
2317 fn option(&self) -> Option<&str> {
2318 match self {
2319 Self::Column(name) | Self::Terminal(name, _) => Some(name.as_str()),
2320 Self::Disabled(_) => None,
2321 }
2322 }
2323}
2324
2325// 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.
2326/// One repository this source can create an issue in, as `owner/name`.
2327///
2328/// Every `createIssue` this source sends names one of these: the item's own single
2329/// `repositories` entry, else its parent project issue's repository, else the configured
2330/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2331/// that choice and says what it refuses before `createIssue`.
2332// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2333#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2334struct RepositoryTarget {
2335 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2336 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2337}
2338
2339impl RepositoryTarget {
2340 fn parse(value: &str) -> Result<Self, SourceError> {
2341 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2342 message: format!(
2343 "repository must be spelled owner/name; {value:?} names no repository"
2344 ),
2345 })?;
2346 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2347 return Err(SourceError::Config {
2348 message: format!(
2349 "repository must be spelled owner/name with a GitHub login and one \
2350 repository name; {value:?} is not"
2351 ),
2352 });
2353 }
2354 Ok(Self {
2355 owner: owner.to_owned(),
2356 name: name.to_owned(),
2357 })
2358 }
2359
2360 /// The one host whose repositories this source creates issues in, spelled once: it is
2361 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2362 const HOST: &str = "github.com";
2363
2364 fn origin(&self) -> String {
2365 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2366 }
2367
2368 /// The repository a normalized origin names, or why it is none this source can create
2369 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2370 fn from_origin(origin: &Repository) -> Result<Self, String> {
2371 let not_here = || {
2372 format!(
2373 "{} is not a {}/owner/name repository",
2374 origin.as_str(),
2375 Self::HOST
2376 )
2377 };
2378 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2379 if host != Self::HOST {
2380 return Err(not_here());
2381 }
2382 Self::parse(rest).map_err(|_| not_here())
2383 }
2384
2385 fn slug(&self) -> String {
2386 format!("{}/{}", self.owner, self.name)
2387 }
2388}
2389
2390/// A source which reads GitHub afresh for every operation.
2391pub struct GitHubProjectsSource {
2392 /// This source's configured name, used both to tell a far end naming this source
2393 /// from one naming a system it knows nothing about, and to name the instance a
2394 /// status refusal is about.
2395 name: SourceName,
2396 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2397 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2398 repository: Option<RepositoryTarget>,
2399 endpoint: Url,
2400 token: SecretString,
2401 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2402 statuses: BoardStatuses,
2403 /// Where each priority lands on this board, or `None` when this instance holds none.
2404 priorities: Option<PriorityMapping>,
2405 client: Client,
2406 /// Every item this source has created since it was built, in the order it created
2407 /// them.
2408 ///
2409 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2410 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2411 /// a copy resolving a dependency on an item it had just created refused it as not
2412 /// found. A board read is completed from this — an item remembered here and absent from
2413 /// the read is added back, because the board really does hold it and only the read is
2414 /// behind.
2415 ///
2416 /// It is not a cache of a user's work: nothing is remembered that this process did not
2417 /// itself just write, it lives and dies with the process, and it is never consulted for
2418 /// an item this source did not create.
2419 created: Mutex<Vec<Resolved>>,
2420 /// Every item that already existed and that this source has written since it was built,
2421 /// as it wrote it.
2422 ///
2423 /// The other half of [`Self::created`], held on the same terms and for the reason a
2424 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2425 /// filter is an index behind a write this process made moments ago, so a query matching
2426 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2427 /// is remembered that this process did not itself just write.
2428 updated: Mutex<Vec<Resolved>>,
2429 /// How fast this source writes, and how long it waits out a refusal.
2430 pacing: Pacing,
2431 /// When the last content-creating mutation finished, or the moment the furthest-out
2432 /// reserved slot releases the next one, whichever is later — so the one after it can be
2433 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2434 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2435 /// what it is measured from.
2436 last_mutation: Mutex<Option<Instant>>,
2437 /// The board as this process last read it, for the length of one command.
2438 ///
2439 /// A copy of a project used to re-read the whole board, paged, before writing each of
2440 /// its items, which is by far the largest part of a copy's request count and none of
2441 /// its work. Nothing else changes this board while a command runs — this source's own
2442 /// writes are the only writer — so one read answers them all.
2443 ///
2444 /// It is not a store of a user's work and it is not the cache the no-persistence
2445 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2446 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2447 /// an item this command created and then depends on resolves whether or not GitHub's
2448 /// own eventually-consistent read has caught up. A write to an item already on the
2449 /// board updates the entry here too, so what this holds is the last read plus this
2450 /// process's own writes rather than a snapshot taken before them.
2451 board_cache: Mutex<Option<Board>>,
2452 /// Every issue this board's own search reported, for the length of one command.
2453 ///
2454 /// The second half of a board read, and cached for the same reason and on the same
2455 /// terms as the first: it lives and dies with the process, nothing is written down, and
2456 /// a write this process makes updates the entry here exactly as it updates the one in
2457 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2458 /// that lists this board's projects and its tasks pays for one search rather than two.
2459 search_cache: Mutex<Option<Vec<Resolved>>>,
2460 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2461 /// the length of one command.
2462 ///
2463 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2464 /// and dies with the process, nothing is written down, a write this process makes
2465 /// updates the entry here as it updates the other two, and every answer is completed
2466 /// with this process's own writes each time it is given. A command that asks the same
2467 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2468 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2469 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2470 search_next: Mutex<BTreeMap<String, Option<String>>>,
2471 /// Records already resolved in this source instance, reused by writes and
2472 /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2473 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2474 /// The board's own id and field definitions as this process last read them on their
2475 /// own, for the length of one command.
2476 ///
2477 /// What a write needs of the board and its item does not say, read once per command
2478 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2479 /// lives and dies with the process and nothing is written down. It holds no item and so
2480 /// can answer no question about one — see [`Self::board_fields`].
2481 fields_cache: Mutex<Option<BoardFields>>,
2482 /// Each destination repository's node id, resolved once per repository
2483 /// rather than per issue created.
2484 ///
2485 /// A repository's node id does not change, and re-reading it for every issue of a copy
2486 /// spent one request per item on an answer this source already had. It is a map rather
2487 /// than one entry because a copy files each item in the repository its own
2488 /// `repositories` field names, so a plan across five repositories asks GitHub five
2489 /// times and not once per item.
2490 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2491 /// What every request this source sends is recorded into.
2492 ///
2493 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2494 /// a request leaves this crate, so nothing has to be switched on for a session to be
2495 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2496 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2497 /// source's reads and writes — adds up one accounting instead of two. See
2498 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2499 ledger: Arc<Accounting>,
2500}
2501
2502/// GitHub's closed single-select color vocabulary.
2503#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2504#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2505pub enum StatusOptionColor {
2506 /// Gray.
2507 Gray,
2508 /// Blue.
2509 Blue,
2510 /// Green.
2511 Green,
2512 /// Yellow.
2513 Yellow,
2514 /// Purple.
2515 Purple,
2516 /// Red.
2517 Red,
2518 /// Orange.
2519 Orange,
2520 /// Pink.
2521 Pink,
2522}
2523
2524/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2525/// applies its additions.
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2527pub enum SetupMode {
2528 /// Read without mutation.
2529 Plan,
2530 /// Apply and verify.
2531 Apply,
2532}
2533
2534/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2535/// against it goes on compiling.
2536pub type StatusOptionsMode = SetupMode;
2537
2538/// The explicit result of the requested operation.
2539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2540#[serde(rename_all = "kebab-case")]
2541pub enum StatusOptionsOutcome {
2542 /// A read-only plan.
2543 Planned,
2544 /// Apply found nothing missing.
2545 Unchanged,
2546 /// Additions were applied and verified.
2547 Applied,
2548}
2549
2550/// A GitHub single-select option's opaque GraphQL node identifier.
2551#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2552#[serde(transparent)]
2553pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2554
2555impl TryFrom<String> for StatusOptionId {
2556 type Error = String;
2557
2558 fn try_from(id: String) -> Result<Self, Self::Error> {
2559 if id.trim().is_empty() {
2560 return Err("a GitHub Status option id cannot be blank".to_owned());
2561 }
2562 Ok(Self(id))
2563 }
2564}
2565
2566/// One existing or proposed option in a guarded Status-field update.
2567#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2568pub struct StatusOption {
2569 /// GitHub's stable id.
2570 pub id: StatusOptionId,
2571 /// The visible option name.
2572 pub name: ColumnName,
2573 /// GitHub's single-select color token.
2574 pub color: StatusOptionColor,
2575 /// The option description, including an empty one.
2576 pub description: String,
2577}
2578
2579/// One board item's Status assignment, retained as recovery data.
2580#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2581pub struct StatusAssignment {
2582 /// The project item id whose assignment this is.
2583 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2584 // carried verbatim as operator recovery data; introducing a semantic type would claim
2585 // validation rules GitHub does not publish and no operation here interprets.
2586 pub item_id: String,
2587 /// The selected option, absent when the item has no status.
2588 #[serde(skip_serializing_if = "Option::is_none")]
2589 pub option: Option<AssignedStatusOption>,
2590}
2591
2592/// The inseparable id and name of an assigned option.
2593#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2594pub struct AssignedStatusOption {
2595 /// GitHub's stable id.
2596 pub id: StatusOptionId,
2597 /// The visible name.
2598 pub name: ColumnName,
2599}
2600
2601/// The plan and verified outcome of reconciling configured Status options.
2602#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2603pub struct StatusOptionsReport {
2604 /// The configured source name.
2605 pub source: SourceName,
2606 /// Configured option names absent before the operation.
2607 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2608 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2609 // serialized string here preserves the report's intentionally simple public contract.
2610 pub missing: Vec<String>,
2611 /// What the requested operation did.
2612 pub outcome: StatusOptionsOutcome,
2613 /// The complete option list observed before any mutation.
2614 pub existing: Vec<StatusOption>,
2615}
2616
2617#[derive(Debug, Clone, PartialEq, Eq)]
2618struct StatusSnapshot {
2619 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2620 // passed back as the mutation's project identity; a newtype could enforce no stronger
2621 // invariant because GitHub publishes no grammar for it.
2622 board_id: String,
2623 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2624 // passed back as the mutation's field identity; a newtype could enforce no stronger
2625 // invariant because GitHub publishes no grammar for it.
2626 field_id: String,
2627 options: Vec<StatusOption>,
2628 assignments: Vec<StatusAssignment>,
2629}
2630
2631/// The name of the board field a status is held in.
2632const STATUS_FIELD: &str = "Status";
2633
2634/// Every item's value of each field `report` names, as it stood before the setup wrote
2635/// anything — what a person puts back when the setup is refused part way.
2636fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2637 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2638 .fields
2639 .iter()
2640 .map(|field| (field.field.name(), before.assignments(field.field)))
2641 .collect();
2642 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2643 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2644 })
2645}
2646
2647/// One board field the guarded setup reads and writes — every one it reads, and the only
2648/// ones it writes.
2649#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2650pub enum BoardField {
2651 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2652 Status,
2653 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2654 Priority,
2655}
2656
2657impl BoardField {
2658 /// The field's name on the board.
2659 #[must_use]
2660 pub const fn name(self) -> &'static str {
2661 match self {
2662 Self::Status => STATUS_FIELD,
2663 Self::Priority => PRIORITY_FIELD,
2664 }
2665 }
2666
2667 /// The field a board calls `name`, or `None` for one this setup does not own.
2668 fn named(name: &str) -> Option<Self> {
2669 [Self::Status, Self::Priority]
2670 .into_iter()
2671 .find(|field| field.name() == name)
2672 }
2673}
2674
2675/// What the guarded setup did to one field.
2676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2677#[serde(rename_all = "kebab-case")]
2678pub enum FieldOutcome {
2679 /// A read-only plan.
2680 Planned,
2681 /// Apply found the field there with every configured option.
2682 Unchanged,
2683 /// Missing options were added to the field that was there, and verified.
2684 Applied,
2685 /// The field was not there; it was created holding the configured options, and verified.
2686 Created,
2687}
2688
2689/// One field's plan, or its verified outcome.
2690#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2691pub struct FieldReport {
2692 /// Which field.
2693 pub field: BoardField,
2694 /// Whether the board had the field before the operation.
2695 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2696 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2697 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2698 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2699 // one constructor, and it derives `outcome` from `exists` in one match.
2700 pub exists: bool,
2701 /// Configured option names the field lacked before the operation — every one of them,
2702 /// in the order a new field lists them, when the field was not there at all.
2703 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2704 // mapping name and has therefore already passed its nonblank validation; the serialized
2705 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2706 pub missing: Vec<String>,
2707 /// For the `Status` field, which item kind each missing name is configured for: one
2708 /// entry per kind `status_mapping` names a missing option for, task before project, each
2709 /// listing that kind's missing names in category order. A name both kinds use is in
2710 /// both. Empty — and left out of the JSON — when nothing is missing, and always for
2711 /// `Priority`, which only a task holds.
2712 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2713 // Kept in the schema as `"default": []` although the JSON leaves an empty list out, so
2714 // both SDKs model an absent `kinds` as an empty list rather than as `null`.
2715 #[schemars(!skip_serializing_if)]
2716 pub kinds: Vec<KindMissing>,
2717 /// What the requested operation did.
2718 pub outcome: FieldOutcome,
2719 /// The field's complete option list observed before any mutation; empty when the field
2720 /// was not there.
2721 pub existing: Vec<StatusOption>,
2722}
2723
2724/// The `Status` option names one item kind's `status_mapping` names that the field lacked.
2725#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2726pub struct KindMissing {
2727 /// The kind these names are configured for.
2728 pub kind: ItemKind,
2729 /// The names that kind maps a category to and the field lacked, in category order.
2730 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2731 // mapping name, as `FieldReport::missing`'s do, and the serialized string is the report's
2732 // intentionally simple public contract.
2733 pub missing: Vec<String>,
2734}
2735
2736/// The plan and verified outcome of setting up every field a source's configuration names.
2737#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2738pub struct FieldsReport {
2739 /// The configured source name.
2740 pub source: SourceName,
2741 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2742 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2743 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2744 // per field would change a published JSON shape. The states the list could hold and the
2745 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2746 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2747 pub fields: Vec<FieldReport>,
2748}
2749
2750/// Which options one field is configured with, in the order a new field would list them.
2751struct FieldPlan {
2752 field: BoardField,
2753 wanted: Vec<String>,
2754}
2755
2756/// One single-select field as the guarded setup snapshots it.
2757#[derive(Debug, Clone, PartialEq, Eq)]
2758struct SnapshotField {
2759 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2760 // passed back as the mutation's field identity; a newtype could enforce no stronger
2761 // invariant because GitHub publishes no grammar for it.
2762 field_id: String,
2763 options: Vec<StatusOption>,
2764}
2765
2766/// Every single-select field of a board and every item's value of each.
2767#[derive(Debug, Clone, PartialEq, Eq)]
2768struct BoardSnapshot {
2769 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2770 // passed back as the mutation's project identity; a newtype could enforce no stronger
2771 // invariant because GitHub publishes no grammar for it.
2772 board_id: String,
2773 fields: BTreeMap<BoardField, SnapshotField>,
2774 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2775 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2776}
2777
2778impl BoardSnapshot {
2779 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2780 /// carries.
2781 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2782 self.items
2783 .iter()
2784 .map(|(item_id, values)| StatusAssignment {
2785 item_id: item_id.clone(),
2786 option: values.get(&field).cloned(),
2787 })
2788 .collect()
2789 }
2790}
2791
2792impl GitHubProjectsSource {
2793 /// Report missing configured Status options and, when `apply` is true, add them with
2794 /// a whole-list mutation that preserves every existing id and verifies the result.
2795 ///
2796 /// # Errors
2797 ///
2798 /// Refuses a board without a single-select `Status` field. A post-write difference in
2799 /// any pre-existing option id or item assignment is refused with the complete pre-write
2800 /// assignment snapshot in the diagnostic for recovery.
2801 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2802 // successful mutation, both drift refusals, source selection, missing Status, casing,
2803 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2804 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2805 // responses from entering the defensive malformed-response branches below.
2806 pub async fn status_options(
2807 &self,
2808 mode: StatusOptionsMode,
2809 ) -> Result<StatusOptionsReport, SourceError> {
2810 let before = self.status_snapshot().await?;
2811 // A terminal category's option is as configured as an open one's: a terminal
2812 // write validates it before closing and refuses when the board lacks it. Both
2813 // kinds' names are options of the one field, so both are asked for.
2814 let missing = self
2815 .statuses
2816 .wanted()
2817 .into_iter()
2818 .filter(|wanted| {
2819 !before
2820 .options
2821 .iter()
2822 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2823 })
2824 .collect::<Vec<_>>();
2825 let report = StatusOptionsReport {
2826 source: self.name.clone(),
2827 missing: missing.clone(),
2828 outcome: match (mode, missing.is_empty()) {
2829 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2830 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2831 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2832 },
2833 existing: before.options.clone(),
2834 };
2835 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2836 return Ok(report);
2837 }
2838 let mut options = before
2839 .options
2840 .iter()
2841 .map(|option| {
2842 json!({
2843 "id": option.id, "name": option.name, "color": option.color,
2844 "description": option.description,
2845 })
2846 })
2847 .collect::<Vec<_>>();
2848 options.extend(missing.iter().map(|name| {
2849 json!({
2850 "name": name, "color": "GRAY", "description": ""
2851 })
2852 }));
2853 self.graphql(
2854 graphql::STATUS_OPTIONS_UPDATE,
2855 json!({"input": {
2856 "projectId": before.board_id, "fieldId": before.field_id,
2857 "singleSelectOptions": options,
2858 }}),
2859 )
2860 .await?;
2861 let after = self.status_snapshot().await?;
2862 let options_preserved = before
2863 .options
2864 .iter()
2865 .all(|old| after.options.iter().any(|new| new == old));
2866 let additions_present = missing.iter().all(|wanted| {
2867 after
2868 .options
2869 .iter()
2870 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2871 });
2872 if !options_preserved || !additions_present || after.assignments != before.assignments {
2873 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2874 SourceError::Malformed {
2875 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2876 }
2877 })?;
2878 return Err(SourceError::Refused {
2879 message: format!(
2880 "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}"
2881 ),
2882 });
2883 }
2884 Ok(report)
2885 }
2886
2887 /// A fresh snapshot of the Status field and every board item's assignment of it.
2888 ///
2889 /// # Errors
2890 ///
2891 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2892 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2893 // Status alone, as this operation has always read it: a `Priority` field is another
2894 // operation's, so nothing about it can refuse this one.
2895 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2896 let field = board
2897 .fields
2898 .remove(&BoardField::Status)
2899 .ok_or_else(|| self.no_status_field())?;
2900 Ok(StatusSnapshot {
2901 assignments: board.assignments(BoardField::Status),
2902 board_id: board.board_id,
2903 field_id: field.field_id,
2904 options: field.options,
2905 })
2906 }
2907
2908 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2909 fn no_status_field(&self) -> SourceError {
2910 SourceError::Refused {
2911 message: format!("source {} board has no Status field", self.name),
2912 }
2913 }
2914
2915 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2916 // the real CLI loopback journey, including pagination. The individual malformed guards
2917 // are defensive validation of a schema-pinned third-party response, not separate user
2918 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2919 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2920 /// every board item's value of each, walked to the end of the board's items. A field not
2921 /// in `owned` is read past whatever it holds.
2922 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2923 let mut after: Option<String> = None;
2924 let mut snapshot: Option<BoardSnapshot> = None;
2925 loop {
2926 let data = self
2927 .graphql(
2928 graphql::STATUS_OPTIONS_SNAPSHOT,
2929 json!({
2930 "owner": self.owner, "number": self.project_number,
2931 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2932 }),
2933 )
2934 .await?;
2935 let board = data
2936 .pointer("/owner/projectV2")
2937 .filter(|board| board.is_object())
2938 .ok_or_else(|| SourceError::Refused {
2939 message: format!(
2940 "source {} has no accessible GitHub Projects board",
2941 self.name
2942 ),
2943 })?;
2944 if board
2945 .pointer("/fields/pageInfo/hasNextPage")
2946 .and_then(Value::as_bool)
2947 != Some(false)
2948 {
2949 return Err(SourceError::Malformed {
2950 message:
2951 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2952 .into(),
2953 });
2954 }
2955 let mut fields = BTreeMap::new();
2956 // Only the fields this setup owns, by name: a node the single-select fragment did not
2957 // match carries no name, and a person's own single-select field — a `Size`, a
2958 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2959 // `Status` or `Priority` field without its options is malformed, not absent.
2960 // 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.
2961 for (owned, field) in board
2962 .pointer("/fields/nodes")
2963 .and_then(Value::as_array)
2964 .ok_or_else(|| SourceError::Malformed {
2965 message: "GitHub project fields.nodes is not an array".into(),
2966 })?
2967 .iter()
2968 .filter_map(|field| {
2969 let named = BoardField::named(field.get("name")?.as_str()?)?;
2970 owned.contains(&named).then_some((named, field))
2971 })
2972 {
2973 let options = field
2974 .get("options")
2975 .and_then(Value::as_array)
2976 .ok_or_else(|| SourceError::Malformed {
2977 message: "GitHub single-select field options is not an array".into(),
2978 })?
2979 .iter()
2980 .map(|option| {
2981 Ok(StatusOption {
2982 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2983 .map_err(|message| SourceError::Malformed { message })?,
2984 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2985 .map_err(|message| SourceError::Malformed {
2986 message: format!(
2987 "GitHub single-select option name is invalid: {message}"
2988 ),
2989 })?,
2990 color: serde_json::from_value(
2991 option.get("color").cloned().unwrap_or(Value::Null),
2992 )
2993 .map_err(|error| {
2994 SourceError::Malformed {
2995 message: format!(
2996 "GitHub single-select option color is invalid: {error}"
2997 ),
2998 }
2999 })?,
3000 description: optional_str(option, "description")?
3001 .unwrap_or_default()
3002 .to_owned(),
3003 })
3004 })
3005 .collect::<Result<Vec<_>, SourceError>>()?;
3006 let snapshot = SnapshotField {
3007 field_id: required_nonblank_str(field, "id")?.to_owned(),
3008 options,
3009 };
3010 // A board's field names are unique, so a second one is an answer that cannot
3011 // say which field the setup would act on — refused rather than one chosen.
3012 if fields.insert(owned, snapshot).is_some() {
3013 return Err(SourceError::Malformed {
3014 message: format!(
3015 "GitHub answered two {} fields for this board",
3016 owned.name()
3017 ),
3018 });
3019 }
3020 }
3021 let board_id = required_nonblank_str(board, "id")?.to_owned();
3022 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
3023 board_id,
3024 fields,
3025 items: Vec::new(),
3026 });
3027 let items = board
3028 .pointer("/items/nodes")
3029 .and_then(Value::as_array)
3030 .ok_or_else(|| SourceError::Malformed {
3031 message: "GitHub project items.nodes is not an array".into(),
3032 })?;
3033 for item in items {
3034 let field_values =
3035 item.get("fieldValues")
3036 .ok_or_else(|| SourceError::Malformed {
3037 message: "GitHub project item is missing fieldValues".into(),
3038 })?;
3039 if field_values
3040 .pointer("/pageInfo/hasNextPage")
3041 .and_then(Value::as_bool)
3042 != Some(false)
3043 {
3044 return Err(SourceError::Malformed {
3045 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
3046 });
3047 }
3048 let values = item
3049 .pointer("/fieldValues/nodes")
3050 .and_then(Value::as_array)
3051 .ok_or_else(|| SourceError::Malformed {
3052 message: "GitHub project item fieldValues.nodes is not an array".into(),
3053 })?;
3054 let item_id = required_nonblank_str(item, "id")?;
3055 let mut assigned = BTreeMap::new();
3056 for value in values {
3057 let Some(field) = value
3058 .pointer("/field/name")
3059 .and_then(Value::as_str)
3060 .and_then(BoardField::named)
3061 .filter(|field| owned.contains(field))
3062 else {
3063 continue;
3064 };
3065 let held = assigned.insert(
3066 field,
3067 AssignedStatusOption {
3068 id: StatusOptionId::try_from(
3069 required_str(value, "optionId")?.to_owned(),
3070 )
3071 .map_err(|message| SourceError::Malformed { message })?,
3072 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
3073 .map_err(|message| SourceError::Malformed {
3074 message: format!(
3075 "GitHub assigned {} name is invalid: {message}",
3076 field.name()
3077 ),
3078 })?,
3079 },
3080 );
3081 // An item holds one value of a field, so a second one leaves no way to
3082 // tell which it holds — and a verification or recovery built on either
3083 // could restore the wrong one.
3084 if held.is_some() {
3085 return Err(SourceError::Malformed {
3086 message: format!(
3087 "GitHub answered two {} values for board item {item_id}",
3088 field.name()
3089 ),
3090 });
3091 }
3092 }
3093 current.items.push((item_id.to_owned(), assigned));
3094 }
3095 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
3096 message: "GitHub project is missing items".into(),
3097 })?;
3098 let has_next = page
3099 .pointer("/pageInfo/hasNextPage")
3100 .and_then(Value::as_bool)
3101 .ok_or_else(|| SourceError::Malformed {
3102 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3103 })?;
3104 if !has_next {
3105 break;
3106 }
3107 let next =
3108 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3109 validate_cursor_progress(after.as_deref(), next)?;
3110 after = Some(next.to_owned());
3111 }
3112 snapshot.ok_or_else(|| SourceError::Malformed {
3113 message: "GitHub returned no board field snapshot".into(),
3114 })
3115 }
3116 // llmlint: ignore-end[changed_behavior_has_e2e]
3117
3118 /// Report every board field this source's configuration names and, with
3119 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3120 /// the `Priority` field when the board has none.
3121 ///
3122 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3123 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3124 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3125 /// color and description: the whole option list goes back with every existing id, because
3126 /// a re-minted id clears every item's value.
3127 ///
3128 /// # Errors
3129 ///
3130 /// Refuses a board without a single-select `Status` field. After an apply the board is
3131 /// read again, and a pre-existing option or any item's value of either field that moved is
3132 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3133 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3134 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3135 // with no Status field and a non-github-projects source through the compiled CLI against
3136 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3137 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3138 let owned: Vec<BoardField> = if self.priorities.is_some() {
3139 vec![BoardField::Status, BoardField::Priority]
3140 } else {
3141 vec![BoardField::Status]
3142 };
3143 let before = self.board_snapshot(&owned).await?;
3144 let mut plans = vec![FieldPlan {
3145 field: BoardField::Status,
3146 wanted: self.statuses.wanted(),
3147 }];
3148 if !before.fields.contains_key(&BoardField::Status) {
3149 return Err(self.no_status_field());
3150 }
3151 if let Some(mapping) = &self.priorities {
3152 plans.push(FieldPlan {
3153 field: BoardField::Priority,
3154 wanted: mapping.names().map(str::to_owned).collect(),
3155 });
3156 }
3157 // The snapshot reads single-select fields alone, so a field it did not find may still
3158 // be on the board under the name, of another type: creating one beside it would fail
3159 // part way, or leave two fields of one name. Asked of the board's own field list, and
3160 // only when a field is missing.
3161 if plans
3162 .iter()
3163 .any(|plan| !before.fields.contains_key(&plan.field))
3164 {
3165 let board = self.board_fields().await?;
3166 for plan in plans
3167 .iter()
3168 .filter(|plan| !before.fields.contains_key(&plan.field))
3169 {
3170 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3171 return Err(SourceError::Refused {
3172 message: format!(
3173 "source {}'s board has a {} field that is not a single-select field \
3174 (it is a {}), so it cannot hold this source's options; next: rename \
3175 or remove that field, then run this again",
3176 self.name,
3177 plan.field.name(),
3178 optional_str(field, "__typename")?.unwrap_or("field of another type")
3179 ),
3180 });
3181 }
3182 }
3183 }
3184 let mut reports = Vec::new();
3185 for plan in &plans {
3186 let held = before.fields.get(&plan.field);
3187 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3188 let mut missing: Vec<String> = Vec::new();
3189 for wanted in &plan.wanted {
3190 let present = existing
3191 .iter()
3192 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3193 || missing
3194 .iter()
3195 .any(|named| named.eq_ignore_ascii_case(wanted));
3196 if !present {
3197 missing.push(wanted.clone());
3198 }
3199 }
3200 let kinds = match plan.field {
3201 BoardField::Status => self.statuses.missing_by_kind(&existing),
3202 BoardField::Priority => Vec::new(),
3203 };
3204 reports.push(FieldReport {
3205 field: plan.field,
3206 exists: held.is_some(),
3207 kinds,
3208 outcome: match (mode, held.is_some(), missing.is_empty()) {
3209 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3210 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3211 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3212 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3213 },
3214 missing,
3215 existing,
3216 });
3217 }
3218 let report = FieldsReport {
3219 source: self.name.clone(),
3220 fields: reports,
3221 };
3222 let writes: Vec<&FieldReport> = report
3223 .fields
3224 .iter()
3225 .filter(|field| !field.missing.is_empty() || !field.exists)
3226 .collect();
3227 if mode == SetupMode::Plan || writes.is_empty() {
3228 return Ok(report);
3229 }
3230 let mut landed: Vec<&str> = Vec::new();
3231 for field in &writes {
3232 let added = field
3233 .missing
3234 .iter()
3235 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3236 let sent = match before.fields.get(&field.field) {
3237 Some(held) => {
3238 let mut options = held
3239 .options
3240 .iter()
3241 .map(|option| {
3242 json!({
3243 "id": option.id, "name": option.name, "color": option.color,
3244 "description": option.description,
3245 })
3246 })
3247 .collect::<Vec<_>>();
3248 options.extend(added);
3249 self.graphql(
3250 graphql::STATUS_OPTIONS_UPDATE,
3251 json!({"input": {
3252 "projectId": before.board_id, "fieldId": held.field_id,
3253 "singleSelectOptions": options,
3254 }}),
3255 )
3256 .await
3257 }
3258 None => {
3259 self.graphql(
3260 graphql::CREATE_FIELD,
3261 json!({"input": {
3262 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3263 "name": field.field.name(),
3264 "singleSelectOptions": added.collect::<Vec<_>>(),
3265 }}),
3266 )
3267 .await
3268 }
3269 };
3270 // A mutation that failed does not establish that GitHub left its field as it was,
3271 // so every failure from here on carries the recovery data a drift refusal does.
3272 match sent {
3273 Ok(_) => landed.push(field.field.name()),
3274 Err(error) => {
3275 let changed = if landed.is_empty() {
3276 String::new()
3277 } else {
3278 format!("changed the {} field and then ", landed.join(" and "))
3279 };
3280 return Err(SourceError::Refused {
3281 message: format!(
3282 "the guarded field setup {changed}failed on the {} field, which it may \
3283 have changed part way: {error}; the pre-write item assignments \
3284 are:\n{}",
3285 field.field.name(),
3286 recovery(&report, &before)?
3287 ),
3288 });
3289 }
3290 }
3291 }
3292 // The board has been written, so a verification read that fails leaves it unverified
3293 // rather than unchanged, and says what to put back.
3294 let after = match self.board_snapshot(&owned).await {
3295 Ok(after) => after,
3296 Err(error) => {
3297 return Err(SourceError::Refused {
3298 message: format!(
3299 "the guarded field setup changed the {} field and then could not read the \
3300 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3301 landed.join(" and "),
3302 recovery(&report, &before)?
3303 ),
3304 });
3305 }
3306 };
3307 let mut moved = Vec::new();
3308 for field in &report.fields {
3309 let name = field.field.name();
3310 let now = after
3311 .fields
3312 .get(&field.field)
3313 .map(|held| held.options.as_slice())
3314 .unwrap_or_default();
3315 if !field.existing.iter().all(|old| now.contains(old)) {
3316 moved.push(format!(
3317 "a pre-existing {name} option id, name, color or description"
3318 ));
3319 }
3320 if !field.missing.iter().all(|wanted| {
3321 now.iter()
3322 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3323 }) {
3324 moved.push(format!("an added {name} option"));
3325 }
3326 if after.assignments(field.field) != before.assignments(field.field) {
3327 moved.push(format!("an item's {name} value"));
3328 }
3329 }
3330 if !moved.is_empty() {
3331 return Err(SourceError::Refused {
3332 message: format!(
3333 "GitHub changed {} after the guarded field setup; the pre-write item \
3334 assignments are:\n{}",
3335 moved.join(", "),
3336 recovery(&report, &before)?
3337 ),
3338 });
3339 }
3340 Ok(report)
3341 }
3342
3343 /// Validate configuration and capture the named credential without exposing it.
3344 ///
3345 /// # Errors
3346 ///
3347 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3348 /// [`SourceError::Auth`] when the named credential is missing or empty.
3349 pub fn new(
3350 name: &SourceName,
3351 config: GitHubProjectsConfig,
3352 secrets: &dyn SecretResolver,
3353 ) -> Result<Self, SourceError> {
3354 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3355 }
3356
3357 /// The same, recording every request it sends into an accounting the caller holds too.
3358 ///
3359 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3360 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3361 /// up — passes the one it records those into, so the session total accounts for the
3362 /// whole session rather than for this source's share of it.
3363 ///
3364 /// # Errors
3365 ///
3366 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3367 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3368 pub fn recording_into(
3369 name: &SourceName,
3370 config: GitHubProjectsConfig,
3371 secrets: &dyn SecretResolver,
3372 ledger: Arc<Accounting>,
3373 ) -> Result<Self, SourceError> {
3374 if !valid_github_owner(&config.owner) {
3375 return Err(SourceError::Config {
3376 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3377 });
3378 }
3379 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3380 return Err(SourceError::Config {
3381 message: format!("project_number must be between 1 and {}", i32::MAX),
3382 });
3383 }
3384 if !valid_environment_name(&config.token_env) {
3385 return Err(SourceError::Config {
3386 message: "token_env must be a valid environment-variable name".into(),
3387 });
3388 }
3389 let repository = config
3390 .repository
3391 .as_deref()
3392 .map(RepositoryTarget::parse)
3393 .transpose()?;
3394 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3395 message: format!("endpoint is not a valid URL: {e}"),
3396 })?;
3397 if endpoint.scheme() != "https"
3398 && !(endpoint.scheme() == "http"
3399 && endpoint
3400 .host_str()
3401 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3402 {
3403 return Err(SourceError::Config {
3404 message:
3405 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3406 .into(),
3407 });
3408 }
3409 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3410 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),
3411 })?;
3412 Ok(Self {
3413 name: name.clone(),
3414 owner: config.owner,
3415 project_number: config.project_number,
3416 repository,
3417 endpoint,
3418 token,
3419 credential_name: config.token_env,
3420 statuses: BoardStatuses::resolve(&config.status_mapping, name)?,
3421 priorities: config
3422 .priority_mapping
3423 .map(|mapping| PriorityMapping::resolve(mapping, name))
3424 .transpose()?,
3425 client: Client::builder()
3426 .user_agent("onetaskgraph")
3427 .build()
3428 .map_err(|e| SourceError::Config {
3429 message: format!("cannot build HTTP client: {e}"),
3430 })?,
3431 created: Mutex::new(Vec::new()),
3432 updated: Mutex::new(Vec::new()),
3433 pacing: Pacing::resolve(config.pacing, name)?,
3434 last_mutation: Mutex::new(None),
3435 board_cache: Mutex::new(None),
3436 search_cache: Mutex::new(None),
3437 narrowed_cache: Mutex::new(BTreeMap::new()),
3438 resolved_cache: Mutex::new(BTreeMap::new()),
3439 search_next: Mutex::new(BTreeMap::new()),
3440 fields_cache: Mutex::new(None),
3441 repository_cache: Mutex::new(BTreeMap::new()),
3442 ledger,
3443 })
3444 }
3445
3446 /// A snapshot of every request this source has sent, and what each cost.
3447 ///
3448 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3449 /// of them can sit side by side. When this source was built with
3450 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3451 /// point of building it that way.
3452 #[must_use]
3453 pub fn accounting(&self) -> accounting::Session {
3454 self.ledger.snapshot()
3455 }
3456
3457 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3458 /// rate limit rather than handing it straight back as an error.
3459 ///
3460 /// Retrying is safe for every document here, including the mutations, and the reason
3461 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3462 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3463 /// this replays has already taken effect. An outcome this source cannot know — the
3464 /// send failed, or the body could not be read, so the mutation may well have landed —
3465 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3466 /// attempt. A duplicate write would come from replaying one of those, and none is
3467 /// replayed.
3468 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3469 if is_mutation(query)
3470 && ![
3471 graphql::ADD_COMMENT,
3472 graphql::UPDATE_COMMENT,
3473 graphql::DELETE_COMMENT,
3474 ]
3475 .contains(&query)
3476 {
3477 let mut cache = self.resolved_cache()?;
3478 for argument in ["input", "second", "third", "clear"] {
3479 if let Some(input) = variables.get(argument) {
3480 cache.retain(|id, item| {
3481 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3482 input
3483 .get(key)
3484 .and_then(Value::as_str)
3485 .is_some_and(|value| value == id.0 || value == item.item_id)
3486 })
3487 });
3488 }
3489 }
3490 }
3491 let doing = operation_description(query);
3492 let mut waited = Duration::ZERO;
3493 let mut waits = 0_u32;
3494 let mut backoff = self.pacing.retry_backoff;
3495 loop {
3496 if is_mutation(query) {
3497 let spacing = self.reserve_mutation_slot();
3498 if !spacing.is_zero() {
3499 tokio::time::sleep(spacing).await;
3500 }
3501 }
3502 let attempt = self.send_once(query, &variables).await;
3503 if is_mutation(query) {
3504 self.finish_mutation();
3505 }
3506 let limited = match attempt {
3507 Ok(data) => return Ok(data),
3508 Err(Attempt::Failed(error)) => return Err(error),
3509 Err(Attempt::Limited(limited)) => limited,
3510 };
3511 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3512 // move that extends a secondary limit, so a hint below the schedule's own next
3513 // wait is raised to it.
3514 let wait = match limited.hint {
3515 Some(hint) => Duration::from_secs(hint).max(backoff),
3516 None => backoff,
3517 };
3518 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3519 // A wait of nothing spends none of the budget, so it is exhaustion rather
3520 // than a retry. `Pacing::resolve` rules out every way of configuring one
3521 // except a budget of zero, where reporting the first refusal is the ask.
3522 if wait.is_zero() || wait > remaining {
3523 return Err(limited.exhausted(
3524 doing,
3525 waits,
3526 waited,
3527 wait,
3528 self.pacing.retry_budget,
3529 ));
3530 }
3531 tokio::time::sleep(wait).await;
3532 waited += wait;
3533 waits += 1;
3534 backoff = backoff.saturating_mul(2);
3535 }
3536 }
3537
3538 /// The next moment a content-creating mutation may leave this source, as a wait from
3539 /// now.
3540 ///
3541 /// The slot is reserved under the lock and the waiting happens outside it, so two
3542 /// callers take two slots rather than the same one — and no lock is held across an
3543 /// await.
3544 ///
3545 /// The moment it is spaced from is the previous mutation's *completion*, which
3546 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3547 /// own is the wrong thing to measure from.
3548 fn reserve_mutation_slot(&self) -> Duration {
3549 if self.pacing.min_mutation_interval.is_zero() {
3550 return Duration::ZERO;
3551 }
3552 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3553 // it would turn an earlier failure into a second one for no gain.
3554 let mut last = self
3555 .last_mutation
3556 .lock()
3557 .unwrap_or_else(std::sync::PoisonError::into_inner);
3558 let now = Instant::now();
3559 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3560 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3561 let at = last.map_or(now, |previous| {
3562 previous
3563 .checked_add(self.pacing.min_mutation_interval)
3564 .map_or(now, |earliest| earliest.max(now))
3565 });
3566 *last = Some(at);
3567 at.saturating_duration_since(now)
3568 }
3569
3570 /// Record that a content-creating mutation has finished, so the next one is spaced
3571 /// from here rather than from the moment this one was released.
3572 ///
3573 /// This source can only choose when a request *departs*; the limiter counts when it
3574 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3575 /// departure from the last therefore hands the limiter a gap of the interval less that
3576 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3577 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3578 /// slower machine while passing on a quick one.
3579 ///
3580 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3581 /// previous request had already arrived before its response came back, so its arrival
3582 /// is no later than this moment, and the next mutation is released at least the
3583 /// interval after this moment and arrives no earlier than it is released: the gap the
3584 /// limiter measures is therefore at least the interval, whatever transit costs and on
3585 /// whatever platform. The price is that a mutation's own round trip no longer counts
3586 /// towards its spacing, which makes this source slightly slower than the configured
3587 /// rate rather than slightly faster — the safe side of a limit that punishes being
3588 /// wrong by refusing reads for the next fifty minutes.
3589 ///
3590 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3591 /// and one that never left costs only a wait nobody needed.
3592 fn finish_mutation(&self) {
3593 if self.pacing.min_mutation_interval.is_zero() {
3594 return;
3595 }
3596 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3597 let mut last = self
3598 .last_mutation
3599 .lock()
3600 .unwrap_or_else(std::sync::PoisonError::into_inner);
3601 let now = Instant::now();
3602 // `max` rather than an assignment: a concurrent caller may already have reserved a
3603 // slot further out, and completing this request must never pull that slot back in.
3604 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3605 }
3606
3607 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3608 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3609 ///
3610 /// This is the one place a request leaves this crate, which is why the accounting is
3611 /// here rather than at each of the callers: a read path added later is counted without
3612 /// anybody remembering to count it, and
3613 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3614 /// when one is not.
3615 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3616 let Attempted {
3617 result,
3618 limits,
3619 reported_cost,
3620 } = self.attempt(query, variables).await;
3621 // No `otherwise` name: every document this source sends is one of its own, and the
3622 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3623 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3624 let outcome = match &result {
3625 Ok(_) => accounting::Outcome::Answered,
3626 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3627 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3628 };
3629 self.ledger.record(sending.finished(outcome, limits));
3630 result
3631 }
3632
3633 /// The attempt itself, with what its response said about the rate limit alongside.
3634 ///
3635 /// The two are returned together rather than recorded here because every one of the
3636 /// early exits below is a different outcome, and a record written at each of them is a
3637 /// record one of them can be added without.
3638 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3639 let mut limits = accounting::RateLimit::default();
3640 let mut reported_cost = None;
3641 let result = self
3642 .attempted(query, variables, &mut limits, &mut reported_cost)
3643 .await;
3644 Attempted {
3645 result,
3646 limits,
3647 reported_cost,
3648 }
3649 }
3650
3651 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3652 async fn attempted(
3653 &self,
3654 query: &str,
3655 variables: &Value,
3656 limits: &mut accounting::RateLimit,
3657 reported_cost: &mut Option<u64>,
3658 ) -> Result<Value, Attempt> {
3659 let response = self
3660 .client
3661 .post(self.endpoint.clone())
3662 .bearer_auth(self.token.expose_secret())
3663 .json(&json!({"query": query, "variables": variables}))
3664 .send()
3665 .await
3666 .map_err(|e| {
3667 Attempt::Failed(SourceError::Unavailable {
3668 message: format!("GitHub GraphQL request failed: {e}"),
3669 })
3670 })?;
3671 let status = response.status();
3672 let header = |name: &str| whole_seconds(response.headers().get(name));
3673 *limits = accounting::RateLimit::read(|name| {
3674 response
3675 .headers()
3676 .get(name)
3677 .and_then(|value| value.to_str().ok())
3678 .map(str::to_owned)
3679 });
3680 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3681 // that are not text at all — is "not known to be exhausted". This never makes a
3682 // response a refusal on its own: it says which limiter a refusal is attributed to
3683 // and where its hint comes from, so a value this cannot read costs a hint rather
3684 // than an answer.
3685 let exhausted = response
3686 .headers()
3687 .get("x-ratelimit-remaining")
3688 .and_then(|value| value.to_str().ok())
3689 == Some("0");
3690 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3691 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3692 // which is the same question answered as an absolute time. Nothing else here is a
3693 // hint, and a schedule is what answers a refusal that carries none.
3694 let hint = header("retry-after").or_else(|| {
3695 exhausted
3696 .then(|| header("x-ratelimit-reset"))
3697 .flatten()
3698 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3699 });
3700 // Read before it is parsed, because the evidence which tells a secondary rate
3701 // limit from a rejected credential is in the body of a response whose status says
3702 // only "forbidden" — and a non-success response was never parsed at all.
3703 let body = response.text().await.map_err(|e| {
3704 Attempt::Failed(SourceError::Unavailable {
3705 message: format!("GitHub GraphQL response could not be read: {e}"),
3706 })
3707 })?;
3708 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3709 return Err(Attempt::Limited(Limited { limiter, hint }));
3710 }
3711 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3712 return Err(Attempt::Failed(SourceError::Auth {
3713 message: format!(
3714 "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"
3715 ),
3716 }));
3717 }
3718 if !status.is_success() {
3719 return Err(Attempt::Failed(SourceError::Unavailable {
3720 message: format!("GitHub GraphQL returned HTTP {status}"),
3721 }));
3722 }
3723 // GitHub reports what a call cost only when the document asked it to, and no
3724 // document this source sends does — so this is `None` here and carries the figure
3725 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3726 // pick up is a `dryRun` probe's cost, which is some other document's.
3727 *reported_cost = serde_json::from_str::<Value>(&body)
3728 .ok()
3729 .as_ref()
3730 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3731 .and_then(Value::as_u64);
3732 self.answer(&body).map_err(Attempt::Failed)
3733 }
3734
3735 /// What one successful HTTP response says, once its GraphQL errors are read.
3736 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3737 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3738 message: format!("GitHub returned invalid JSON: {e}"),
3739 })?;
3740 let errors = body
3741 .get("errors")
3742 .map(|value| {
3743 value.as_array().ok_or_else(|| SourceError::Malformed {
3744 message: "GitHub response errors is not an array".into(),
3745 })
3746 })
3747 .transpose()?;
3748 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3749 let messages = errors
3750 .iter()
3751 .filter_map(|e| e.get("message").and_then(Value::as_str))
3752 .collect::<Vec<_>>()
3753 .join("; ");
3754 let message = if messages.is_empty() {
3755 "GitHub returned GraphQL errors".into()
3756 } else {
3757 messages
3758 };
3759 let normalized = message.to_ascii_lowercase();
3760 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3761 return Err(SourceError::Auth {
3762 message: format!(
3763 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3764 self.credential_name
3765 ),
3766 });
3767 }
3768 return Err(SourceError::Refused { message });
3769 }
3770 body.get("data")
3771 .filter(|data| data.is_object())
3772 .cloned()
3773 .ok_or_else(|| SourceError::Malformed {
3774 message: "GitHub response has no data object".into(),
3775 })
3776 }
3777
3778 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3779 // GraphQL cannot independently page them inside the outer item page. This source page is
3780 // deliberately bounded at that published maximum; the live drift journey exercises it.
3781 async fn board_page(
3782 &self,
3783 items_after: Option<&str>,
3784 items_first: u32,
3785 ) -> Result<Value, SourceError> {
3786 let data = self
3787 .graphql(
3788 graphql::BOARD,
3789 json!({"owner":self.owner,"number":self.project_number,
3790 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3791 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3792 )
3793 .await?;
3794 data.pointer("/owner/projectV2")
3795 .filter(|v| !v.is_null())
3796 .cloned()
3797 .ok_or_else(|| SourceError::Refused {
3798 message: format!(
3799 "GitHub project {}/{} was not found or is not visible to the token",
3800 self.owner, self.project_number
3801 ),
3802 })
3803 }
3804
3805 /// The search that finds the issues of this board, narrowed by `also` when it is
3806 /// given.
3807 ///
3808 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3809 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3810 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3811 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3812 /// from a task by the `parent` field each issue carries rather than by the search.
3813 fn board_search(&self, also: Option<&str>) -> String {
3814 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3815 match also {
3816 Some(also) => format!("{scope} {also}"),
3817 None => scope,
3818 }
3819 }
3820
3821 /// One issue this source reached directly, as the board item a read of the board would
3822 /// have produced — or `None` when this board does not hold it.
3823 ///
3824 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3825 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3826 /// item's own id, that item's field values, and the issue as its content. One resolver
3827 /// for both routes is what makes an issue read through a search, through its own node
3828 /// id, or through its project's sub-issues report the same title, the same status, the
3829 /// same labels and the same qualified id.
3830 ///
3831 /// An issue with no entry for *this* board is not this source's to report, which is
3832 /// what keeps an id naming some other repository's issue from being answered as an item
3833 /// of this board. That answer is given about an **exhausted** connection and never
3834 /// about an unread page: the entry is looked for on the page in hand, and only if that
3835 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3836 /// rest of it.
3837 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3838 if optional_str(issue, "__typename")? != Some("Issue") {
3839 return Ok(None);
3840 }
3841 let memberships = issue
3842 .get("projectItems")
3843 .ok_or_else(|| SourceError::Malformed {
3844 message: "GitHub issue is missing projectItems".into(),
3845 })?;
3846 let nodes = memberships
3847 .get("nodes")
3848 .and_then(Value::as_array)
3849 .ok_or_else(|| SourceError::Malformed {
3850 message: "GitHub issue projectItems.nodes is not an array".into(),
3851 })?;
3852 let held = match self.board_entry(nodes) {
3853 Some(held) => held.clone(),
3854 None => {
3855 let info = memberships
3856 .get("pageInfo")
3857 .ok_or_else(|| SourceError::Malformed {
3858 message: "GitHub issue projectItems has no pageInfo".into(),
3859 })?;
3860 // The page held no entry for this board. Whether that means the issue is
3861 // not on it is a question about the rest of the connection, and only a
3862 // connection with no rest answers it here.
3863 if !required_bool(info, "hasNextPage")? {
3864 return Ok(None);
3865 }
3866 let cursor = required_str(info, "endCursor")?;
3867 validate_cursor_progress(None, cursor)?;
3868 let issue_id = required_str(issue, "id")?;
3869 match self.board_membership(issue_id, cursor).await? {
3870 Some(held) => held,
3871 None => return Ok(None),
3872 }
3873 }
3874 };
3875 let item = json!({
3876 "id": required_str(&held, "id")?,
3877 "project": held.get("project"),
3878 "fieldValues": held.get("fieldValues"),
3879 "content": issue,
3880 });
3881 self.resolve(&item)
3882 }
3883
3884 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3885 ///
3886 /// One spelling of *which membership is this board's*, so the page a read carries and
3887 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3888 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3889 nodes.iter().find(|node| {
3890 node.pointer("/project/number").and_then(Value::as_u64)
3891 == Some(u64::from(self.project_number))
3892 })
3893 }
3894
3895 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3896 ///
3897 /// The recovery read: a page of memberships that holds no entry for this board says
3898 /// nothing about the memberships past it, so the connection is walked to exhaustion
3899 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3900 /// positive answer — the whole connection was read and no entry named this board —
3901 /// rather than a failure, and the walk is held to
3902 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3903 /// with a cursor that does not advance is refused instead of spun on.
3904 async fn board_membership(
3905 &self,
3906 issue: &str,
3907 after: &str,
3908 ) -> Result<Option<Value>, SourceError> {
3909 let mut after = after.to_owned();
3910 loop {
3911 let data = self
3912 .graphql(
3913 graphql::ISSUE_BOARD_ITEMS,
3914 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3915 "nestedFirst":NESTED_PAGE_SIZE}),
3916 )
3917 .await?;
3918 let Some(connection) = data
3919 .pointer("/node/projectItems")
3920 .filter(|value| !value.is_null())
3921 else {
3922 // The id resolved to nothing, or to something with no memberships to walk —
3923 // which is the same answer as a connection holding no entry for this board.
3924 return Ok(None);
3925 };
3926 let nodes = connection
3927 .get("nodes")
3928 .and_then(Value::as_array)
3929 .ok_or_else(|| SourceError::Malformed {
3930 message: "GitHub issue projectItems.nodes is not an array".into(),
3931 })?;
3932 if let Some(held) = self.board_entry(nodes) {
3933 return Ok(Some(held.clone()));
3934 }
3935 let info = connection
3936 .get("pageInfo")
3937 .ok_or_else(|| SourceError::Malformed {
3938 message: "GitHub issue projectItems has no pageInfo".into(),
3939 })?;
3940 let next = required_bool(info, "hasNextPage")?
3941 .then(|| required_str(info, "endCursor"))
3942 .transpose()?;
3943 match next {
3944 Some(next) => {
3945 validate_cursor_progress(Some(&after), next)?;
3946 after = next.to_owned();
3947 }
3948 None => return Ok(None),
3949 }
3950 }
3951 }
3952
3953 /// One page of a board-scoped issue search, and where the next page resumes.
3954 async fn search_page(
3955 &self,
3956 search: &str,
3957 first: u32,
3958 after: Option<&str>,
3959 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3960 let data = self
3961 .graphql(
3962 graphql::SEARCH_ISSUES,
3963 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3964 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3965 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3966 )
3967 .await?;
3968 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3969 message: "GitHub search response has no search connection".into(),
3970 })?;
3971 let mut found = Vec::new();
3972 for node in connection
3973 .get("nodes")
3974 .and_then(Value::as_array)
3975 .ok_or_else(|| SourceError::Malformed {
3976 message: "GitHub search nodes is not an array".into(),
3977 })?
3978 {
3979 if let Some(resolved) = self.resolve_issue(node).await? {
3980 found.push(resolved);
3981 }
3982 }
3983 let info = connection
3984 .get("pageInfo")
3985 .ok_or_else(|| SourceError::Malformed {
3986 message: "GitHub search connection has no pageInfo".into(),
3987 })?;
3988 let next = required_bool(info, "hasNextPage")?
3989 .then(|| required_str(info, "endCursor"))
3990 .transpose()?
3991 .map(str::to_owned);
3992 if let Some(next) = &next {
3993 validate_cursor_progress(after, next)?;
3994 }
3995 Ok((found, next))
3996 }
3997
3998 /// Every issue this board holds, completed with what this run wrote.
3999 ///
4000 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
4001 /// is an index and is eventually consistent, so an issue this run created seconds ago
4002 /// can be absent from it, and a project listed straight after being written would
4003 /// otherwise be missing from its own board. What is added back is only what this
4004 /// process itself wrote, out of [`Self::created`], which lives and dies with the
4005 /// process.
4006 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4007 let found = self.searched_issues().await?;
4008 self.completed_with_written(found, |_| true)
4009 }
4010
4011 /// Every issue this board's own search reports, walked to exhaustion, read once per
4012 /// source.
4013 ///
4014 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
4015 /// needs it too and the two would otherwise walk the same search twice in one command.
4016 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
4017 /// is.
4018 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
4019 let cached = self.search_cache()?.clone();
4020 if let Some(held) = cached {
4021 return Ok(held);
4022 }
4023 let mut after: Option<String> = None;
4024 let mut found = Vec::new();
4025 let search = self.board_search(None);
4026 loop {
4027 let (page, next) = self
4028 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
4029 .await?;
4030 found.extend(page);
4031 match next {
4032 Some(next) => after = Some(next),
4033 None => break,
4034 }
4035 }
4036 *self.search_cache()? = Some(found.clone());
4037 Ok(found)
4038 }
4039
4040 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
4041 fn search_cache(
4042 &self,
4043 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
4044 self.search_cache
4045 .lock()
4046 .map_err(|_| SourceError::Unavailable {
4047 message: "this source's view of the board's issues was left inconsistent by an \
4048 earlier failure; next: run the command again"
4049 .into(),
4050 })
4051 }
4052
4053 /// `found`, with everything this run wrote that `keep` accepts and the read did not
4054 /// report.
4055 ///
4056 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
4057 /// at all: the search index is behind, and a node read of an item filed moments ago can
4058 /// be too.
4059 fn completed_with_written(
4060 &self,
4061 mut found: Vec<Resolved>,
4062 keep: impl Fn(&Resolved) -> bool,
4063 ) -> Result<Vec<Resolved>, SourceError> {
4064 for own in self.created()?.iter().filter(|own| keep(own)) {
4065 if !found.iter().any(|item| item.id == own.id) {
4066 found.push(own.clone());
4067 }
4068 }
4069 Ok(found)
4070 }
4071
4072 /// What resolving one node id reached.
4073 ///
4074 /// Three answers rather than an `Option`, because a board *draft* is none of the other
4075 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
4076 /// is completed by a read of the draft itself rather than reported as nothing.
4077 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
4078 let asked = self
4079 .graphql(
4080 graphql::ISSUE,
4081 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
4082 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4083 )
4084 .await;
4085 let data = match asked {
4086 Ok(data) => data,
4087 // A string that is not a node id at all is not a failure to report: it is an id
4088 // this board does not hold, which is what every read of one already answers.
4089 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
4090 Err(error) => return Err(error),
4091 };
4092 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
4093 return Ok(Reached::Nothing);
4094 };
4095 if optional_str(node, "__typename")? == Some("DraftIssue") {
4096 return Ok(Reached::Draft);
4097 }
4098 Ok(match self.resolve_issue(node).await? {
4099 Some(item) => Reached::Held(Box::new(item)),
4100 None => Reached::Nothing,
4101 })
4102 }
4103
4104 /// One item of this board by its own id, whatever kind it is.
4105 ///
4106 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4107 /// run wrote is read first, because a node read of an item created moments ago can
4108 /// still be behind the board field values written onto it — see [`Self::created`].
4109 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4110 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4111 return Ok(Some(own.clone()));
4112 }
4113 match self.reach(id).await? {
4114 Reached::Held(item) => Ok(Some(*item)),
4115 Reached::Nothing => Ok(None),
4116 Reached::Draft => self.draft_by_id(id).await,
4117 }
4118 }
4119
4120 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4121 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4122 /// than one request per id.
4123 ///
4124 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4125 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4126 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4127 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4128 /// is completed by a read of the draft, exactly as there.
4129 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4130 let mut found: Vec<Option<Option<Resolved>>> = {
4131 let created = self.created()?;
4132 ids.iter()
4133 .map(|id| {
4134 created
4135 .iter()
4136 .find(|own| own.id == *id)
4137 .map(|own| Some(own.clone()))
4138 })
4139 .collect()
4140 };
4141 let unread: Vec<NativeId> = ids
4142 .iter()
4143 .zip(&found)
4144 .filter(|(_, found)| found.is_none())
4145 .map(|(id, _)| id.clone())
4146 .collect();
4147 let mut read = Vec::with_capacity(unread.len());
4148 if let [one] = unread.as_slice() {
4149 read.push(self.item_by_id(one).await?);
4150 } else {
4151 for batch in unread.chunks(DETAIL_BATCH) {
4152 let data = match self
4153 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4154 .await
4155 {
4156 Ok(data) => data,
4157 Err(error) if unresolvable_node(&error) => {
4158 for id in batch {
4159 read.push(self.item_by_id(id).await?);
4160 }
4161 continue;
4162 }
4163 Err(error) => return Err(error),
4164 };
4165 for (slot, id) in batch.iter().enumerate() {
4166 let node =
4167 data.get(format!("i{slot}"))
4168 .ok_or_else(|| SourceError::Malformed {
4169 message: format!(
4170 "GitHub answered a batch read with no item for {}",
4171 id.0
4172 ),
4173 })?;
4174 read.push(if node.is_null() {
4175 None
4176 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4177 self.draft_by_id(id).await?
4178 } else {
4179 if optional_str(node, "__typename")? == Some("Issue")
4180 && required_str(node, "id")? != id.0
4181 {
4182 return Err(SourceError::Malformed {
4183 message: format!(
4184 "GitHub answered the read of {} with issue {}",
4185 id.0,
4186 required_str(node, "id")?
4187 ),
4188 });
4189 }
4190 self.resolve_issue(node).await?
4191 });
4192 }
4193 }
4194 }
4195 let mut read = read.into_iter();
4196 Ok(found
4197 .iter_mut()
4198 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4199 .collect())
4200 }
4201
4202 fn resolved_cache(
4203 &self,
4204 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4205 self.resolved_cache
4206 .lock()
4207 .map_err(|_| SourceError::Unavailable {
4208 message: "resolved item records were left inconsistent; run the command again"
4209 .into(),
4210 })
4211 }
4212
4213 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4214 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4215 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4216 let cached = self.resolved_cache()?.get(id).cloned();
4217 match cached {
4218 Some(item) => Ok(Some(item)),
4219 None => self.item_by_id(id).await,
4220 }
4221 }
4222
4223 /// One board draft by its own id, with the board item it sits in — or `None` when no
4224 /// item of this board is that draft's.
4225 ///
4226 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4227 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4228 /// links a draft to one board item, so the page this read carries is the whole of that
4229 /// connection, and a page that reports more than it holds is refused rather than read
4230 /// as an answer about memberships nobody read.
4231 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4232 let data = self
4233 .graphql(
4234 graphql::DRAFT,
4235 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4236 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4237 )
4238 .await?;
4239 // Gone between the two reads is an answer — the draft is no longer there. Anything
4240 // else than the draft [`Self::reach`] was just told this id is, is not one.
4241 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4242 return Ok(None);
4243 };
4244 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4245 return Err(SourceError::Malformed {
4246 message: format!(
4247 "GitHub answered {} as a draft and then as something else",
4248 id.0
4249 ),
4250 });
4251 }
4252 if required_str(draft, "id")? != id.0 {
4253 return Err(SourceError::Malformed {
4254 message: format!("GitHub answered a different draft for {}", id.0),
4255 });
4256 }
4257 let memberships = draft
4258 .get("projectV2Items")
4259 .ok_or_else(|| SourceError::Malformed {
4260 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4261 })?;
4262 let nodes = memberships
4263 .get("nodes")
4264 .and_then(Value::as_array)
4265 .ok_or_else(|| SourceError::Malformed {
4266 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4267 })?;
4268 let info = memberships
4269 .get("pageInfo")
4270 .ok_or_else(|| SourceError::Malformed {
4271 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4272 })?;
4273 // Read whether or not this board's entry is on the page: a page claiming more than
4274 // the one item GitHub links a draft to is a malformed answer either way.
4275 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4276 return Err(SourceError::Malformed {
4277 message: format!(
4278 "GitHub draft {} reports more board items than the one GitHub links a draft \
4279 to",
4280 id.0
4281 ),
4282 });
4283 }
4284 if let Some(node) = nodes.first()
4285 && node
4286 .pointer("/project/number")
4287 .and_then(Value::as_u64)
4288 .is_none()
4289 {
4290 return Err(SourceError::Malformed {
4291 message: format!(
4292 "GitHub draft {} board item has no numeric project number",
4293 id.0
4294 ),
4295 });
4296 }
4297 let Some(held) = self.board_entry(nodes) else {
4298 return Ok(None);
4299 };
4300 if required_str(
4301 held.get("project").ok_or_else(|| SourceError::Malformed {
4302 message: format!("GitHub draft {} board item has no project", id.0),
4303 })?,
4304 "id",
4305 )? != self.board_fields().await?.id.as_str()
4306 {
4307 return Ok(None);
4308 }
4309 let item = json!({
4310 "id": required_str(held, "id")?,
4311 "project": held.get("project"),
4312 "fieldValues": held.get("fieldValues"),
4313 "content": draft,
4314 });
4315 self.resolve(&item)
4316 }
4317
4318 /// The board's own id and field definitions, for a write whose item does not carry
4319 /// them — never its items.
4320 ///
4321 /// A board this command has already listed supplies them, since it read them beside its
4322 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4323 /// is consulted about which items the board holds: see the module documentation for
4324 /// why a question about one known item is answered by reading that item.
4325 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4326 if let Some(board) = self.board_cache()?.as_ref() {
4327 return Ok(BoardFields {
4328 id: BoardId::parse(&board.id)?,
4329 fields: board.fields.clone(),
4330 });
4331 }
4332 if let Some(held) = self.fields_cache()?.clone() {
4333 return Ok(held);
4334 }
4335 let data = self
4336 .graphql(
4337 graphql::BOARD_FIELDS,
4338 json!({"owner":self.owner,"number":self.project_number,
4339 "nestedFirst":NESTED_PAGE_SIZE}),
4340 )
4341 .await?;
4342 self.fields_read(&data)
4343 }
4344
4345 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4346 /// the rest of this command.
4347 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4348 let board = data
4349 .pointer("/boardFields/projectV2")
4350 .filter(|value| !value.is_null())
4351 .ok_or_else(|| SourceError::Refused {
4352 message: format!(
4353 "GitHub project {}/{} was not found or is not visible to the token",
4354 self.owner, self.project_number
4355 ),
4356 })?;
4357 let read = BoardFields {
4358 id: BoardId::parse(required_str(board, "id")?)?,
4359 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4360 };
4361 *self.fields_cache()? = Some(read.clone());
4362 Ok(read)
4363 }
4364
4365 /// Read what creating an issue in `repository` needs and this command has not read yet —
4366 /// the board's fields and the repository's node id — in one request when it needs both.
4367 ///
4368 /// When either is already known this sends nothing, and the other is read by its own
4369 /// document where it is asked for, so no create reads anything twice.
4370 async fn creation_context(
4371 &self,
4372 repository: &RepositoryTarget,
4373 incoming: &Incoming<'_>,
4374 ) -> Result<(), SourceError> {
4375 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4376 if fields_known || self.repository_cache()?.contains_key(repository) {
4377 return Ok(());
4378 }
4379 let data = self
4380 .graphql(
4381 graphql::CREATION_CONTEXT,
4382 json!({"owner":self.owner,"number":self.project_number,
4383 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4384 "repositoryName":repository.name}),
4385 )
4386 .await?;
4387 self.fields_read(&data)?;
4388 self.repository_read(&data, repository, incoming)?;
4389 Ok(())
4390 }
4391
4392 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4393 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4394 self.fields_cache
4395 .lock()
4396 .map_err(|_| SourceError::Unavailable {
4397 message: "this source's view of the board's fields was left inconsistent by an \
4398 earlier failure; next: run the command again"
4399 .into(),
4400 })
4401 }
4402
4403 /// What a write to `item` needs of the board, read off that item when it says enough and
4404 /// off [`Self::board_fields`] when it does not.
4405 ///
4406 /// A node read of an item names its board and carries the definition of every field it
4407 /// holds a value of — so an item naming its board, holding a value of the origin field,
4408 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4409 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4410 /// of may still be on the board, and a view reading it as absent would refuse a write the
4411 /// board can take or skip a field write the board needs, so such an item — and a create,
4412 /// which has no item yet — takes the board's fields from their own read instead.
4413 async fn fields_for(
4414 &self,
4415 item: Option<&Resolved>,
4416 writes_status: bool,
4417 selects_priority: bool,
4418 ) -> Result<BoardFields, SourceError> {
4419 if let Some(board) = item.and_then(Resolved::carried_board) {
4420 return Ok(board);
4421 }
4422 if let Some(item) = item
4423 && let Some(board_id) = item.named_board()
4424 && item.defines(ORIGIN_FIELD)
4425 && (!writes_status || item.defines("Status"))
4426 && (!selects_priority || item.defines(PRIORITY_FIELD))
4427 {
4428 return Ok(BoardFields {
4429 id: board_id,
4430 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4431 });
4432 }
4433 self.board_fields().await
4434 }
4435
4436 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4437 /// when that id names nothing here with a sub-issue relationship to walk.
4438 ///
4439 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4440 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4441 /// empty vector is a project that holds nothing.
4442 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4443 let mut after: Option<String> = None;
4444 let mut children = Vec::new();
4445 loop {
4446 let asked = self
4447 .graphql(
4448 graphql::SUB_ISSUES,
4449 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4450 "nestedFirst":NESTED_PAGE_SIZE,
4451 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4452 )
4453 .await;
4454 let data = match asked {
4455 Ok(data) => data,
4456 // A string that is not a node id at all is not a failure to report: it is
4457 // the ordinary answer to a selector naming a project by its name.
4458 Err(error) if unresolvable_node(&error) => return Ok(None),
4459 Err(error) => return Err(error),
4460 };
4461 let Some(connection) = data
4462 .pointer("/node/subIssues")
4463 .filter(|value| !value.is_null())
4464 else {
4465 // No such node, or one with no sub-issue relationship — a board draft is
4466 // the one this board can really hold.
4467 return Ok(None);
4468 };
4469 for node in connection
4470 .get("nodes")
4471 .and_then(Value::as_array)
4472 .ok_or_else(|| SourceError::Malformed {
4473 message: "GitHub subIssues.nodes is not an array".into(),
4474 })?
4475 {
4476 if let Some(resolved) = self.resolve_issue(node).await? {
4477 children.push(resolved);
4478 }
4479 }
4480 let info = connection
4481 .get("pageInfo")
4482 .ok_or_else(|| SourceError::Malformed {
4483 message: "GitHub subIssues connection has no pageInfo".into(),
4484 })?;
4485 let next = required_bool(info, "hasNextPage")?
4486 .then(|| required_str(info, "endCursor"))
4487 .transpose()?;
4488 match next {
4489 Some(next) => {
4490 validate_cursor_progress(after.as_deref(), next)?;
4491 after = Some(next.to_owned());
4492 }
4493 None => return Ok(Some(children)),
4494 }
4495 }
4496 }
4497
4498 /// Which issue of this board a project *name* is, or `None` when none is.
4499 ///
4500 /// One bounded query which filters on that name at the server, rather than a walk of
4501 /// every issue the board holds. The name is compared again here: the qualifier narrows
4502 /// what GitHub sends, and this source decides what it names.
4503 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4504 let search = self.board_search(Some(&title_qualifier(name)));
4505 let mut after = None;
4506 loop {
4507 let (candidates, next) = self
4508 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4509 .await?;
4510 if let Some(item) = candidates.into_iter().find(|item| {
4511 item.kind == BoardKind::Work(ItemKind::Project)
4512 && item.title.eq_ignore_ascii_case(name)
4513 }) {
4514 return Ok(Some(item.id));
4515 }
4516 match next {
4517 Some(next) => after = Some(next),
4518 None => return Ok(None),
4519 }
4520 }
4521 }
4522
4523 /// Everything filed under one project of this board: the sub-issues of the issue that
4524 /// project is.
4525 ///
4526 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4527 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4528 /// gains projects, or as another project gains tasks.
4529 ///
4530 /// A qualified id names the issue and is asked for its sub-issues directly: one
4531 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4532 /// read as a project *name*, which costs the one bounded search
4533 /// [`Self::project_by_name`] makes.
4534 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4535 let (project, children) = match self.sub_issues(selector).await? {
4536 Some(children) => (selector.clone(), children),
4537 None => match self.project_by_name(&selector.0).await? {
4538 Some(project) => {
4539 let children = self.sub_issues(&project).await?.unwrap_or_default();
4540 (project, children)
4541 }
4542 None => return Ok(Vec::new()),
4543 },
4544 };
4545 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4546 }
4547
4548 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4549 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4550 ///
4551 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4552 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4553 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4554 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4555 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4556 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4557 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4558 /// rather than silently narrowing a caller's answer.
4559 ///
4560 /// The instant is written to the second, rounded down, which can only widen what the
4561 /// search returns; confirmation against each candidate's own comments is what makes the
4562 /// answer exact. The search is an index that lags a write by a second or two — the module
4563 /// documentation records it — so a caller that asks again from its last instant should
4564 /// overlap the two by more than that.
4565 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4566 let found = self.searched(&updated_qualifier(since)).await?;
4567 self.completed_with_written(found, |_| true)
4568 }
4569
4570 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4571 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4572 ///
4573 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4574 /// own record is the fresher of the two.
4575 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4576 let search = self.board_search(Some(also));
4577 let mut after: Option<String> = None;
4578 let mut found = Vec::new();
4579 loop {
4580 let (page, next) = self
4581 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4582 .await?;
4583 found.extend(page);
4584 match next {
4585 Some(next) => after = Some(next),
4586 None => return Ok(found),
4587 }
4588 }
4589 }
4590
4591 /// A bounded task answer; the versioned cursor carries the connection position, how
4592 /// many rows of the page starting there were already handed out, and the own-write ids
4593 /// already observed, including across a new source instance.
4594 ///
4595 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4596 /// sliced from the pages it needs; why is the module documentation's paging contract.
4597 async fn search_tasks(
4598 &self,
4599 query: &TaskQuery,
4600 page: &PageRequest,
4601 also: &str,
4602 ) -> Result<Page<Task>, SourceError> {
4603 let mut position = match &page.cursor {
4604 None => SearchPosition::default(),
4605 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4606 .ok()
4607 .filter(|position| {
4608 position.version == SEARCH_CURSOR_VERSION
4609 && position.connection.valid_resume(position.offset)
4610 })
4611 .ok_or_else(|| SourceError::Config {
4612 message: "page cursor is invalid".into(),
4613 })?,
4614 };
4615 let search = self.board_search(Some(also));
4616 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4617 let own = self.with_own_writes(Vec::new())?;
4618 for item in &own {
4619 if !position.own.contains(&item.id) {
4620 position.own.push(item.id.clone());
4621 }
4622 }
4623 let mut tasks = Vec::new();
4624 while !position.connection.exhausted() && tasks.len() < limit {
4625 let first = SEARCH_PAGE_SIZE;
4626 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4627 let key =
4628 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4629 .expect("search page key is serializable");
4630 let cached = if query.commented_since.is_none() {
4631 self.narrowed_cache()?.get(&key).cloned()
4632 } else {
4633 None
4634 };
4635 let (found, next) = match cached {
4636 Some(found) => {
4637 let next = self
4638 .search_next
4639 .lock()
4640 .map_err(|_| SourceError::Unavailable {
4641 message:
4642 "search pagination was left inconsistent; run the command again"
4643 .into(),
4644 })?
4645 .get(&key)
4646 .cloned()
4647 .flatten();
4648 (found, next)
4649 }
4650 None => {
4651 let (found, next) = self
4652 .search_page(&search, first, position.connection.after())
4653 .await?;
4654 if query.commented_since.is_none() {
4655 self.search_next
4656 .lock()
4657 .map_err(|_| SourceError::Unavailable {
4658 message:
4659 "search pagination was left inconsistent; run the command again"
4660 .into(),
4661 })?
4662 .insert(key.clone(), next.clone());
4663 self.narrowed_cache()?.insert(key, found.clone());
4664 }
4665 (found, next)
4666 }
4667 };
4668 let rows = found.len();
4669 for mut item in found.into_iter().skip(position.offset) {
4670 if tasks.len() == limit {
4671 break;
4672 }
4673 position.offset += 1;
4674 if position.own.contains(&item.id) {
4675 if position.seen.contains(&item.id) {
4676 continue;
4677 }
4678 position.seen.push(item.id.clone());
4679 let updated_at = item.updated_at;
4680 let Some(written) = self.search_written(&own, &item.id).await? else {
4681 continue;
4682 };
4683 item = written;
4684 item.updated_at = item.updated_at.max(updated_at);
4685 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4686 }
4687 if item.kind == BoardKind::Work(ItemKind::Task) {
4688 let task = item.task()?;
4689 if task_matches(&task, query, &query.project)
4690 && self.commented_since(&item, query.commented_since).await?
4691 {
4692 tasks.push(task);
4693 }
4694 }
4695 }
4696 if position.offset < rows {
4697 continue;
4698 }
4699 position.offset = 0;
4700 position.connection = match next {
4701 Some(after) => SearchConnection::Continuing {
4702 after: Cursor(after),
4703 },
4704 None => SearchConnection::Exhausted {},
4705 };
4706 }
4707 if position.connection.exhausted() {
4708 for id in position.own.clone() {
4709 if position.seen.contains(&id) {
4710 continue;
4711 }
4712 if tasks.len() == limit {
4713 break;
4714 }
4715 position.seen.push(id.clone());
4716 let Some(item) = self.search_written(&own, &id).await? else {
4717 continue;
4718 };
4719 if item.kind == BoardKind::Work(ItemKind::Task) {
4720 let task = item.task()?;
4721 if task_matches(&task, query, &query.project)
4722 && self.commented_since(&item, query.commented_since).await?
4723 {
4724 tasks.push(task);
4725 }
4726 }
4727 }
4728 }
4729 let more = !position.connection.exhausted()
4730 || position.own.iter().any(|id| !position.seen.contains(id));
4731 Ok(Page {
4732 items: tasks,
4733 next: more.then(|| {
4734 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4735 }),
4736 })
4737 }
4738
4739 /// A resumed process has the ids but no write records; resolve only a record the
4740 /// current page needs, by its uncached node read rather than the lagging search index.
4741 async fn search_written(
4742 &self,
4743 own: &[Resolved],
4744 id: &NativeId,
4745 ) -> Result<Option<Resolved>, SourceError> {
4746 match own.iter().find(|item| item.id == *id) {
4747 Some(item) => Ok(Some(item.clone())),
4748 None => self.item_by_id(id).await,
4749 }
4750 }
4751
4752 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4753 /// without enumerating the board — or `None` for a query carrying none of the three, which
4754 /// keeps the reads it always had.
4755 ///
4756 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4757 /// because it names at most a handful of items. Text and metadata are answered by one
4758 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4759 /// further by `updated:>=` when the query also asks for comment activity, since both
4760 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4761 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4762 ///
4763 /// Completed with what this process wrote, its own record winning over the index's copy
4764 /// of the same item: see [`Self::with_own_writes`].
4765 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4766 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4767 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4768 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4769 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4770 None => qualifiers,
4771 }),
4772 (None, None) => return Ok(None),
4773 };
4774 // A question about comment activity is asked afresh every time, as it always was: it
4775 // is the one a caller polls from one source while waiting for the index, and an
4776 // answer held from the first poll would be the answer to every later one.
4777 let key = query.commented_since.is_none().then(|| asked.key());
4778 let cached = match &key {
4779 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4780 None => None,
4781 };
4782 let found = match cached {
4783 Some(found) => found,
4784 None => {
4785 let found = match &asked {
4786 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4787 Narrowing::Search(also) => self.searched(also).await?,
4788 };
4789 if let Some(key) = key {
4790 self.narrowed_cache()?.insert(key, found.clone());
4791 }
4792 found
4793 }
4794 };
4795 self.with_own_writes(found).map(Some)
4796 }
4797
4798 /// The candidates for a project or unscoped document query carrying a searchable text,
4799 /// read without enumerating the board — or `None` for a query with no text or a blank one,
4800 /// which keeps the read it always had.
4801 ///
4802 /// The text is sent as the very phrase a task query's text is — see [`text_qualifiers`] —
4803 /// in one board-scoped issue search walked to its end at [`SEARCH_PAGE_SIZE`], so what it
4804 /// costs is the issues that match and never the board. Its answer is held for the command
4805 /// under the same key [`Self::narrowed`] holds that search under, so a walk of the caller's
4806 /// pages asks GitHub once. Every candidate is confirmed afterwards by its kind and by the
4807 /// substring rule, exactly as an item of the wider read was, and is completed with what this
4808 /// process wrote: see [`Self::with_own_writes`].
4809 async fn text_searched(
4810 &self,
4811 text: Option<&TextQuery>,
4812 ) -> Result<Option<Vec<Resolved>>, SourceError> {
4813 let Some(also) = text_qualifiers(text) else {
4814 return Ok(None);
4815 };
4816 let key = Narrowing::Search(also.clone()).key();
4817 let cached = self.narrowed_cache()?.get(&key).cloned();
4818 let found = match cached {
4819 Some(found) => found,
4820 None => {
4821 let found = self.searched(&also).await?;
4822 self.narrowed_cache()?.insert(key, found.clone());
4823 found
4824 }
4825 };
4826 self.with_own_writes(found).map(Some)
4827 }
4828
4829 /// Every item of this board that may carry `origin` — a superset of those that do — found
4830 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4831 ///
4832 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4833 /// which reads the field every carrier holds, whichever release wrote it — and the
4834 /// board-scoped issue search for the same id as a phrase in the body, where this source
4835 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4836 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4837 /// query's, exactly.
4838 ///
4839 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4840 /// already ended is sent its last cursor again, which answers an empty page, so the one
4841 /// document serves every page of either. What the two leave is stated in the module
4842 /// documentation: a carrier another process added within the last second or two, before
4843 /// either index has it.
4844 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4845 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4846 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4847 let mut items_after: Option<String> = None;
4848 let mut search_after: Option<String> = None;
4849 let mut found: Vec<Resolved> = Vec::new();
4850 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4851 if !found.iter().any(|held| held.id == resolved.id) {
4852 found.push(resolved);
4853 }
4854 };
4855 loop {
4856 let data = self
4857 .graphql(
4858 graphql::ORIGIN_LOOKUP,
4859 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4860 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4861 "itemsAfter":items_after,"searchAfter":search_after,
4862 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4863 "duplicates":true}),
4864 )
4865 .await?;
4866 let items = data
4867 .pointer("/originItems/projectV2/items")
4868 .filter(|value| !value.is_null())
4869 .ok_or_else(|| SourceError::Refused {
4870 message: format!(
4871 "GitHub project {}/{} was not found or is not visible to the token",
4872 self.owner, self.project_number
4873 ),
4874 })?;
4875 for item in optional_nodes(Some(items), "project items")?
4876 .into_iter()
4877 .flatten()
4878 {
4879 // The board's own items list its drafts too, and a draft is not an issue: no
4880 // narrowed read answers with one, whatever its origin field holds.
4881 if let Some(resolved) = self.resolve(item)?
4882 && resolved.content_kind == ContentKind::Issue
4883 {
4884 keep(resolved, &mut found);
4885 }
4886 }
4887 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4888 message: "GitHub search response has no search connection".into(),
4889 })?;
4890 for node in optional_nodes(Some(searched), "search")?
4891 .into_iter()
4892 .flatten()
4893 {
4894 if let Some(resolved) = self.resolve_issue(node).await? {
4895 keep(resolved, &mut found);
4896 }
4897 }
4898 let items_next = resumed(items, items_after.as_deref())?;
4899 let search_next = resumed(searched, search_after.as_deref())?;
4900 if !items_next.has_more() && !search_next.has_more() {
4901 return Ok(found);
4902 }
4903 items_after = items_next.cursor();
4904 search_after = search_next.cursor();
4905 }
4906 }
4907
4908 /// `found`, with every item this process created or wrote in its place, and every one of
4909 /// them the read did not report added.
4910 ///
4911 /// This process's own record wins over the read's copy of the same item, because a read
4912 /// of an item written moments ago can still be behind what was written onto it — the
4913 /// origin field included, which is the one a narrowed read is confirmed against — and a
4914 /// read that still names an item under a predicate this process's write moved it out of
4915 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4916 /// last saw the item change, which is what a comment-activity read rules a candidate out
4917 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4918 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4919 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4920 // A board draft is not an issue, so no narrowed read returns one, and this process
4921 // having written one does not make it an answer either.
4922 let own: Vec<Resolved> = self
4923 .created()?
4924 .iter()
4925 .chain(self.updated()?.iter())
4926 .filter(|own| own.content_kind == ContentKind::Issue)
4927 .cloned()
4928 .collect();
4929 for mut own in own {
4930 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4931 match found.iter_mut().find(|read| read.id == own.id) {
4932 Some(read) => {
4933 own.updated_at = own.updated_at.max(read.updated_at);
4934 *read = own;
4935 }
4936 None => found.push(own),
4937 }
4938 }
4939 Ok(found)
4940 }
4941
4942 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4943 /// there is no instant to hold it to.
4944 ///
4945 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4946 /// or after the instant moved it there: an issue not updated since holds no such comment,
4947 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4948 /// only as far as the first that matches. A board draft is not an issue and has no
4949 /// comments, so it never matches.
4950 async fn commented_since(
4951 &self,
4952 item: &Resolved,
4953 since: Option<DateTime<Utc>>,
4954 ) -> Result<bool, SourceError> {
4955 let Some(since) = since else {
4956 return Ok(true);
4957 };
4958 if item.content_kind == ContentKind::DraftIssue
4959 || item.updated_at.is_some_and(|updated| updated < since)
4960 {
4961 return Ok(false);
4962 }
4963 let query = TaskQuery {
4964 commented_since: Some(since),
4965 ..TaskQuery::default()
4966 };
4967 let mut after: Option<String> = None;
4968 loop {
4969 let data = self
4970 .graphql(
4971 graphql::ISSUE_COMMENTS,
4972 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4973 )
4974 .await?;
4975 let Some(connection) = data
4976 .get("node")
4977 .filter(|value| !value.is_null())
4978 .and_then(|node| node.get("comments"))
4979 .filter(|value| !value.is_null())
4980 else {
4981 // Removed since the search reported it: no longer an issue with comments.
4982 return Ok(false);
4983 };
4984 let comments = optional_nodes(Some(connection), "issue comments")?
4985 .into_iter()
4986 .flatten()
4987 .map(comment_from)
4988 .collect::<Result<Vec<_>, _>>()?;
4989 if query.comments_match(&comments) {
4990 return Ok(true);
4991 }
4992 match next_cursor(connection)? {
4993 Some(next) => {
4994 validate_cursor_progress(after.as_deref(), &next.0)?;
4995 after = Some(next.0);
4996 }
4997 None => return Ok(false),
4998 }
4999 }
5000 }
5001
5002 /// Every item on the board: the union of both enumerations GitHub offers of one.
5003 ///
5004 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
5005 /// board **draft** and reads the board's own fields beside its items, and only the search
5006 /// reports an item that connection is behind on. The module documentation is where the lag and the
5007 /// measurements behind it are written down.
5008 ///
5009 /// A search result is admitted on the same terms as any other issue this source reaches
5010 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
5011 /// names *this* board — so an issue the index still believes is here after it was taken
5012 /// off is refused rather than reported.
5013 ///
5014 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
5015 /// which is what the cache could otherwise have broken.
5016 async fn board(&self) -> Result<Board, SourceError> {
5017 let cached = self.board_cache()?.clone();
5018 let mut board = match cached {
5019 Some(board) => board,
5020 None => {
5021 let read = self.read_board().await?;
5022 *self.board_cache()? = Some(read.clone());
5023 read
5024 }
5025 };
5026 for held in self.searched_issues().await? {
5027 if !board.items.iter().any(|item| item.id == held.id) {
5028 board.items.push(held);
5029 }
5030 }
5031 for own in self.created()?.iter() {
5032 if !board.items.iter().any(|item| item.id == own.id) {
5033 board.items.push(own.clone());
5034 }
5035 }
5036 Ok(board)
5037 }
5038
5039 /// This process's own view of the board, or the refusal a poisoned lock is.
5040 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
5041 self.board_cache
5042 .lock()
5043 .map_err(|_| SourceError::Unavailable {
5044 message: "this source's view of the board was left inconsistent by an earlier \
5045 failure; next: run the command again"
5046 .into(),
5047 })
5048 }
5049
5050 /// Bring this process's own view of the board up to an item it has just written.
5051 ///
5052 /// A created item goes to `created`, which is what completes a board read GitHub's own
5053 /// eventual consistency has left behind. An item that was already there is replaced
5054 /// where it sits, so a second write of it in the same command reads its real parent
5055 /// rather than the one it had before the first write.
5056 ///
5057 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
5058 /// that wins: an item this same run created is held in `created` and not in the cached
5059 /// board, and `board` completes the cached board *from* `created`, so replacing only
5060 /// the cached copy of such an item replaces nothing and the read still reports the
5061 /// title it was created with. The search is the third, and it is the one an item the
5062 /// board's own projection is behind on sits in *alone* — which is exactly the item this
5063 /// source is least able to re-read, so leaving it out would put the stale title back on
5064 /// the only items the completion in [`Self::board`] exists for.
5065 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
5066 self.resolved_cache()?.insert(item.id.clone(), item.clone());
5067 if created {
5068 self.created()?.push(item);
5069 return Ok(());
5070 }
5071 {
5072 let mut own = self.created()?;
5073 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
5074 *held = item;
5075 return Ok(());
5076 }
5077 }
5078 {
5079 let mut own = self.updated()?;
5080 match own.iter_mut().find(|held| held.id == item.id) {
5081 Some(held) => *held = item.clone(),
5082 None => own.push(item.clone()),
5083 }
5084 }
5085 if let Some(board) = self.board_cache()?.as_mut()
5086 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
5087 {
5088 *held = item.clone();
5089 }
5090 if let Some(found) = self.search_cache()?.as_mut()
5091 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
5092 {
5093 *held = item.clone();
5094 }
5095 for found in self.narrowed_cache()?.values_mut() {
5096 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
5097 *held = item.clone();
5098 }
5099 }
5100 Ok(())
5101 }
5102
5103 /// Forget one item this process has just deleted, from every half of its own view.
5104 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
5105 self.resolved_cache()?.remove(id);
5106 self.created()?.retain(|own| own.id != *id);
5107 self.updated()?.retain(|own| own.id != *id);
5108 if let Some(board) = self.board_cache()?.as_mut() {
5109 board.items.retain(|item| item.id != *id);
5110 }
5111 if let Some(found) = self.search_cache()?.as_mut() {
5112 found.retain(|item| item.id != *id);
5113 }
5114 for found in self.narrowed_cache()?.values_mut() {
5115 found.retain(|item| item.id != *id);
5116 }
5117 Ok(())
5118 }
5119
5120 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
5121 fn narrowed_cache(
5122 &self,
5123 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
5124 self.narrowed_cache
5125 .lock()
5126 .map_err(|_| SourceError::Unavailable {
5127 message: "this source's view of a narrowed read was left inconsistent by an \
5128 earlier failure; next: run the command again"
5129 .into(),
5130 })
5131 }
5132
5133 /// Every page of the board, read from GitHub.
5134 async fn read_board(&self) -> Result<Board, SourceError> {
5135 let mut after: Option<String> = None;
5136 let mut items = Vec::new();
5137 let mut board;
5138 loop {
5139 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5140 for item in page
5141 .pointer("/items/nodes")
5142 .and_then(Value::as_array)
5143 .ok_or_else(|| SourceError::Malformed {
5144 message: "GitHub project items.nodes is not an array".into(),
5145 })?
5146 {
5147 if let Some(resolved) = self.resolve(item)? {
5148 items.push(resolved);
5149 }
5150 }
5151 let info = page
5152 .pointer("/items/pageInfo")
5153 .ok_or_else(|| SourceError::Malformed {
5154 message: "GitHub project items have no pageInfo".into(),
5155 })?;
5156 let has_next = required_bool(info, "hasNextPage")?;
5157 let next = has_next
5158 .then(|| required_str(info, "endCursor"))
5159 .transpose()?;
5160 board = page.clone();
5161 match next {
5162 Some(next) => {
5163 validate_cursor_progress(after.as_deref(), next)?;
5164 after = Some(next.to_owned());
5165 }
5166 None => break,
5167 }
5168 }
5169 Ok(Board {
5170 id: required_str(&board, "id")?.to_owned(),
5171 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5172 items,
5173 })
5174 }
5175
5176 /// The existing items this source has written, for completing a narrowed read that is
5177 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5178 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5179 self.updated.lock().map_err(|_| SourceError::Unavailable {
5180 message: "this source's record of what it wrote in this run was left inconsistent \
5181 by an earlier failure; next: run the command again"
5182 .into(),
5183 })
5184 }
5185
5186 /// The items this source has created, for completing a board read that is behind.
5187 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5188 self.created.lock().map_err(|_| SourceError::Unavailable {
5189 message: "this source's record of what it created in this run was left \
5190 inconsistent by an earlier failure; next: run the command again"
5191 .into(),
5192 })
5193 }
5194
5195 /// One board item as this source reports it, or `None` for content it ignores.
5196 ///
5197 /// A pull request is neither a project nor a task — it is somebody's change, not a
5198 /// unit of plan — and an item whose content the token cannot see has nothing to
5199 /// report at all.
5200 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5201 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5202 message: "GitHub project item is missing content".into(),
5203 })?;
5204 if content.is_null() {
5205 return Ok(None);
5206 }
5207 let content_kind = match required_str(content, "__typename")? {
5208 "Issue" => ContentKind::Issue,
5209 "DraftIssue" => ContentKind::DraftIssue,
5210 _ => return Ok(None),
5211 };
5212 let field_values = item
5213 .get("fieldValues")
5214 .ok_or_else(|| SourceError::Malformed {
5215 message: "GitHub project item is missing fieldValues".into(),
5216 })?;
5217 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5218 let nodes = field_values
5219 .get("nodes")
5220 .and_then(Value::as_array)
5221 .ok_or_else(|| SourceError::Malformed {
5222 message: "GitHub project item fieldValues.nodes is not an array".into(),
5223 })?;
5224 if let Some(labels) = content.get("labels") {
5225 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5226 }
5227 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5228 let (body, slot) = metadata_body(raw_body.clone())?;
5229 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5230 .map(|id| NativeId(id.to_owned()));
5231 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5232 // to read one from; it is a task, and never a project.
5233 let sub_issues = match content_kind {
5234 ContentKind::Issue => sub_issue_total(content)?,
5235 ContentKind::DraftIssue => 0,
5236 };
5237 let content_id = required_str(content, "id")?;
5238 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5239 message: format!("GitHub issue {content_id}: {message}"),
5240 })?;
5241 let raw_title = required_str(content, "title")?;
5242 // The design prefix is read *first*, before either of the two rules that separate
5243 // a project from a task. A document is not work whatever sub-issues it has and
5244 // whatever marker it carries, and reading the prefix later would make a design
5245 // issue with none of either an empty project.
5246 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5247 BoardKind::Document
5248 } else if parent.is_some() {
5249 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5250 // under a project is that project's task even when it has sub-issues of its
5251 // own.
5252 BoardKind::Work(ItemKind::Task)
5253 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5254 BoardKind::Work(ItemKind::Project)
5255 } else {
5256 BoardKind::Work(ItemKind::Task)
5257 };
5258 // The title a person wrote, which for a document is the one without the prefix —
5259 // the same way `content` above is the body without this source's metadata slot.
5260 let title = match kind {
5261 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5262 BoardKind::Work(_) => raw_title.to_owned(),
5263 };
5264 let own_repository = content
5265 .pointer("/repository/nameWithOwner")
5266 .and_then(Value::as_str)
5267 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5268 .transpose()
5269 .map_err(|message| SourceError::Malformed { message })?;
5270 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5271 Repository::from_metadata(&slot)
5272 .map_err(|message| SourceError::Malformed { message })?
5273 } else {
5274 own_repository.clone().into_iter().collect()
5275 };
5276 let id = NativeId(content_id.to_owned());
5277 // Read only for a task, because only a task has either list: a project or a
5278 // document holding one of these keys holds nothing this source reports, and the
5279 // keys are left out of its caller-visible metadata all the same.
5280 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5281 let listed = |key: &str| {
5282 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5283 .map_err(|message| SourceError::Malformed { message })
5284 };
5285 (
5286 listed(TaskRef::DELIVERS_KEY)?,
5287 listed(TaskRef::DELIVERED_BY_KEY)?,
5288 )
5289 } else {
5290 (Vec::new(), Vec::new())
5291 };
5292 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5293 let priority = self.held_priority(nodes)?;
5294 // Present when the item was reached through its own issue, whose board entry
5295 // names the board; a read of the board's own items has the board already. An
5296 // empty id names nothing a field write could address, so it is read as absent and
5297 // the write goes back to reading the board.
5298 let board_id = item
5299 .pointer("/project/id")
5300 .and_then(Value::as_str)
5301 .filter(|id| !id.is_empty());
5302 let resolved = Resolved {
5303 item_id: required_str(item, "id")?.to_owned(),
5304 id,
5305 content_kind,
5306 kind,
5307 title,
5308 body: body.filter(|value| !value.is_empty()),
5309 raw_body,
5310 status: self
5311 .statuses
5312 .status(kind.status_kind(), option, closed, reason),
5313 option: option.map(str::to_owned),
5314 priority,
5315 closed,
5316 delivers,
5317 delivered_by,
5318 labels: labels(content)?,
5319 parent,
5320 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5321 number: match content_kind {
5322 ContentKind::Issue => Some(issue_number(content)?),
5323 // A draft is filed in no repository, so nothing ever numbered it:
5324 // `DraftIssue` declares no `number` at all, exactly as it declares no
5325 // `subIssuesSummary` the branch above reads.
5326 ContentKind::DraftIssue => None,
5327 },
5328 url: optional_str(content, "url")?.map(str::to_owned),
5329 created_at: optional_time(content, "createdAt")?,
5330 updated_at: optional_time(content, "updatedAt")?,
5331 own_repository,
5332 repositories,
5333 slot,
5334 board_id: board_id.map(str::to_owned),
5335 fields: field_definitions(nodes),
5336 board_fields: Self::carried_board_fields(content, board_id)?,
5337 blocked_by: carried_blocked_by(content)?,
5338 };
5339 self.resolved_cache()?
5340 .insert(resolved.id.clone(), resolved.clone());
5341 Ok(Some(resolved))
5342 }
5343
5344 /// The field definitions of the board `board_id` names — the project this issue's own
5345 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5346 /// `None` when the read carried none, carried no entry for that board, or the board item
5347 /// named no board, which a write then answers by reading the board's fields itself.
5348 ///
5349 /// Matched by the board's node id and never by its number alone: a project number is
5350 /// unique only within its owner, so another owner's board numbered alike can sit on the
5351 /// same page, and its field and option ids address nothing on this one.
5352 fn carried_board_fields(
5353 content: &Value,
5354 board_id: Option<&str>,
5355 ) -> Result<Option<Value>, SourceError> {
5356 let (Some(nodes), Some(board_id)) = (
5357 content.pointer("/boards/nodes").and_then(Value::as_array),
5358 board_id,
5359 ) else {
5360 return Ok(None);
5361 };
5362 let Some(board) = nodes.iter().find_map(|node| {
5363 let project = node.get("project")?;
5364 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5365 }) else {
5366 return Ok(None);
5367 };
5368 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5369 return Ok(None);
5370 };
5371 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5372 Ok(Some(fields.clone()))
5373 }
5374
5375 /// What one board item's `Priority` field says, through this instance's mapping.
5376 ///
5377 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5378 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5379 /// option the mapping does not name is kept as itself — never read as a level or as
5380 /// `none` — for a read of the task to report by name.
5381 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5382 let Some(mapping) = &self.priorities else {
5383 return Ok(HeldPriority::Read(Priority::None));
5384 };
5385 // A value of the field that names no option — a text field someone called `Priority` —
5386 // is malformed rather than `none`: reading it as no priority would let the next copy
5387 // clear one a person set.
5388 let Some(option) = field_values
5389 .iter()
5390 .find(|value| {
5391 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5392 })
5393 .map(|value| required_str(value, "name"))
5394 .transpose()?
5395 else {
5396 return Ok(HeldPriority::Read(Priority::None));
5397 };
5398 Ok(mapping.priority_of(option).map_or_else(
5399 || HeldPriority::Unmapped(option.to_owned()),
5400 HeldPriority::Read,
5401 ))
5402 }
5403
5404 /// What one board item's status is read from: its `Status` option, whether its issue
5405 /// is closed, and the reason it was closed with. [`BoardStatuses::status`] turns the
5406 /// three into the status it reports.
5407 fn status_parts<'a>(
5408 field_values: &'a [Value],
5409 content: &'a Value,
5410 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5411 let option = field_values
5412 .iter()
5413 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5414 .map(|value| required_str(value, "name"))
5415 .transpose()?;
5416 let closed = optional_str(content, "state")? == Some("CLOSED");
5417 Ok((option, closed, optional_str(content, "stateReason")?))
5418 }
5419
5420 /// The board Status option this write selects, or the refusal that says why not.
5421 ///
5422 /// The mapped option is required for both open and terminal targets. A terminal write
5423 /// validates it before changing either representation, so it can never fall back to
5424 /// closing an issue whose board cannot display the matching status.
5425 ///
5426 /// Answers the field's id, the option's id, and the option's name as the board spells
5427 /// it — which is the name a read of the item reports once it sits there.
5428 fn column_for(
5429 &self,
5430 fields: &Value,
5431 kind: ItemKind,
5432 category: StatusCategory,
5433 target: &StatusTarget,
5434 ) -> Result<Option<(String, String, String)>, SourceError> {
5435 let Some(wanted) = target.option() else {
5436 return Ok(None);
5437 };
5438 let missing = |detail: &str| SourceError::Refused {
5439 message: format!(
5440 "{} status {} of source {} needs the board Status option {wanted:?}, and \
5441 {detail}; next: add that option to the board, which `onetaskgraph sources \
5442 fields {} --apply` does, or point status_mapping.{}.{} of this source at one \
5443 it has",
5444 kind.marker(),
5445 category_name(category),
5446 self.name,
5447 self.name,
5448 category_name(category),
5449 kind.marker()
5450 ),
5451 };
5452 let Some(field) = Board::field(fields, "Status")? else {
5453 return Err(missing("this board has no Status field"));
5454 };
5455 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5456 return Err(missing(
5457 "this board's Status field is not a single-select field",
5458 ));
5459 }
5460 let option = field
5461 .get("options")
5462 .and_then(Value::as_array)
5463 .and_then(|options| {
5464 options.iter().find(|option| {
5465 option
5466 .get("name")
5467 .and_then(Value::as_str)
5468 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5469 })
5470 });
5471 match option {
5472 None => Err(missing("this board does not have it")),
5473 Some(option) => Ok(Some((
5474 required_str(field, "id")?.to_owned(),
5475 required_str(option, "id")?.to_owned(),
5476 required_str(option, "name")?.to_owned(),
5477 ))),
5478 }
5479 }
5480
5481 /// The refusal a status that closes an issue is answered with over a board draft.
5482 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5483 SourceError::Refused {
5484 message: format!(
5485 "status {} of source {} closes the item's issue, and GitHub draft items have \
5486 no open or closed state",
5487 category_name(category),
5488 self.name
5489 ),
5490 }
5491 }
5492
5493 /// What a status write to one item needs of the board: the board's id and the
5494 /// definition of its `Status` field, read off the item when the item says both.
5495 ///
5496 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5497 /// and its `Status` value carries that field's definition, options and all. An item that
5498 /// does not say — no board id, or no `Status` value to read the field off — takes them
5499 /// from [`Self::board_fields`], which reads no item.
5500 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5501 if let Some(board) = item.carried_board() {
5502 return Ok(board);
5503 }
5504 if item.defines("Status")
5505 && let Some(board_id) = item.named_board()
5506 {
5507 return Ok(BoardFields {
5508 id: board_id,
5509 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5510 });
5511 }
5512 self.board_fields().await
5513 }
5514
5515 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5516 async fn set_status(
5517 &self,
5518 id: &NativeId,
5519 category: StatusCategory,
5520 ) -> Result<Option<Status>, SourceError> {
5521 // Refused before anything is read, in the words a write of the same status is.
5522 let target = self.resolved_target(ItemKind::Task, category)?;
5523 let Some(mut item) = self
5524 .bound_item(id)
5525 .await?
5526 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5527 else {
5528 return Ok(None);
5529 };
5530 let board = self.status_board(&item).await?;
5531 let (field, option, name) = self
5532 .column_for(&board.fields, ItemKind::Task, category, &target)?
5533 .ok_or_else(|| SourceError::Malformed {
5534 message: format!(
5535 "status {} of source {} names no board Status option",
5536 category_name(category),
5537 self.name
5538 ),
5539 })?;
5540 if item.status.category == category && item.option.as_deref() == Some(&name) {
5541 return Ok(Some(item.status));
5542 }
5543 match &target {
5544 StatusTarget::Terminal(_, reason) => {
5545 if item.content_kind == ContentKind::DraftIssue {
5546 return Err(self.closes_a_draft(category));
5547 }
5548 self.set_item_field(
5549 board.id.as_str(),
5550 &item.item_id,
5551 &field,
5552 json!({"singleSelectOptionId": option}),
5553 )
5554 .await?;
5555 self.update_content(
5556 ContentKind::Issue,
5557 &item.id,
5558 json!({"stateInput": state_input(Some(&target))}),
5559 )
5560 .await?;
5561 item.closed = true;
5562 item.status =
5563 self.statuses
5564 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()));
5565 item.option = Some(name);
5566 }
5567 StatusTarget::Column(_) => {
5568 // An option is what an open item's status is, so a closed issue is reopened
5569 // first — sitting closed in the column, it would read back as closed. A draft has
5570 // no state to reopen.
5571 if item.content_kind == ContentKind::Issue && item.closed {
5572 self.update_content(
5573 ContentKind::Issue,
5574 &item.id,
5575 json!({"stateInput": state_input(Some(&target))}),
5576 )
5577 .await?;
5578 item.closed = false;
5579 }
5580 self.set_item_field(
5581 board.id.as_str(),
5582 &item.item_id,
5583 &field,
5584 json!({"singleSelectOptionId": option}),
5585 )
5586 .await?;
5587 item.status = self
5588 .statuses
5589 .status(ItemKind::Task, Some(&name), false, None);
5590 item.option = Some(name);
5591 }
5592 StatusTarget::Disabled(_) => {
5593 unreachable!("resolved_target refused a disabled status")
5594 }
5595 }
5596 let status = item.status.clone();
5597 self.remember_written(item, false)?;
5598 Ok(Some(status))
5599 }
5600
5601 /// Replace one task's `delivered_by` and nothing else; see
5602 /// [`TaskSource::set_delivered_by`].
5603 ///
5604 /// One update of the body, which differs from the body GitHub holds only inside the
5605 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5606 async fn replace_delivered_by(
5607 &self,
5608 id: &NativeId,
5609 delivered_by: &[TaskRef],
5610 ) -> Result<Option<()>, SourceError> {
5611 let entries = TaskRef::listed(
5612 TaskRef::DELIVERED_BY_KEY,
5613 id,
5614 Some(&self.name),
5615 delivered_by.to_vec(),
5616 )
5617 .map_err(|message| SourceError::Refused { message })?;
5618 let Some(mut item) = self
5619 .bound_item(id)
5620 .await?
5621 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5622 else {
5623 return Ok(None);
5624 };
5625 let mut slot = item.slot.clone();
5626 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5627 self.write_slot(&mut item, &slot).await?;
5628 item.delivered_by = entries;
5629 self.remember_written(item, false)?;
5630 Ok(Some(()))
5631 }
5632
5633 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5634 /// see [`TaskSource::set_task_metadata`].
5635 ///
5636 /// `None` when this board holds no item by that id, or holds one of another kind. The
5637 /// answer is the item as this source now reads it, so what a caller is told the key
5638 /// holds is what the slot holds.
5639 ///
5640 /// A key already holding the value is answered without a write, compared as JSON rather
5641 /// than as the body's bytes: a slot a person spelled with other whitespace would
5642 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5643 async fn set_slot_key(
5644 &self,
5645 id: &NativeId,
5646 kind: BoardKind,
5647 key: &MetadataKey,
5648 value: &Value,
5649 ) -> Result<Option<Resolved>, SourceError> {
5650 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5651 return Ok(None);
5652 };
5653 if item.slot.get(key.as_str()) == Some(value) {
5654 return Ok(Some(item));
5655 }
5656 let mut slot = item.slot.clone();
5657 slot.insert(key.as_str().to_owned(), value.clone());
5658 self.write_slot(&mut item, &slot).await?;
5659 self.remember_written(item.clone(), false)?;
5660 Ok(Some(item))
5661 }
5662
5663 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5664 /// `item` up to what that write left.
5665 ///
5666 /// The body sent differs from the body GitHub holds only inside the slot — see
5667 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5668 /// the mutation the item's content takes, so a board draft's body is written with
5669 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5670 async fn write_slot(
5671 &self,
5672 item: &mut Resolved,
5673 slot: &BTreeMap<String, Value>,
5674 ) -> Result<(), SourceError> {
5675 let held = item.raw_body.clone().unwrap_or_default();
5676 let body = with_slot(&held, slot)?;
5677 if body != held {
5678 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5679 .await?;
5680 }
5681 let (visible, slot) = metadata_body(Some(body.clone()))?;
5682 item.body = visible.filter(|value| !value.is_empty());
5683 item.raw_body = Some(body);
5684 item.slot = slot;
5685 Ok(())
5686 }
5687
5688 /// This instance's target for a category written to an item of `kind`, refusing one
5689 /// that kind has no option for — before anything is read or written.
5690 ///
5691 /// Nothing here mutates the board's option set to make room for a status. GitHub
5692 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5693 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5694 /// field and every item's status.
5695 fn resolved_target(
5696 &self,
5697 kind: ItemKind,
5698 category: StatusCategory,
5699 ) -> Result<StatusTarget, SourceError> {
5700 let target = self.statuses.target(kind, category).clone();
5701 let StatusTarget::Disabled(why) = target else {
5702 return Ok(target);
5703 };
5704 let refusal = why.refusal(&self.name, category, kind);
5705 // Why there is no shipped default, which is the question a person meeting this
5706 // refusal on a source that never mentioned the category asks.
5707 let shipped_none = match category {
5708 StatusCategory::Draft => Some(
5709 "draft has no shipped default because GitHub draft issues cannot have \
5710 sub-issues, and this source stores a project's tasks as its issue's sub-issues",
5711 ),
5712 StatusCategory::Unknown => Some(
5713 "unknown has no shipped default because this board keeps no open-ended status \
5714 word: every word classified unknown is written to the one board Status option \
5715 status_mapping.unknown names",
5716 ),
5717 _ => None,
5718 };
5719 Err(match (refusal, shipped_none, why) {
5720 (SourceError::Refused { message }, Some(note), UnmappedStatus::Unconfigured) => {
5721 SourceError::Refused {
5722 message: format!("{message}; {note}"),
5723 }
5724 }
5725 (refusal, _, _) => refusal,
5726 })
5727 }
5728
5729 /// What writing `priority` does to one item's `Priority` field on this board, or the
5730 /// refusal naming what the board lacks.
5731 ///
5732 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5733 /// none already, or of an item not created yet. Every other priority selects the option
5734 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5735 /// without that option, is refused rather than given one: reads and writes never create
5736 /// a field or an option.
5737 fn priority_write(
5738 &self,
5739 fields: &Value,
5740 existing: Option<&Resolved>,
5741 priority: Priority,
5742 ) -> Result<Option<PriorityWrite>, SourceError> {
5743 let Some(mapping) = &self.priorities else {
5744 return Err(self.holds_no_priority());
5745 };
5746 let Some(wanted) = mapping.option(priority) else {
5747 if !existing.is_some_and(Resolved::holds_priority) {
5748 return Ok(None);
5749 }
5750 let field =
5751 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5752 message: format!(
5753 "an item holding a {PRIORITY_FIELD} value was read without that field"
5754 ),
5755 })?;
5756 return Ok(Some(PriorityWrite::Clear {
5757 field: required_str(field, "id")?.to_owned(),
5758 }));
5759 };
5760 let missing = |detail: &str| SourceError::Refused {
5761 message: format!(
5762 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5763 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5764 it, or point priority_mapping.{priority} of this source at an option the board \
5765 has",
5766 self.name, self.name
5767 ),
5768 };
5769 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5770 return Err(missing(&format!(
5771 "this board has no {PRIORITY_FIELD} field"
5772 )));
5773 };
5774 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5775 return Err(missing(&format!(
5776 "this board's {PRIORITY_FIELD} field is not a single-select field"
5777 )));
5778 }
5779 // An options list that is absent or not a list is an answer this source cannot read,
5780 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5781 let option = field
5782 .get("options")
5783 .and_then(Value::as_array)
5784 .ok_or_else(|| SourceError::Malformed {
5785 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5786 })?
5787 .iter()
5788 .find(|option| {
5789 option
5790 .get("name")
5791 .and_then(Value::as_str)
5792 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5793 })
5794 .ok_or_else(|| missing("this board does not have it"))?;
5795 Ok(Some(PriorityWrite::Select {
5796 field: required_str(field, "id")?.to_owned(),
5797 option: required_str(option, "id")?.to_owned(),
5798 }))
5799 }
5800
5801 /// Apply one priority write to one board item.
5802 async fn write_priority(
5803 &self,
5804 board_id: &str,
5805 item_id: &str,
5806 write: &PriorityWrite,
5807 ) -> Result<(), SourceError> {
5808 match write {
5809 PriorityWrite::Select { field, option } => {
5810 self.set_item_field(
5811 board_id,
5812 item_id,
5813 field,
5814 json!({"singleSelectOptionId": option}),
5815 )
5816 .await
5817 }
5818 PriorityWrite::Clear { field } => {
5819 let data = self
5820 .graphql(
5821 graphql::CLEAR_FIELD,
5822 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5823 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5824 )
5825 .await?;
5826 let returned = data
5827 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5828 .ok_or_else(|| SourceError::Malformed {
5829 message: "GitHub field clear returned no project item".into(),
5830 })?;
5831 if required_str(returned, "id")? != item_id {
5832 return Err(SourceError::Malformed {
5833 message: "GitHub field clear returned the wrong project item".into(),
5834 });
5835 }
5836 Ok(())
5837 }
5838 }
5839 }
5840
5841 /// The refusal a priority is answered with by an instance configured with no
5842 /// `priority_mapping`, which holds none.
5843 fn holds_no_priority(&self) -> SourceError {
5844 SourceError::Refused {
5845 message: format!(
5846 "source {} holds no task priority: its configuration sets no priority_mapping; \
5847 next: set priority_mapping on this source, then run `onetaskgraph sources \
5848 fields {} --apply` to set its board up",
5849 self.name, self.name
5850 ),
5851 }
5852 }
5853
5854 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5855 ///
5856 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5857 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5858 async fn set_priority(
5859 &self,
5860 id: &NativeId,
5861 priority: Priority,
5862 ) -> Result<Option<Priority>, SourceError> {
5863 if self.priorities.is_none() {
5864 return Err(self.holds_no_priority());
5865 }
5866 let Some(mut item) = self
5867 .bound_item(id)
5868 .await?
5869 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5870 else {
5871 return Ok(None);
5872 };
5873 if priority == Priority::None && !item.holds_priority() {
5874 return Ok(Some(priority));
5875 }
5876 // The item's own read carries the field's definition whenever it holds a value of
5877 // it, which a clear always does; a select onto an item holding none reads the board.
5878 let board = match (item.carried_board(), item.named_board()) {
5879 (Some(board), _) => board,
5880 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5881 id,
5882 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5883 },
5884 _ => self.board_fields().await?,
5885 };
5886 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5887 return Ok(Some(priority));
5888 };
5889 let (document, root, input) = match write {
5890 PriorityWrite::Select { field, option } => (
5891 graphql::UPDATE_FIELD,
5892 "updateProjectV2ItemFieldValue",
5893 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5894 ),
5895 PriorityWrite::Clear { field } => (
5896 graphql::CLEAR_FIELD,
5897 "clearProjectV2ItemFieldValue",
5898 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5899 ),
5900 };
5901 let data = self
5902 .graphql(
5903 document,
5904 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5905 )
5906 .await?;
5907 let returned = data
5908 .get(root)
5909 .and_then(|value| value.get("projectV2Item"))
5910 .ok_or_else(|| SourceError::Malformed {
5911 message: "GitHub priority write returned no project item".into(),
5912 })?;
5913 if required_str(returned, "id")? != item.item_id {
5914 return Err(SourceError::Malformed {
5915 message: "GitHub priority write returned the wrong project item".into(),
5916 });
5917 }
5918 let value = returned
5919 .get("fieldValueByName")
5920 .ok_or_else(|| SourceError::Malformed {
5921 message: "GitHub priority write returned no priority read-back".into(),
5922 })?;
5923 if !value.is_null()
5924 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5925 {
5926 return Err(SourceError::Malformed {
5927 message: "GitHub priority read-back is not a Priority field value".into(),
5928 });
5929 }
5930 let values = if value.is_null() {
5931 Vec::new()
5932 } else {
5933 vec![value.clone()]
5934 };
5935 item.priority = self.held_priority(&values)?;
5936 let answer = item.task()?.priority;
5937 self.remember_written(item, false)?;
5938 Ok(Some(answer))
5939 }
5940
5941 /// Replace one task's visible body and nothing else; see
5942 /// [`TaskSource::set_task_content`].
5943 ///
5944 /// One update of the body, which differs from the body GitHub holds only outside the
5945 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5946 /// this source keeps there reads back as it was. A body that would not change is not
5947 /// sent at all.
5948 async fn replace_content(
5949 &self,
5950 id: &NativeId,
5951 content: &str,
5952 ) -> Result<Option<()>, SourceError> {
5953 let Some(mut item) = self
5954 .bound_item(id)
5955 .await?
5956 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5957 else {
5958 return Ok(None);
5959 };
5960 let held = item.raw_body.clone().unwrap_or_default();
5961 let body = with_content(&held, content)?;
5962 // Checked before anything is sent: content ending in what this source reads as its own
5963 // metadata slot would read back as metadata rather than as the content it was.
5964 let (visible, slot) = metadata_body(Some(body.clone()))?;
5965 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5966 return Err(SourceError::Refused {
5967 message: format!(
5968 "this content ends in what source {} reads as its own metadata slot \
5969 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5970 as content; next: remove that trailing block from the content",
5971 self.name
5972 ),
5973 });
5974 }
5975 if body != held {
5976 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5977 .await?;
5978 }
5979 item.body = visible.filter(|value| !value.is_empty());
5980 item.raw_body = Some(body);
5981 item.slot = slot;
5982 self.remember_written(item, false)?;
5983 Ok(Some(()))
5984 }
5985
5986 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5987 ///
5988 /// One read of the item — which carries the board's field definitions and the issue's
5989 /// `blockedBy`, so neither is read again — and then only what differs from it: the
5990 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5991 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5992 /// the title, the body — visible content and metadata slot together — and a state change.
5993 /// So an update naming any of title, body, metadata, status and priority is one read and
5994 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5995 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5996 /// as a whole write does; an open one selects its option and then reopens. The origin
5997 /// field is never written: an update is of an item that already exists, whose origin is
5998 /// what it is.
5999 ///
6000 /// The task answered is the item as those writes left it, built from the read and what was
6001 /// sent rather than read again — the same record a later read in this run answers from.
6002 async fn targeted_update(
6003 &self,
6004 id: &NativeId,
6005 update: &TaskUpdate,
6006 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
6007 // Everything this source can refuse without reading the item is refused first, in the
6008 // words a whole write of the same fields is refused with.
6009 update.consistent()?;
6010 if update
6011 .title
6012 .as_deref()
6013 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
6014 {
6015 return Err(SourceError::Refused {
6016 message: format!(
6017 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6018 spells a document, so it would read back as one rather than as a task; \
6019 retitle it",
6020 self.name
6021 ),
6022 });
6023 }
6024 if let Some(delivers) = &update.delivers {
6025 TaskRef::listed(
6026 TaskRef::DELIVERS_KEY,
6027 id,
6028 Some(&self.name),
6029 delivers.clone(),
6030 )
6031 .map_err(|message| SourceError::Refused { message })?;
6032 }
6033 if self.priorities.is_none()
6034 && update
6035 .priority
6036 .is_some_and(|priority| priority != Priority::None)
6037 {
6038 return Err(self.holds_no_priority());
6039 }
6040 let target = update
6041 .status
6042 .as_ref()
6043 .map(|status| self.resolved_target(ItemKind::Task, status.category))
6044 .transpose()?;
6045 let Some(mut item) = self
6046 .bound_item(id)
6047 .await?
6048 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6049 else {
6050 return Ok(None);
6051 };
6052 let before = item.task()?;
6053
6054 let mut status_move = None;
6055 if let (Some(status), Some(target)) = (&update.status, target) {
6056 let board = self.status_board(&item).await?;
6057 let (field, option, name) = self
6058 .column_for(&board.fields, ItemKind::Task, status.category, &target)?
6059 .ok_or_else(|| SourceError::Malformed {
6060 message: format!(
6061 "status {} of source {} names no board Status option",
6062 category_name(status.category),
6063 self.name
6064 ),
6065 })?;
6066 let terminal = matches!(target, StatusTarget::Terminal(_, _));
6067 if terminal && item.content_kind == ContentKind::DraftIssue {
6068 return Err(self.closes_a_draft(status.category));
6069 }
6070 let landed = match &target {
6071 StatusTarget::Terminal(_, reason) => {
6072 self.statuses
6073 .status(ItemKind::Task, Some(&name), true, Some(reason.reason()))
6074 }
6075 _ => self
6076 .statuses
6077 .status(ItemKind::Task, Some(&name), false, None),
6078 };
6079 let option_moves = item
6080 .option
6081 .as_deref()
6082 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
6083 let state_moves = item.content_kind == ContentKind::Issue
6084 && (item.closed != terminal || (terminal && item.status != landed));
6085 if let Some(moves) = Moves::of(option_moves, state_moves) {
6086 status_move = Some(StatusMove {
6087 board: board.id,
6088 field,
6089 option,
6090 name,
6091 target,
6092 landed,
6093 moves,
6094 });
6095 }
6096 }
6097
6098 let mut priority_move = None;
6099 if let Some(priority) = update.priority
6100 && self.priorities.is_some()
6101 && item.priority != HeldPriority::Read(priority)
6102 {
6103 let board = match (item.carried_board(), item.named_board()) {
6104 (Some(board), _) => board,
6105 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
6106 id: board,
6107 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
6108 },
6109 _ => self.board_fields().await?,
6110 };
6111 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
6112 priority_move = Some((board.id, write, priority));
6113 }
6114 }
6115
6116 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
6117 // recorded in the slot, and the slot travels in the one body update below.
6118 let edges = match &update.depends_on {
6119 Some(edges) => Some(
6120 self.partition_edges(
6121 BoardKind::Work(ItemKind::Task),
6122 item.content_kind,
6123 item.blocked_by.as_deref(),
6124 edges,
6125 )
6126 .await?,
6127 ),
6128 None => None,
6129 };
6130
6131 let mut slot = item.slot.clone();
6132 for (key, value) in &update.metadata_set {
6133 slot.insert(key.as_str().to_owned(), value.clone());
6134 }
6135 for key in &update.metadata_remove {
6136 slot.remove(key.as_str());
6137 }
6138 if let Some(delivers) = &update.delivers {
6139 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6140 }
6141 if let Some((_, recorded)) = &edges {
6142 record_edges(&mut slot, recorded);
6143 }
6144 let held = item.raw_body.clone().unwrap_or_default();
6145 let content = match &update.content {
6146 Some(content) => with_content(&held, content)?,
6147 None => held.clone(),
6148 };
6149 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6150 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6151 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6152 let body = if slot == item.slot {
6153 content
6154 } else {
6155 with_slot(&content, &slot)?
6156 };
6157 // Checked before anything is sent, as a content write checks it: content ending in
6158 // what this source reads as its own slot would read back as metadata.
6159 let (visible, read) = metadata_body(Some(body.clone()))?;
6160 let wanted = update.content.as_deref().or(item.body.as_deref());
6161 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6162 return Err(SourceError::Refused {
6163 message: format!(
6164 "this content ends in what source {} reads as its own metadata slot \
6165 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6166 as content; next: remove that trailing block from the content",
6167 self.name
6168 ),
6169 });
6170 }
6171 let recorded_moves =
6172 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6173
6174 // One `updateIssue` carries all three, because every mutation spends the secondary
6175 // limiter and the title, body and state are one mutation's inputs.
6176 let mut fields = serde_json::Map::new();
6177 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6178 fields.insert("title".to_owned(), json!(title));
6179 }
6180 if body != held {
6181 fields.insert("body".to_owned(), json!(body));
6182 }
6183 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6184 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6185 }
6186 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6187 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6188 // without undoing an earlier field when a later one fails — so a body written before a
6189 // board field the board then refused would be left changed. Written after every other
6190 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6191 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6192 // first, together in one request — a terminal option selected before the issue
6193 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6194 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6195 let mut clear: Option<(&BoardId, &str)> = None;
6196 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6197 board_writes.push((
6198 &moving.board,
6199 (
6200 moving.field.clone(),
6201 json!({"singleSelectOptionId": moving.option}),
6202 ),
6203 ));
6204 }
6205 match &priority_move {
6206 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6207 board,
6208 (field.clone(), json!({"singleSelectOptionId": option})),
6209 )),
6210 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6211 None => {}
6212 }
6213 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6214 boards.extend(clear.map(|(board, _)| board));
6215 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6216 for board in boards {
6217 let writes = board_writes
6218 .iter()
6219 .filter(|(on, _)| on.as_str() == board.as_str())
6220 .map(|(_, write)| write.clone())
6221 .collect::<Vec<_>>();
6222 let cleared = clear
6223 .filter(|(on, _)| on.as_str() == board.as_str())
6224 .map(|(_, field)| field);
6225 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6226 .await?;
6227 }
6228 let mut blocked_by_moved = false;
6229 if let Some((native, _)) = &edges
6230 && item.content_kind == ContentKind::Issue
6231 {
6232 blocked_by_moved = self
6233 .reconcile_blocked_by(
6234 &item.id,
6235 native,
6236 Issue::Existing(item.blocked_by.as_deref()),
6237 )
6238 .await?;
6239 }
6240 if !fields.is_empty() {
6241 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6242 .await?;
6243 }
6244
6245 if let Some(title) = &update.title {
6246 item.title.clone_from(title);
6247 }
6248 item.body = visible.filter(|value| !value.is_empty());
6249 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6250 item.slot = slot;
6251 if let Some(delivers) = &update.delivers {
6252 item.delivers.clone_from(delivers);
6253 }
6254 if let Some(moving) = status_move {
6255 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6256 && item.content_kind == ContentKind::Issue;
6257 item.status = moving.landed;
6258 item.option = Some(moving.name);
6259 }
6260 if let Some((_, _, priority)) = priority_move {
6261 item.priority = HeldPriority::Read(priority);
6262 }
6263 let task = item.task()?;
6264 let mut written = update.changed(&before, &task);
6265 if blocked_by_moved || recorded_moves {
6266 written.insert(UpdatedField::DependsOn);
6267 }
6268 self.remember_written(item, false)?;
6269 Ok(Some(TaskUpdateOutcome {
6270 task,
6271 written,
6272 delivers_before: before.delivers,
6273 }))
6274 }
6275
6276 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6277 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6278 ///
6279 /// One update of the body: the content outside the slot, and inside it that one entry,
6280 /// every other entry kept as it was. This source keeps no template answers — an issue has
6281 /// no room beside itself that is not its body, and answers written there would duplicate
6282 /// what the content already says and count against GitHub's body limit — so `answers`
6283 /// reaches nothing here. A body that would not change is not sent at all.
6284 async fn replace_rendering(
6285 &self,
6286 id: &NativeId,
6287 kind: BoardKind,
6288 content: &str,
6289 provenance: &Value,
6290 ) -> Result<Option<()>, SourceError> {
6291 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6292 return Ok(None);
6293 };
6294 let held = item.raw_body.clone().unwrap_or_default();
6295 let mut slot = item.slot.clone();
6296 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6297 let body = with_slot(&with_content(&held, content)?, &slot)?;
6298 // Checked before anything is sent, as a content write checks it.
6299 let (visible, read) = metadata_body(Some(body.clone()))?;
6300 if visible.as_deref().unwrap_or_default() != content || read != slot {
6301 return Err(SourceError::Refused {
6302 message: format!(
6303 "this content ends in what source {} reads as its own metadata slot \
6304 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6305 as content; next: remove that trailing block from the template",
6306 self.name
6307 ),
6308 });
6309 }
6310 if body != held {
6311 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6312 .await?;
6313 }
6314 item.body = visible.filter(|value| !value.is_empty());
6315 item.raw_body = Some(body);
6316 item.slot = read;
6317 self.remember_written(item, false)?;
6318 Ok(Some(()))
6319 }
6320
6321 async fn set_item_field(
6322 &self,
6323 board_id: &str,
6324 item_id: &str,
6325 field_id: &str,
6326 value: Value,
6327 ) -> Result<(), SourceError> {
6328 let data = self
6329 .graphql(
6330 graphql::UPDATE_FIELD,
6331 json!({"input":{
6332 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6333 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6334 )
6335 .await?;
6336 let returned = data
6337 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6338 .ok_or_else(|| SourceError::Malformed {
6339 message: "GitHub field update returned no project item".into(),
6340 })?;
6341 if required_str(returned, "id")? != item_id {
6342 return Err(SourceError::Malformed {
6343 message: "GitHub field update returned the wrong project item".into(),
6344 });
6345 }
6346 Ok(())
6347 }
6348
6349 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6350 /// one request. Every returned item id is checked, including optional aliases.
6351 async fn set_item_fields(
6352 &self,
6353 board: &str,
6354 item: &str,
6355 fields: &[(String, Value)],
6356 clear: Option<&str>,
6357 ) -> Result<(), SourceError> {
6358 if fields.len() <= 1 && clear.is_none() {
6359 if let Some((field, value)) = fields.first() {
6360 self.set_item_field(board, item, field, value.clone())
6361 .await?;
6362 }
6363 return Ok(());
6364 }
6365 if fields.is_empty() {
6366 if let Some(field) = clear {
6367 self.write_priority(
6368 board,
6369 item,
6370 &PriorityWrite::Clear {
6371 field: field.to_owned(),
6372 },
6373 )
6374 .await?;
6375 }
6376 return Ok(());
6377 }
6378 let input = |index: usize| {
6379 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6380 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6381 };
6382 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6383 "input":input(0),"second":input(1),"third":input(2),
6384 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6385 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6386 })).await?;
6387 for alias in [
6388 Some("updateProjectV2ItemFieldValue"),
6389 (fields.len() > 1).then_some("second"),
6390 (fields.len() > 2).then_some("third"),
6391 clear.map(|_| "cleared"),
6392 ]
6393 .into_iter()
6394 .flatten()
6395 {
6396 let returned = data
6397 .get(alias)
6398 .and_then(|value| value.get("projectV2Item"))
6399 .ok_or_else(|| SourceError::Malformed {
6400 message: format!("GitHub field update {alias} returned no project item"),
6401 })?;
6402 if required_str(returned, "id")? != item {
6403 return Err(SourceError::Malformed {
6404 message: format!("GitHub field update {alias} returned the wrong project item"),
6405 });
6406 }
6407 }
6408 Ok(())
6409 }
6410
6411 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6412 let mut after: Option<String> = None;
6413 let mut ids = Vec::new();
6414 loop {
6415 let data = self
6416 .graphql(
6417 graphql::ISSUE_DEPENDENCIES,
6418 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6419 )
6420 .await?;
6421 let connection =
6422 data.pointer("/node/blockedBy")
6423 .ok_or_else(|| SourceError::Malformed {
6424 message: "GitHub dependency response has no blockedBy connection".into(),
6425 })?;
6426 ids.extend(
6427 connection
6428 .get("nodes")
6429 .and_then(Value::as_array)
6430 .ok_or_else(|| SourceError::Malformed {
6431 message: "GitHub dependency response nodes is not an array".into(),
6432 })?
6433 .iter()
6434 .map(|value| required_str(value, "id").map(str::to_owned))
6435 .collect::<Result<Vec<_>, _>>()?,
6436 );
6437 let next = next_cursor(connection)?;
6438 if let Some(next) = &next {
6439 validate_cursor_progress(after.as_deref(), &next.0)?;
6440 }
6441 after = next.map(|cursor| cursor.0);
6442 if after.is_none() {
6443 return Ok(ids);
6444 }
6445 }
6446 }
6447
6448 async fn dependencies(
6449 &self,
6450 id: &NativeId,
6451 near_kind: ItemKind,
6452 direction: Direction,
6453 page: &PageRequest,
6454 ) -> Result<Page<DependencyEdge>, SourceError> {
6455 validate_page(page)?;
6456 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6457 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6458 let recorded = recorded_offset(cursor, direction)?;
6459 // What this issue is blocked by, when a read of it by its own id in this command
6460 // already carried the whole connection — a copy reads the item it writes before it
6461 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6462 // after it. Answered from that read, in the shape the dependency read answers in;
6463 // anything else is asked of GitHub.
6464 let carried = match direction {
6465 Direction::DependsOn => self
6466 .resolved_cache()?
6467 .get(id)
6468 .filter(|item| item.content_kind == ContentKind::Issue)
6469 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6470 Direction::DependedOnBy => None,
6471 }
6472 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6473 // Asked for even in the recorded phase, whose page reads nothing from the
6474 // connection: `__typename` is what says whether this item has a native
6475 // relationship at all, and that is what decides which far ends the reserved key is
6476 // allowed to hold.
6477 let data = match carried {
6478 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6479 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6480 None => {
6481 self.graphql(
6482 graphql::ISSUE_DEPENDENCIES,
6483 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6484 "after":if recorded.is_some() {None} else {cursor}}),
6485 )
6486 .await?
6487 }
6488 };
6489 let node =
6490 data.get("node")
6491 .filter(|v| !v.is_null())
6492 .ok_or_else(|| SourceError::Refused {
6493 message: format!(
6494 "GitHub item {} was not found or does not support dependencies",
6495 id.0
6496 ),
6497 })?;
6498 let connection_name = match direction {
6499 Direction::DependsOn => "blockedBy",
6500 Direction::DependedOnBy => "blocking",
6501 };
6502 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6503 // named natively and the reserved key may hold any far end. An issue's connections
6504 // hold issues, and this source reads them at the near item's own level.
6505 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6506 if let Some(offset) = recorded {
6507 return Ok(recorded_page(
6508 self.recorded_edges(id, near_kind, direction, natively_names, node)
6509 .await?,
6510 offset,
6511 limit,
6512 ));
6513 }
6514 if natively_names.is_none() {
6515 return Ok(recorded_page(
6516 self.recorded_edges(id, near_kind, direction, natively_names, node)
6517 .await?,
6518 0,
6519 limit,
6520 ));
6521 }
6522 let connection = node
6523 .get(connection_name)
6524 .ok_or_else(|| SourceError::Malformed {
6525 message: "GitHub dependency response is missing its connection".into(),
6526 })?;
6527 let nodes = connection
6528 .get("nodes")
6529 .and_then(Value::as_array)
6530 .ok_or_else(|| SourceError::Malformed {
6531 message: "GitHub dependency response nodes is not an array".into(),
6532 })?;
6533 // `from` depends on `to`, always. GitHub spells the same relationship from either
6534 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6535 // it — so the near item is `from` in one direction and `to` in the other.
6536 let items = nodes
6537 .iter()
6538 .map(|value| {
6539 let related = NativeId(required_str(value, "id")?.into());
6540 let related_kind = related_kind(value)?;
6541 let (from, to) = match direction {
6542 Direction::DependsOn => (
6543 DependencyEndpoint::from_native(id.clone(), near_kind),
6544 DependencyEndpoint::from_native(related, related_kind),
6545 ),
6546 Direction::DependedOnBy => (
6547 DependencyEndpoint::from_native(related, related_kind),
6548 DependencyEndpoint::from_native(id.clone(), near_kind),
6549 ),
6550 };
6551 Ok(DependencyEdge {
6552 from,
6553 to,
6554 kind: DependencyKind::Blocks,
6555 })
6556 })
6557 .collect::<Result<Vec<_>, SourceError>>()?;
6558 let mut next = next_cursor(connection)?;
6559 if let Some(next) = &next {
6560 validate_cursor_progress(cursor, &next.0)?;
6561 }
6562 if next.is_none()
6563 && !self
6564 .recorded_edges(id, near_kind, direction, natively_names, node)
6565 .await?
6566 .is_empty()
6567 {
6568 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6569 }
6570 Ok(Page { items, next })
6571 }
6572
6573 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6574 /// a far end in another source has to live: no GitHub issue relationship can name one.
6575 ///
6576 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6577 /// source never writes one down.
6578 ///
6579 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6580 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6581 /// request beyond the read already made, and reading the board for them would be a
6582 /// walk of every item for one field of one. A draft has no body in that answer, because
6583 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6584 /// listing of the board, which can be behind on the very item asked about.
6585 async fn recorded_edges(
6586 &self,
6587 id: &NativeId,
6588 near_kind: ItemKind,
6589 direction: Direction,
6590 natively_names: Option<ItemKind>,
6591 node: &Value,
6592 ) -> Result<Vec<DependencyEdge>, SourceError> {
6593 if direction != Direction::DependsOn {
6594 return Ok(Vec::new());
6595 }
6596 let slot = match node.get("body") {
6597 Some(body) if natively_names.is_some() => {
6598 metadata_body(body.as_str().map(str::to_owned))?.1
6599 }
6600 _ => {
6601 let Some(item) = self.bound_item(id).await? else {
6602 return Ok(Vec::new());
6603 };
6604 item.slot
6605 }
6606 };
6607 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6608 .map_err(|message| SourceError::Malformed { message })
6609 }
6610
6611 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6612 self.repository
6613 .as_ref()
6614 .ok_or_else(|| SourceError::Refused {
6615 message: format!(
6616 "source {} has no repository configured, and a GitHub Projects board has no \
6617 repository of its own to create an issue in; set repository: owner/name on \
6618 this source",
6619 self.name
6620 ),
6621 })
6622 }
6623
6624 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6625 /// states.
6626 ///
6627 /// The fallback is demanded first, whichever arm answers: a write without a configured
6628 /// repository is refused naming the field exactly as it was before the rule existed,
6629 /// so a source that could not write before cannot write now, rather than writing for
6630 /// the one item whose own field happens to decide it.
6631 ///
6632 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6633 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6634 /// entry owned by someone other than the owner of the parent issue's repository —
6635 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6636 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6637 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6638 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6639 /// and is visible to the token is checked where its node id is resolved, still before
6640 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6641 /// looked up in a listing of the board, which can be minutes behind an issue its own
6642 /// `projectItems` already places on it — and that read answers first from this process's
6643 /// own record, so a project created moments ago in this command answers though GitHub
6644 /// has not caught up.
6645 async fn creation_target(
6646 &self,
6647 incoming: &Incoming<'_>,
6648 ) -> Result<RepositoryTarget, SourceError> {
6649 let fallback = self.configured_repository()?;
6650 let what = |incoming: &Incoming<'_>| {
6651 format!(
6652 "{} {:?}",
6653 incoming.written.kind().describes(),
6654 incoming.title
6655 )
6656 };
6657 let parent = match incoming.parent {
6658 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6659 SourceError::Refused {
6660 message: format!(
6661 "GitHub project issue {} was not found on the board of source {}, so {} \
6662 cannot be filed under it",
6663 parent.0,
6664 self.name,
6665 what(incoming)
6666 ),
6667 }
6668 })?),
6669 None => None,
6670 };
6671 let parents_repository = parent
6672 .as_ref()
6673 .map(|parent| {
6674 // A draft is on the board and so is found, but it has no repository to
6675 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6676 // would refuse the task only once `createIssue` had made it.
6677 if parent.content_kind == ContentKind::DraftIssue {
6678 return Err(SourceError::Refused {
6679 message: format!(
6680 "GitHub project item {} on the board of source {} is a draft, \
6681 which cannot have sub-issues, so {} cannot be filed under it",
6682 parent.id.0,
6683 self.name,
6684 what(incoming)
6685 ),
6686 });
6687 }
6688 // An issue's repository is where a sub-issue is placed and whose owner it
6689 // is compared against, so a parent whose repository this source cannot
6690 // spell as `owner/name` — GitHub's login grammar is wider than this
6691 // source's floor — is one nothing can be filed under.
6692 parent
6693 .own_repository
6694 .as_ref()
6695 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6696 .ok_or_else(|| SourceError::Malformed {
6697 message: format!(
6698 "GitHub project issue {} on the board of source {} is in {}, which \
6699 is not a {}/owner/name repository this source can place {} in",
6700 parent.id.0,
6701 self.name,
6702 parent
6703 .own_repository
6704 .as_ref()
6705 .map_or("no repository", Repository::as_str),
6706 RepositoryTarget::HOST,
6707 what(incoming)
6708 ),
6709 })
6710 })
6711 .transpose()?;
6712 match incoming.repositories {
6713 [named] => {
6714 let target =
6715 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6716 message: format!(
6717 "{} names repository {}, which is not a {}/owner/name repository \
6718 source {} can create an issue in; name one that is, or name none",
6719 what(incoming),
6720 named.as_str(),
6721 RepositoryTarget::HOST,
6722 self.name
6723 ),
6724 })?;
6725 if let Some(parents) = &parents_repository
6726 && parents.owner != target.owner
6727 {
6728 return Err(SourceError::Refused {
6729 message: format!(
6730 "{} names repository {}, owned by {}, but its project's issue is in \
6731 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6732 of the same owner as its parent issue; name a repository of {}, or \
6733 name none",
6734 what(incoming),
6735 target.slug(),
6736 target.owner,
6737 parents.slug(),
6738 parents.owner,
6739 parents.owner
6740 ),
6741 });
6742 }
6743 Ok(target)
6744 }
6745 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6746 }
6747 }
6748
6749 /// The node id of the repository `incoming` is being created in, or the refusal naming
6750 /// the item and the repository the token cannot see.
6751 ///
6752 /// Resolved once per command per repository; see [`Self::repository_cache`].
6753 async fn repository_id(
6754 &self,
6755 repository: &RepositoryTarget,
6756 incoming: &Incoming<'_>,
6757 ) -> Result<String, SourceError> {
6758 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6759 return Ok(id);
6760 }
6761 let data = self
6762 .graphql(
6763 graphql::REPOSITORY,
6764 json!({"owner":repository.owner,"name":repository.name}),
6765 )
6766 .await?;
6767 self.repository_read(&data, repository, incoming)
6768 }
6769
6770 /// The repository's node id out of an answer carrying the `repository` root, held for
6771 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6772 fn repository_read(
6773 &self,
6774 data: &Value,
6775 repository: &RepositoryTarget,
6776 incoming: &Incoming<'_>,
6777 ) -> Result<String, SourceError> {
6778 let node = data
6779 .get("repository")
6780 .filter(|value| !value.is_null())
6781 .ok_or_else(|| SourceError::Refused {
6782 message: format!(
6783 "GitHub repository {} was not found or is not visible to the token, so {} \
6784 {:?} cannot be created in it",
6785 repository.slug(),
6786 incoming.written.kind().describes(),
6787 incoming.title
6788 ),
6789 })?;
6790 let id = required_str(node, "id")?.to_owned();
6791 self.repository_cache()?
6792 .insert(repository.clone(), id.clone());
6793 Ok(id)
6794 }
6795
6796 fn repository_cache(
6797 &self,
6798 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6799 self.repository_cache
6800 .lock()
6801 .map_err(|_| SourceError::Unavailable {
6802 message: "this source's record of the destination repository was left \
6803 inconsistent by an earlier failure; next: run the command again"
6804 .into(),
6805 })
6806 }
6807
6808 /// Create or update one board item, whichever kind it is.
6809 async fn write_item(
6810 &self,
6811 incoming: &Incoming<'_>,
6812 target: Option<&NativeId>,
6813 depends_on: &[DependencyEdge],
6814 ) -> Result<NativeId, SourceError> {
6815 // Refused before anything is read or written: a task or a project titled the way
6816 // this board spells a document would land as an issue this same source reads back
6817 // as a document, so the field this destination cannot carry is named rather than
6818 // written and silently reclassified.
6819 if let Written::Work(kind, _) = incoming.written
6820 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6821 {
6822 return Err(SourceError::Refused {
6823 message: format!(
6824 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6825 spells a document, so it would read back as one rather than as a {}; \
6826 retitle it, or copy it as a document",
6827 kind.marker(),
6828 self.name,
6829 kind.marker()
6830 ),
6831 });
6832 }
6833 // The destination is read by its own id, and whether this board holds it is decided
6834 // by that read — its own `projectItems` — rather than by whether a listing of the
6835 // board happens to include it yet. See the module documentation.
6836 let existing = match target {
6837 Some(target) => {
6838 Some(
6839 self.bound_item(target)
6840 .await?
6841 .ok_or_else(|| SourceError::Refused {
6842 message: format!("GitHub destination item {} was not found", target.0),
6843 })?,
6844 )
6845 }
6846 None => None,
6847 };
6848 let existing = existing.as_ref();
6849 // An existing issue is never moved; a new one is created where the rule says — and
6850 // knowing where is what lets the board's fields and that repository's id be read
6851 // together, before anything below needs either.
6852 let creation_target = match existing {
6853 Some(_) => None,
6854 None => {
6855 let target = self.creation_target(incoming).await?;
6856 self.creation_context(&target, incoming).await?;
6857 Some(target)
6858 }
6859 };
6860 let board = self
6861 .fields_for(
6862 existing,
6863 incoming.written.status().is_some(),
6864 incoming
6865 .priority
6866 .is_some_and(|priority| priority != Priority::None),
6867 )
6868 .await?;
6869 let status_target = incoming
6870 .written
6871 .work_status()
6872 .map(|(kind, status)| self.resolved_target(kind, status.category))
6873 .transpose()?;
6874 let column = match (incoming.written.work_status(), status_target.as_ref()) {
6875 (Some((kind, status)), Some(target)) => {
6876 self.column_for(&board.fields, kind, status.category, target)?
6877 }
6878 _ => None,
6879 };
6880 // Resolved before anything is created, for the reason the column above is: a
6881 // priority this board has no option for is refused while nothing has been written.
6882 let priority_write = match incoming.priority {
6883 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6884 None => None,
6885 };
6886 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6887 if content_kind == ContentKind::DraftIssue {
6888 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6889 (status_target.as_ref(), incoming.written.status())
6890 {
6891 return Err(self.closes_a_draft(status.category));
6892 }
6893 if incoming.parent.is_some() {
6894 return Err(SourceError::Refused {
6895 message: "GitHub draft items cannot be a project's sub-issue".into(),
6896 });
6897 }
6898 }
6899 match existing {
6900 Some(item) if content_kind == ContentKind::Issue => {
6901 if item.labels != incoming.labels {
6902 return Err(SourceError::Refused {
6903 message: "GitHub issue labels differ from the labels being written".into(),
6904 });
6905 }
6906 }
6907 _ => {
6908 if !incoming.labels.is_empty() {
6909 return Err(SourceError::Refused {
6910 message: "GitHub items created by this destination carry no labels".into(),
6911 });
6912 }
6913 }
6914 }
6915
6916 // The repository the issue really lives in is what the slot below is written against,
6917 // so a single entry that is where the issue is created travels as no key at all, and
6918 // the read side derives it back from the issue.
6919 let own_repository = match (existing, &creation_target) {
6920 (Some(item), _) => item.own_repository.clone(),
6921 (None, Some(target)) => Some(
6922 Repository::try_from(target.origin())
6923 .map_err(|message| SourceError::Config { message })?,
6924 ),
6925 (None, None) => None,
6926 };
6927 let (native, fallback) = self
6928 .partition_edges(
6929 incoming.written.kind(),
6930 content_kind,
6931 existing.and_then(|item| item.blocked_by.as_deref()),
6932 depends_on,
6933 )
6934 .await?;
6935 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6936 let body = compose_body(incoming.content, &slot)?;
6937 // Read before anything is created, for the reason the field below is: a value
6938 // this destination cannot store has to refuse, and refusing after `createIssue`
6939 // would leave an issue behind that nothing asked for. The engine writes a
6940 // qualified id here; a caller handing this key anything else is told so rather
6941 // than having it silently stored as no origin at all.
6942 // 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.
6943 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6944 None => "",
6945 Some(Value::String(origin)) => origin.as_str(),
6946 Some(other) => {
6947 return Err(SourceError::Refused {
6948 message: format!(
6949 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6950 is {other}"
6951 ),
6952 });
6953 }
6954 };
6955 // Resolved before anything is created: a board that cannot carry the copy origin
6956 // has to refuse the write, and refusing it after `createIssue` would leave an
6957 // issue behind that nothing asked for.
6958 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6959 Some(field) => {
6960 if required_str(field, "__typename")? != "ProjectV2Field" {
6961 return Err(SourceError::Refused {
6962 message: format!(
6963 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6964 ),
6965 });
6966 }
6967 Some(required_str(field, "id")?.to_owned())
6968 }
6969 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6970 return Err(SourceError::Refused {
6971 message: format!(
6972 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6973 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6974 the board"
6975 ),
6976 });
6977 }
6978 None => None,
6979 };
6980
6981 let Landed {
6982 content_id,
6983 item_id,
6984 url,
6985 number,
6986 } = match existing {
6987 // Its content is written last, below, once everything else has landed.
6988 Some(item) => Landed {
6989 content_id: item.id.clone(),
6990 item_id: item.item_id.clone(),
6991 url: item.url.clone(),
6992 number: item.number,
6993 },
6994 None => {
6995 let target = creation_target
6996 .as_ref()
6997 .ok_or_else(|| SourceError::Malformed {
6998 message: "a new item was decided without a repository to create it in"
6999 .into(),
7000 })?;
7001 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
7002 .await?
7003 }
7004 };
7005
7006 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
7007 let column = column
7008 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
7009 .map(|(field, option, _)| (field, option));
7010 // Creating an item here is several calls — `createIssue`, which files it on the
7011 // board, then its board fields, the parent and the dependencies — and GitHub can fail
7012 // at any of them. Everything this source can refuse *before* the first of those is
7013 // already checked above, so what is left is GitHub itself failing part way. When it
7014 // does over an item this call created, the issue is taken back: a write that
7015 // refused must not leave an item behind that nobody asked for, and one that does
7016 // makes the retry create a second.
7017 // Whether the board-field write carrying a moved origin was answered as landing whole.
7018 // When it was refused, GitHub does not say which of its fields ran before the one that
7019 // failed, so the origin may or may not have moved.
7020 let mut origin_landed = false;
7021 let landed = self
7022 .finish_write(
7023 board.id.as_str(),
7024 incoming,
7025 &content_id,
7026 &item_id,
7027 content_kind,
7028 existing,
7029 origin_field.as_deref(),
7030 origin,
7031 column,
7032 status_target.as_ref(),
7033 priority_write.as_ref(),
7034 &native,
7035 &mut origin_landed,
7036 )
7037 .await;
7038 // An existing item's title, body and state go last, in one `updateIssue`, once its board
7039 // fields and its relationships have landed: a refusal of any of those then leaves its
7040 // body — and the metadata slot inside it — exactly as it stood.
7041 let landed = match (landed, existing) {
7042 (Ok(()), Some(item)) => {
7043 self.update_existing(item, incoming, &body, status_target.as_ref())
7044 .await
7045 }
7046 (landed, _) => landed,
7047 };
7048 if let Err(error) = landed {
7049 match existing {
7050 // Best effort, and the write's own failure is what the caller is told: a
7051 // refusal naming the tidy-up would hide why the write failed at all.
7052 None => {
7053 let _ = self.delete_issue(&content_id).await;
7054 }
7055 // The origin field is the one piece of an existing item's metadata written
7056 // before its body, so a write refused after it puts it back as it was. When
7057 // that is refused too, the write's own failure is still what the caller is
7058 // told — with what it left behind added, because the item's metadata is then
7059 // not as it stood and a caller retrying has to know which key moved.
7060 Some(item) => {
7061 let before = item.origin.as_deref().unwrap_or("");
7062 if let Some(field) = origin_field.as_deref()
7063 && before != origin
7064 && let Err(restore) = self
7065 .set_item_field(
7066 board.id.as_str(),
7067 &item.item_id,
7068 field,
7069 json!({"text": before}),
7070 )
7071 .await
7072 {
7073 let left = if origin_landed {
7074 format!(
7075 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
7076 not be put back to {before:?} ({restore}), so item {} still \
7077 holds {origin:?} there",
7078 item.id.0
7079 )
7080 } else {
7081 format!(
7082 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
7083 {origin:?}, GitHub does not say whether that part of it ran, \
7084 and putting it back to {before:?} was refused ({restore}), so \
7085 item {} holds {origin:?} or {before:?} there",
7086 item.id.0
7087 )
7088 };
7089 return Err(noting(
7090 error,
7091 &format!(
7092 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
7093 run the write again"
7094 ),
7095 ));
7096 }
7097 }
7098 }
7099 return Err(error);
7100 }
7101
7102 let written_status = match (incoming.written.work_status(), status_target.as_ref()) {
7103 (Some((kind, _)), Some(StatusTarget::Terminal(_, reason))) => {
7104 self.statuses
7105 .status(kind, written_option.as_deref(), true, Some(reason.reason()))
7106 }
7107 (Some((kind, _)), Some(StatusTarget::Column(_))) => {
7108 self.statuses
7109 .status(kind, written_option.as_deref(), false, None)
7110 }
7111 (Some((_, status)), _) => status.clone(),
7112 (None, _) => Status {
7113 category: StatusCategory::Unknown,
7114 name: "Open".to_owned(),
7115 },
7116 };
7117
7118 // So the rest of this command reads what it just did rather than what the board
7119 // said before it. See `remember_written` for which half takes it.
7120 let remembered = Resolved {
7121 item_id,
7122 id: content_id.clone(),
7123 content_kind,
7124 kind: incoming.written.kind(),
7125 title: incoming.title.to_owned(),
7126 // The visible half of the body this write composed, split back off it the
7127 // way a read splits it — so what this record reports is what a read of the
7128 // same issue reports, rather than the person's text with the metadata slot
7129 // still on the end of it.
7130 body: metadata_body(body.clone())?.0,
7131 raw_body: body.clone(),
7132 // A document has no status of its own; what it reads back as is whatever
7133 // the issue's own state says, which is what a re-read reports.
7134 status: written_status,
7135 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
7136 priority: match incoming.priority {
7137 Some(priority) => HeldPriority::Read(priority),
7138 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
7139 item.priority.clone()
7140 }),
7141 },
7142 // What `state_input` asked for: closed for a terminal target, open for any other
7143 // status, and the issue's own state left as it was by a document write.
7144 closed: content_kind == ContentKind::Issue
7145 && match status_target.as_ref() {
7146 Some(StatusTarget::Terminal(_, _)) => true,
7147 Some(_) => false,
7148 None => existing.is_some_and(|item| item.closed),
7149 },
7150 delivers: incoming.delivers.to_vec(),
7151 delivered_by: incoming.delivered_by.to_vec(),
7152 labels: incoming.labels.to_vec(),
7153 parent: incoming.parent.cloned(),
7154 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7155 number,
7156 // In the update path this is the item's own url, read off `existing` where the
7157 // record above was bound, so one expression serves both halves.
7158 url,
7159 created_at: existing.and_then(|item| item.created_at),
7160 updated_at: existing.and_then(|item| item.updated_at),
7161 own_repository,
7162 repositories: incoming.repositories.to_vec(),
7163 slot,
7164 board_id: Some(board.id.as_str().to_owned()),
7165 fields: board
7166 .fields
7167 .get("nodes")
7168 .and_then(Value::as_array)
7169 .cloned()
7170 .unwrap_or_default(),
7171 board_fields: Some(board.fields.clone()),
7172 // What this write left the relationship holding is known by id alone, and a
7173 // later read of its edges needs each far end's kind, so it reads them again.
7174 blocked_by: None,
7175 };
7176 self.remember_written(remembered, existing.is_none())?;
7177 Ok(content_id)
7178 }
7179
7180 /// Everything a write does after the item exists: its board fields, its parent, and
7181 /// its dependencies.
7182 ///
7183 /// Split out of `write_item` so there is one place a failure past the point of no
7184 /// return is caught, rather than a tidy-up repeated at each `?` above.
7185 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7186 // so there is one place a failure past the point of no return is caught, and its
7187 // arguments are exactly the values that tail already had in scope. Bundling them into a
7188 // struct would describe no concept — it would be "the arguments of this function" — and
7189 // would put the whole of `write_item`'s locals behind one more indirection.
7190 #[allow(clippy::too_many_arguments)]
7191 async fn finish_write(
7192 &self,
7193 board_id: &str,
7194 incoming: &Incoming<'_>,
7195 content_id: &NativeId,
7196 item_id: &str,
7197 content_kind: ContentKind,
7198 existing: Option<&Resolved>,
7199 origin_field: Option<&str>,
7200 origin: &str,
7201 column: Option<(String, String)>,
7202 status_target: Option<&StatusTarget>,
7203 priority: Option<&PriorityWrite>,
7204 native: &[String],
7205 origin_landed: &mut bool,
7206 ) -> Result<(), SourceError> {
7207 let mut fields = Vec::new();
7208 if let Some(field_id) = origin_field
7209 && existing.map_or(!origin.is_empty(), |item| {
7210 item.origin.as_deref().unwrap_or("") != origin
7211 })
7212 {
7213 fields.push((field_id.to_owned(), json!({"text":origin})));
7214 }
7215 if let Some((field_id, option_id)) = column {
7216 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7217 }
7218 let clear = match priority {
7219 Some(PriorityWrite::Select { field, option }) => {
7220 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7221 None
7222 }
7223 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7224 None => None,
7225 };
7226 self.set_item_fields(board_id, item_id, &fields, clear)
7227 .await?;
7228 *origin_landed = true;
7229
7230 // An existing issue closes in the `updateIssue` its write ends with; one created just
7231 // now closes here, once its option is selected.
7232 if existing.is_none()
7233 && content_kind == ContentKind::Issue
7234 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7235 {
7236 self.update_content(
7237 ContentKind::Issue,
7238 content_id,
7239 json!({"stateInput":state_input(status_target)}),
7240 )
7241 .await?;
7242 }
7243
7244 if content_kind == ContentKind::Issue {
7245 self.reparent(
7246 existing.and_then(|item| item.parent.clone()),
7247 content_id,
7248 incoming.parent,
7249 )
7250 .await?;
7251 // A document takes part in no dependency graph, so writing one neither reads
7252 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7253 // against the empty list a document write carries would *delete* whatever
7254 // relationships a person had made on that issue, which is a write nobody
7255 // asked for.
7256 if incoming.written.kind() != BoardKind::Document {
7257 let issue = match existing {
7258 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7259 None => Issue::Created,
7260 };
7261 self.reconcile_blocked_by(content_id, native, issue).await?;
7262 }
7263 }
7264 Ok(())
7265 }
7266
7267 /// Delete one issue, which takes its board item with it.
7268 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7269 let data = self
7270 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7271 .await?;
7272 data.pointer("/deleteIssue/repository")
7273 .filter(|value| !value.is_null())
7274 .ok_or_else(|| SourceError::Malformed {
7275 message: "GitHub issue deletion returned no repository".into(),
7276 })?;
7277 self.forget(id)?;
7278 Ok(())
7279 }
7280
7281 /// Remove one item this copy created, so a copy that could not finish leaves the board
7282 /// as it found it.
7283 ///
7284 /// Deleting the issue takes its board item with it, so there is no second mutation to
7285 /// keep in step. An id the board does not hold is not an error: the item is already
7286 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7287 /// by its own id — a listing of the board can still be missing an item it holds, and
7288 /// reading that as *already gone* would leave behind the very item this was asked to
7289 /// take back.
7290 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7291 let Some(item) = self.bound_item(id).await? else {
7292 return Ok(());
7293 };
7294 if item.content_kind == ContentKind::DraftIssue {
7295 return Err(SourceError::Refused {
7296 message: format!(
7297 "GitHub item {} is a draft, and this source removes an item by deleting \
7298 its issue; next: remove it from the board by hand",
7299 id.0
7300 ),
7301 });
7302 }
7303 let data = self
7304 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7305 .await?;
7306 data.pointer("/deleteIssue/repository")
7307 .filter(|value| !value.is_null())
7308 .ok_or_else(|| SourceError::Malformed {
7309 message: "GitHub issue deletion returned no repository".into(),
7310 })?;
7311 self.forget(id)?;
7312 Ok(())
7313 }
7314
7315 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7316 /// task.
7317 ///
7318 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7319 /// read of the task cannot disagree about which ids name one: a project or a document of
7320 /// this board is not a task here either.
7321 ///
7322 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7323 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7324 /// which would read as a task nobody has commented on yet.
7325 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7326 let cached = self.resolved_cache()?.get(task).cloned();
7327 let Some(item) = (match cached {
7328 Some(item) => Some(item),
7329 None => self.item_by_id(task).await?,
7330 })
7331 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7332 return Ok(None);
7333 };
7334 if item.content_kind == ContentKind::DraftIssue {
7335 return Err(self.draft_has_no_comments(task));
7336 }
7337 Ok(Some(item.id))
7338 }
7339
7340 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7341 /// issues, and a draft is not one.
7342 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7343 SourceError::Refused {
7344 message: format!(
7345 "task {} of source {} is a draft item on the board, and GitHub keeps \
7346 comments on issues alone, so a draft has none to read or write; next: \
7347 convert the draft to an issue on the board, then comment on the issue it \
7348 becomes",
7349 task.0, self.name
7350 ),
7351 }
7352 }
7353
7354 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7355 /// request — or `None` when this board holds no task by that id.
7356 ///
7357 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7358 /// is answered with the draft and the refusal, at the price of the draft's own read.
7359 async fn issue_detail(
7360 &self,
7361 id: &NativeId,
7362 page: &PageRequest,
7363 ) -> Result<Option<TaskDetailRead>, SourceError> {
7364 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7365 let asked = self
7366 .graphql(
7367 graphql::ISSUE_DETAIL,
7368 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7369 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7370 "duplicates":true}),
7371 )
7372 .await;
7373 let data = match asked {
7374 Ok(data) => data,
7375 Err(error) if unresolvable_node(&error) => return Ok(None),
7376 Err(error) => return Err(error),
7377 };
7378 // `node` is null for an id that names nothing, and absent only from an answer this
7379 // source cannot read — never the same thing.
7380 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7381 message: format!("GitHub answered the read of {} with no node", id.0),
7382 })?;
7383 self.detail_of(id, node, true, after).await
7384 }
7385
7386 /// Several tasks, each with the first page of its comments when `comments` is set, read
7387 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7388 /// order.
7389 ///
7390 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7391 /// one item at a time, so that id is answered as missing and the others as themselves; any
7392 /// other refusal is every id of that batch's answer.
7393 async fn issue_details(
7394 &self,
7395 ids: &[NativeId],
7396 comments: Option<&PageRequest>,
7397 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7398 let mut read = Vec::with_capacity(ids.len());
7399 for batch in ids.chunks(DETAIL_BATCH) {
7400 match self
7401 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7402 .await
7403 {
7404 Ok(data) => {
7405 for (slot, id) in batch.iter().enumerate() {
7406 // Every alias asked for is answered, null for an id naming nothing;
7407 // one missing is an answer this source cannot read.
7408 let read_one = match data.get(format!("i{slot}")) {
7409 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7410 None => Err(SourceError::Malformed {
7411 message: format!(
7412 "GitHub answered a batch read with no item for {}",
7413 id.0
7414 ),
7415 }),
7416 };
7417 read.push(read_one);
7418 }
7419 }
7420 Err(error) if unresolvable_node(&error) => {
7421 for id in batch {
7422 read.push(match comments {
7423 Some(page) => self.issue_detail(id, page).await,
7424 None => self.task_read(id).await,
7425 });
7426 }
7427 }
7428 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7429 }
7430 }
7431 read
7432 }
7433
7434 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7435 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7436 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7437 task,
7438 comments: None,
7439 }))
7440 }
7441
7442 /// What one node a detail read reached says: the task this board holds by `id`, with the
7443 /// page of comments the node carries when `commented` — or `None` for a node that is no
7444 /// task of this board.
7445 ///
7446 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7447 /// and an item this process created answers from this process's own record, which a node
7448 /// read taken moments after the write can still be behind.
7449 async fn detail_of(
7450 &self,
7451 id: &NativeId,
7452 node: &Value,
7453 commented: bool,
7454 after: Option<&str>,
7455 ) -> Result<Option<TaskDetailRead>, SourceError> {
7456 if node.is_null() {
7457 return Ok(None);
7458 }
7459 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7460 // An issue answered under one id is that id's, or the answer is not one this source
7461 // can report: reporting another issue's task and comments under the qualified id asked
7462 // for would be the one wrong answer here. A draft's own read checks the same.
7463 if !draft
7464 && optional_str(node, "__typename")? == Some("Issue")
7465 && required_str(node, "id")? != id.0
7466 {
7467 return Err(SourceError::Malformed {
7468 message: format!(
7469 "GitHub answered the read of {} with issue {}",
7470 id.0,
7471 required_str(node, "id")?
7472 ),
7473 });
7474 }
7475 let item = if draft {
7476 self.draft_by_id(id).await?
7477 } else {
7478 self.resolve_issue(node).await?
7479 };
7480 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7481 return Ok(None);
7482 };
7483 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7484 let task = own.unwrap_or(item).task()?;
7485 let comments = match (commented, draft) {
7486 (false, _) => None,
7487 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7488 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7489 };
7490 Ok(Some(TaskDetailRead { task, comments }))
7491 }
7492
7493 /// Whether the comment `comment` is one of `issue`'s own.
7494 ///
7495 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7496 /// comment's id and nothing else: a comment id given against the wrong task would
7497 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7498 /// names something that is not an issue comment, is a comment this task does not have —
7499 /// which is what GitHub refusing to resolve it means too.
7500 async fn comment_is_on(
7501 &self,
7502 issue: &NativeId,
7503 comment: &NativeId,
7504 ) -> Result<bool, SourceError> {
7505 let asked = self
7506 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7507 .await;
7508 let data = match asked {
7509 Ok(data) => data,
7510 Err(error) if unresolvable_node(&error) => return Ok(false),
7511 Err(error) => return Err(error),
7512 };
7513 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7514 return Ok(false);
7515 };
7516 if optional_str(node, "__typename")? != Some("IssueComment") {
7517 return Ok(false);
7518 }
7519 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7520 message: format!("GitHub issue comment {} names no issue", comment.0),
7521 })?;
7522 Ok(required_str(on, "id")? == issue.0)
7523 }
7524
7525 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7526 async fn partition_edges(
7527 &self,
7528 near_kind: BoardKind,
7529 near_content: ContentKind,
7530 carried: Option<&[Value]>,
7531 depends_on: &[DependencyEdge],
7532 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7533 let mut native = Vec::new();
7534 let mut fallback = Vec::new();
7535 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7536 .iter()
7537 .map(|edge| {
7538 let same_source = edge
7539 .to
7540 .source()
7541 .is_none_or(|source| source == self.name.as_str());
7542 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7543 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7544 // colons of its own, so the far end is everything after that one separator.
7545 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7546 let far_id = if edge.to.is_qualified() {
7547 edge.to
7548 .id()
7549 .split_once(':')
7550 .map_or(edge.to.id(), |(_, native)| native)
7551 } else {
7552 edge.to.id()
7553 };
7554 // One that already blocks the near issue was answered by that issue's own
7555 // read, which carried each of its blockers' kinds — an issue every one — so it
7556 // is not read again.
7557 let blocking = carried.and_then(|nodes| {
7558 nodes
7559 .iter()
7560 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7561 });
7562 (edge, far_id, same_source, blocking)
7563 })
7564 .collect();
7565 // Every other same-source far end is read by its own id, exactly as the item it is a
7566 // far end of is: whether this board holds it is that read's answer, never a listing's.
7567 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7568 let mut unread: Vec<NativeId> = Vec::new();
7569 for (_, far_id, same_source, blocking) in &far_ends {
7570 let id = NativeId((*far_id).to_owned());
7571 if *same_source && blocking.is_none() && !unread.contains(&id) {
7572 unread.push(id);
7573 }
7574 }
7575 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7576 .iter()
7577 .cloned()
7578 .zip(self.items_by_ids(&unread).await?)
7579 .collect();
7580 for (edge, far_id, same_source, blocking) in far_ends {
7581 let far = match (same_source, blocking) {
7582 (false, _) => None,
7583 (true, Some(node)) => Some(FarEnd {
7584 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7585 BoardKind::Document
7586 } else {
7587 BoardKind::Work(related_kind(node)?)
7588 },
7589 content_kind: ContentKind::Issue,
7590 }),
7591 (true, None) => {
7592 let read = read
7593 .get(&NativeId(far_id.to_owned()))
7594 .cloned()
7595 .flatten()
7596 .ok_or_else(|| SourceError::Refused {
7597 message: format!("GitHub dependency item {far_id} was not found"),
7598 })?;
7599 Some(FarEnd {
7600 kind: read.kind,
7601 content_kind: read.content_kind,
7602 })
7603 }
7604 };
7605 let far = far.as_ref();
7606 // The caller says which kind the far end is, and this board holds the far end
7607 // itself, so a disagreement is settled here rather than stored: recorded, the
7608 // wrong kind would read back as a cross-level edge that never existed; written
7609 // natively, it would name a relationship of a different level than the caller
7610 // asked for.
7611 //
7612 // A far end this board holds as a *document* fails the same comparison and is
7613 // refused by the same sentence: `ItemKind` has no document variant because
7614 // nothing may point at one, so no caller can name it correctly and the refusal
7615 // is the only honest answer.
7616 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7617 return Err(SourceError::Refused {
7618 message: format!(
7619 "GitHub dependency item {far_id} is a {} of this board, and this item \
7620 names it as a {}; record the kind it is",
7621 disagreeing.kind.describes(),
7622 edge.to.kind.marker()
7623 ),
7624 });
7625 }
7626 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7627 // however the far end is spelled — and one classified native here would be
7628 // written nowhere at all, because a draft's native reconciliation never runs.
7629 let native_here = near_content == ContentKind::Issue
7630 && far.is_some_and(|far| {
7631 far.content_kind == ContentKind::Issue
7632 && BoardKind::Work(edge.to.kind) == near_kind
7633 });
7634 if native_here {
7635 native.push(far_id.to_owned());
7636 } else {
7637 fallback.push(edge.clone());
7638 }
7639 }
7640 Ok((native, fallback))
7641 }
7642
7643 async fn update_existing(
7644 &self,
7645 item: &Resolved,
7646 incoming: &Incoming<'_>,
7647 body: &Option<String>,
7648 status_target: Option<&StatusTarget>,
7649 ) -> Result<(), SourceError> {
7650 let title = incoming.written_title();
7651 // A terminal status closes the issue here, in the same mutation as its body: its board
7652 // option was selected before this, so a close never lands on an item whose board cannot
7653 // show it.
7654 let fields = match item.content_kind {
7655 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7656 ContentKind::Issue => json!({"title":title,"body":body,
7657 "stateInput":state_input(status_target)}),
7658 };
7659 self.update_content(item.content_kind, &item.id, fields)
7660 .await
7661 }
7662
7663 /// Update one board item's content with exactly `fields` beside its id, through the
7664 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7665 /// a draft.
7666 ///
7667 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7668 /// is what lets a narrow write carry the one thing it changes and nothing else.
7669 async fn update_content(
7670 &self,
7671 kind: ContentKind,
7672 id: &NativeId,
7673 fields: Value,
7674 ) -> Result<(), SourceError> {
7675 let (operation, id_key, pointer) = match kind {
7676 ContentKind::DraftIssue => (
7677 graphql::UPDATE_DRAFT,
7678 "draftIssueId",
7679 "/updateProjectV2DraftIssue/draftIssue",
7680 ),
7681 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7682 };
7683 let mut input = fields;
7684 input[id_key] = json!(id.0);
7685 let data = self.graphql(operation, json!({"input":input})).await?;
7686 let returned = data
7687 .pointer(pointer)
7688 .ok_or_else(|| SourceError::Malformed {
7689 message: "GitHub item update returned no item".into(),
7690 })?;
7691 if required_str(returned, "id")? != id.0 {
7692 return Err(SourceError::Malformed {
7693 message: "GitHub item update returned the wrong item".into(),
7694 });
7695 }
7696 Ok(())
7697 }
7698
7699 /// Creates one issue, files it on the board, and reports what a read of it would say:
7700 /// its content id, its board item id, and the web address GitHub gave it.
7701 ///
7702 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7703 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7704 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7705 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7706 /// "Content already exists in this project". A terminal status is not written here:
7707 /// `finish_write` selects its option first and closes the issue after, so a close never
7708 /// lands on an item whose board cannot show it.
7709 ///
7710 /// The address and the number come back here because this is the only place either is
7711 /// known before GitHub's own board read catches up — an item this run created answers
7712 /// the reads that follow it out of the record below, and one remembered without them
7713 /// would report no location and no key for the rest of the run.
7714 async fn create_and_file_issue(
7715 &self,
7716 board_id: &str,
7717 repository: &RepositoryTarget,
7718 incoming: &Incoming<'_>,
7719 body: &Option<String>,
7720 ) -> Result<Landed, SourceError> {
7721 let repository_id = self.repository_id(repository, incoming).await?;
7722 let data = self
7723 .graphql(
7724 graphql::CREATE_ISSUE,
7725 json!({"input":{
7726 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7727 }}),
7728 )
7729 .await?;
7730 let created = data
7731 .pointer("/createIssue/issue")
7732 .filter(|value| !value.is_null())
7733 .ok_or_else(|| SourceError::Malformed {
7734 message: "GitHub issue creation returned no issue".into(),
7735 })?;
7736 let content_id = NativeId(required_str(created, "id")?.to_owned());
7737 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7738 // a response without it is not worth failing a landed write over — the item simply
7739 // reports no location until the board read catches up, which is what it did before.
7740 let url = optional_str(created, "url")?.map(str::to_owned);
7741 // The issue exists from here on, so an unreadable number and a refused board
7742 // filing below each try, best effort, to take it back: an issue in the repository
7743 // that is on no board is an item nobody asked for and nothing here would find again.
7744 //
7745 // Its number is optional on the same terms its address is — a landed write is not
7746 // worth failing over a member that came back missing, and such an item reports no
7747 // handle until a board read catches up. A number that is *present* and is not an
7748 // unsigned integer is still a response this source cannot read.
7749 let number = match created_issue_number(created) {
7750 Ok(number) => number,
7751 Err(error) => {
7752 let _ = self.delete_issue(&content_id).await;
7753 return Err(error);
7754 }
7755 };
7756 let added = match self
7757 .graphql(
7758 graphql::ADD_TO_BOARD,
7759 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7760 )
7761 .await
7762 {
7763 Ok(added) => added,
7764 Err(error) => {
7765 let _ = self.delete_issue(&content_id).await;
7766 return Err(error);
7767 }
7768 };
7769 let item = added
7770 .pointer("/addProjectV2ItemById/item")
7771 .filter(|value| !value.is_null())
7772 .ok_or_else(|| SourceError::Malformed {
7773 message: "GitHub board addition returned no project item".into(),
7774 })?;
7775 Ok(Landed {
7776 content_id,
7777 item_id: required_str(item, "id")?.to_owned(),
7778 url,
7779 number,
7780 })
7781 }
7782
7783 /// Move one issue under the project it now belongs to, or out of the one it left.
7784 async fn reparent(
7785 &self,
7786 held: Option<NativeId>,
7787 child: &NativeId,
7788 wanted: Option<&NativeId>,
7789 ) -> Result<(), SourceError> {
7790 if held.as_ref() == wanted {
7791 return Ok(());
7792 }
7793 if let Some(held) = &held {
7794 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7795 .await?;
7796 }
7797 if let Some(wanted) = wanted {
7798 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7799 .await?;
7800 }
7801 Ok(())
7802 }
7803
7804 async fn sub_issue(
7805 &self,
7806 operation: &str,
7807 parent: &NativeId,
7808 child: &NativeId,
7809 root: &str,
7810 ) -> Result<(), SourceError> {
7811 let data = self
7812 .graphql(
7813 operation,
7814 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7815 )
7816 .await?;
7817 let issue =
7818 data.pointer(&format!("/{root}/issue"))
7819 .ok_or_else(|| SourceError::Malformed {
7820 message: "GitHub sub-issue update returned no issue".into(),
7821 })?;
7822 let sub =
7823 data.pointer(&format!("/{root}/subIssue"))
7824 .ok_or_else(|| SourceError::Malformed {
7825 message: "GitHub sub-issue update returned no sub-issue".into(),
7826 })?;
7827 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7828 return Err(SourceError::Malformed {
7829 message: "GitHub sub-issue update returned the wrong issues".into(),
7830 });
7831 }
7832 Ok(())
7833 }
7834
7835 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7836 /// whether there was one.
7837 ///
7838 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7839 /// relationships are not read: there is nothing a read of them could find.
7840 async fn reconcile_blocked_by(
7841 &self,
7842 content_id: &NativeId,
7843 native: &[String],
7844 issue: Issue<'_>,
7845 ) -> Result<bool, SourceError> {
7846 let current = match issue {
7847 Issue::Created => Vec::new(),
7848 Issue::Existing(Some(held)) => held
7849 .iter()
7850 .map(|far| required_str(far, "id").map(str::to_owned))
7851 .collect::<Result<Vec<_>, _>>()?,
7852 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7853 };
7854 let mut changed = false;
7855 for (operation, far_id) in current
7856 .iter()
7857 .filter(|id| !native.contains(id))
7858 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7859 .chain(
7860 native
7861 .iter()
7862 .filter(|id| !current.contains(id))
7863 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7864 )
7865 {
7866 let data = self
7867 .graphql(
7868 operation,
7869 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7870 )
7871 .await?;
7872 let root = if operation == graphql::ADD_BLOCKED_BY {
7873 "addBlockedBy"
7874 } else {
7875 "removeBlockedBy"
7876 };
7877 let issue =
7878 data.pointer(&format!("/{root}/issue"))
7879 .ok_or_else(|| SourceError::Malformed {
7880 message: "GitHub dependency update returned no issue".into(),
7881 })?;
7882 let blocker = data
7883 .pointer(&format!("/{root}/blockingIssue"))
7884 .ok_or_else(|| SourceError::Malformed {
7885 message: "GitHub dependency update returned no blocking issue".into(),
7886 })?;
7887 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7888 {
7889 return Err(SourceError::Malformed {
7890 message: "GitHub dependency update returned the wrong issues".into(),
7891 });
7892 }
7893 changed = true;
7894 }
7895 Ok(changed)
7896 }
7897}
7898
7899/// What a write needs to know of one far end it names: which kind of item it is, and whether
7900/// it is an issue a native relationship can name.
7901struct FarEnd {
7902 kind: BoardKind,
7903 content_kind: ContentKind,
7904}
7905
7906/// Whether the issue one write reconciles was created by that write or was already there.
7907#[derive(Clone, Copy, PartialEq, Eq)]
7908enum Issue<'a> {
7909 /// Created by this write, so it holds no relationships yet.
7910 Created,
7911 /// On the board before this write, holding whatever relationships it holds — the far
7912 /// ends of its whole `blockedBy`, when the read that reached it carried them.
7913 Existing(Option<&'a [Value]>),
7914}
7915
7916/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7917enum Reached {
7918 /// An issue this board holds, resolved into everything this source reports about it.
7919 Held(Box<Resolved>),
7920 /// Nothing this board holds: no such node, or a node on some other board.
7921 Nothing,
7922 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7923 /// again by [`GitHubProjectsSource::draft_by_id`].
7924 Draft,
7925}
7926
7927/// What GitHub says when a string is not a node id it can resolve.
7928///
7929/// Matched because it is the ordinary answer to a project selector naming a project by its
7930/// *name*, and reporting that as a failure would make naming one impossible. It is read
7931/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7932/// does not define the syntax of a GitHub node id and would be wrong about it.
7933const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7934
7935/// `error` with `note` added to the end of what it says, its kind and every other member
7936/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7937/// what that failure left behind.
7938fn noting(error: SourceError, note: &str) -> SourceError {
7939 match error {
7940 SourceError::Config { message } => SourceError::Config {
7941 message: message + note,
7942 },
7943 SourceError::Auth { message } => SourceError::Auth {
7944 message: message + note,
7945 },
7946 SourceError::Refused { message } => SourceError::Refused {
7947 message: message + note,
7948 },
7949 SourceError::RateLimited {
7950 retry_after_seconds,
7951 message,
7952 } => SourceError::RateLimited {
7953 retry_after_seconds,
7954 message: Some(message.unwrap_or_default() + note),
7955 },
7956 SourceError::Unavailable { message } => SourceError::Unavailable {
7957 message: message + note,
7958 },
7959 SourceError::Malformed { message } => SourceError::Malformed {
7960 message: message + note,
7961 },
7962 }
7963}
7964
7965/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7966/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7967/// for them.
7968///
7969/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7970/// is read again at no added price.
7971fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7972 let mut variables = serde_json::Map::new();
7973 for slot in 0..DETAIL_BATCH {
7974 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7975 variables.insert(format!("id{slot}"), json!(id));
7976 }
7977 variables.insert(
7978 "first".to_owned(),
7979 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7980 );
7981 variables.insert("comments".to_owned(), json!(comments.is_some()));
7982 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7983 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7984 variables.insert("duplicates".to_owned(), json!(true));
7985 Value::Object(variables)
7986}
7987
7988/// Whether this refusal is GitHub saying the id names no node at all.
7989fn unresolvable_node(error: &SourceError) -> bool {
7990 matches!(error, SourceError::Refused { message }
7991 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7992}
7993
7994/// One project name, as a search qualifier which filters on it at the server.
7995///
7996/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7997/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7998/// the way it documents. A title matched here is still compared for equality afterwards:
7999/// the qualifier narrows what the server sends, and this source decides what it names.
8000fn title_qualifier(name: &str) -> String {
8001 format!("in:title {}", quoted(name))
8002}
8003
8004/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
8005/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
8006/// it documents — so a value holding a qualifier's spelling is searched for rather than
8007/// obeyed.
8008fn quoted(value: &str) -> String {
8009 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
8010 format!("\"{escaped}\"")
8011}
8012
8013/// The search qualifier for the issues updated at or after `since`.
8014///
8015/// Written to the second, rounded down, which can only widen what the search returns.
8016fn updated_qualifier(since: DateTime<Utc>) -> String {
8017 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
8018}
8019
8020/// The search terms that narrow a board-scoped issue search to a task query's text and
8021/// metadata predicates, or `None` when it carries neither.
8022///
8023/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
8024/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
8025/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
8026/// matches each in any field the `in:` qualifier names, so a query naming a title search and
8027/// a metadata value searches both fields for both — wider than asked, never narrower, and
8028/// every candidate is confirmed in process afterwards.
8029///
8030/// **This narrows a text search, and that is this source's declared semantics.** GitHub
8031/// matches whole tokens where a substring rule would match inside a word, so an item holding
8032/// the text only inside a longer word is not returned. A text of nothing but whitespace
8033/// matches every item, so it narrows nothing and is not sent.
8034fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
8035 let text = query
8036 .text
8037 .as_ref()
8038 .filter(|text| !text.terms.trim().is_empty());
8039 if text.is_none() && query.metadata.is_empty() {
8040 return None;
8041 }
8042 let (title, body) = match text.map(|text| text.fields) {
8043 None => (false, true),
8044 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
8045 Some(TextFields::Content) => (false, true),
8046 Some(TextFields::TitleOrContent) => (true, true),
8047 };
8048 let fields = match (title, body) {
8049 (true, true) => "in:title,body",
8050 (true, false) => "in:title",
8051 _ => "in:body",
8052 };
8053 let phrases = text
8054 .map(|text| text.terms.clone())
8055 .into_iter()
8056 .chain(
8057 query
8058 .metadata
8059 .iter()
8060 .map(|wanted| as_stored(wanted.value())),
8061 )
8062 .map(|phrase| quoted(&phrase))
8063 .collect::<Vec<_>>();
8064 Some(format!("{fields} {}", phrases.join(" ")))
8065}
8066
8067/// The search terms that narrow a board-scoped issue search to a project or document query's
8068/// text, or `None` when it has none or a blank one: the phrase, in the fields, a task query
8069/// carrying that text alone is sent as by [`narrowing_qualifiers`].
8070fn text_qualifiers(text: Option<&TextQuery>) -> Option<String> {
8071 narrowing_qualifiers(&TaskQuery {
8072 text: text.cloned(),
8073 ..TaskQuery::default()
8074 })
8075}
8076
8077/// Refuses a project or document query's text GitHub's issue search cannot find, before
8078/// anything is asked of GitHub, on exactly the terms [`refuse_unsearchable`] refuses a task
8079/// query's.
8080fn refuse_unsearchable_text(text: Option<&TextQuery>) -> Result<(), SourceError> {
8081 refuse_unsearchable(&TaskQuery {
8082 text: text.cloned(),
8083 ..TaskQuery::default()
8084 })
8085}
8086
8087/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
8088/// before anything is asked of GitHub.
8089///
8090/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
8091/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
8092/// left out, the search is every issue of the board. So this source says it cannot answer
8093/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
8094/// nothing GitHub could search for, and keeps the board read it always had.
8095fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
8096 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
8097 letter or digit with a bounded query";
8098 if let Some(text) = &query.text
8099 && !text.terms.trim().is_empty()
8100 && !has_words(&text.terms)
8101 {
8102 return Err(SourceError::Refused {
8103 message: format!(
8104 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
8105 text.terms
8106 ),
8107 });
8108 }
8109 if let Some(wanted) = query
8110 .metadata
8111 .iter()
8112 .find(|wanted| !has_words(wanted.value()))
8113 {
8114 return Err(SourceError::Refused {
8115 message: format!(
8116 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
8117 wanted.value(),
8118 std::iter::once(wanted.key())
8119 .chain(wanted.path().iter().map(String::as_str))
8120 .collect::<Vec<_>>()
8121 .join("/"),
8122 ),
8123 });
8124 }
8125 Ok(())
8126}
8127
8128/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
8129fn has_words(phrase: &str) -> bool {
8130 phrase.chars().any(char::is_alphanumeric)
8131}
8132
8133/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
8134///
8135/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
8136/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
8137/// which GitHub's word match would read as different words.
8138fn as_stored(value: &str) -> String {
8139 let encoded = Value::String(value.to_owned()).to_string();
8140 encoded[1..encoded.len() - 1].to_owned()
8141}
8142
8143/// The one narrower question a task query carrying a text, metadata or origin predicate is
8144/// sent as.
8145enum Narrowing {
8146 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
8147 Origin(String),
8148 /// The board-scoped issue search narrowed by these qualifiers.
8149 Search(String),
8150}
8151
8152impl Narrowing {
8153 /// What this question is remembered under for the length of one command.
8154 fn key(&self) -> String {
8155 match self {
8156 Self::Origin(origin) => format!("origin {origin}"),
8157 Self::Search(also) => format!("search {also}"),
8158 }
8159 }
8160}
8161
8162/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8163enum Resumed {
8164 /// It reported another page, which starts after this cursor.
8165 More(String),
8166 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8167 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8168 /// can go on walking the other connection.
8169 Ended(Option<String>),
8170}
8171
8172impl Resumed {
8173 /// Whether the connection has another page.
8174 const fn has_more(&self) -> bool {
8175 matches!(self, Self::More(_))
8176 }
8177
8178 /// The cursor to send this connection next.
8179 fn cursor(self) -> Option<String> {
8180 match self {
8181 Self::More(next) => Some(next),
8182 Self::Ended(last) => last,
8183 }
8184 }
8185}
8186
8187/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8188/// with no cursor to it, or from a cursor that does not advance.
8189fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8190 let info = connection
8191 .get("pageInfo")
8192 .ok_or_else(|| SourceError::Malformed {
8193 message: "GitHub connection has no pageInfo".into(),
8194 })?;
8195 let end = optional_str(info, "endCursor")?;
8196 if required_bool(info, "hasNextPage")? {
8197 let next = end.ok_or_else(|| SourceError::Malformed {
8198 message: "GitHub connection reports another page and no endCursor".into(),
8199 })?;
8200 validate_cursor_progress(after, next)?;
8201 return Ok(Resumed::More(next.to_owned()));
8202 }
8203 Ok(Resumed::Ended(
8204 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8205 ))
8206}
8207
8208/// The board, and every item on it this source reports.
8209#[derive(Clone)]
8210struct Board {
8211 id: String,
8212 fields: Value,
8213 items: Vec<Resolved>,
8214}
8215
8216/// What a write needs of the board and nothing more: its node id and its field
8217/// definitions, in the shape a read of the board's own `fields` gives them.
8218///
8219/// Deliberately no items. A write decides which item it writes, which parent it files
8220/// under and which far ends it names by reading each of them by its own id; this is the
8221/// half of the board those reads cannot carry, and holding no item is what keeps it from
8222/// ever being asked whether an item is there.
8223#[derive(Clone)]
8224struct BoardFields {
8225 id: BoardId,
8226 fields: Value,
8227}
8228
8229/// A board's node id: what a field write and `addProjectV2ItemById` address.
8230///
8231/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8232/// refused where it is read, and one an item names blank is read as not named at all.
8233#[derive(Clone)]
8234struct BoardId(String);
8235
8236/// Where one write left its item, for the record the rest of the command reads it out of.
8237///
8238/// A named record rather than a tuple because the update arm and the create arm each fill
8239/// all four, and two `Option`s of different meaning side by side in a tuple are two
8240/// positions a reader has to count.
8241struct Landed {
8242 /// The issue's own node id, which is the [`NativeId`] this source reports.
8243 content_id: NativeId,
8244 /// The board item's id, which is what a field write addresses.
8245 // 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.
8246 item_id: String,
8247 /// The web address GitHub gave the issue, when it gave one.
8248 // 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.
8249 url: Option<String>,
8250 /// The issue's number on its repository, when GitHub reported one.
8251 number: Option<u64>,
8252}
8253
8254impl BoardId {
8255 fn parse(id: &str) -> Result<Self, SourceError> {
8256 if id.trim().is_empty() {
8257 return Err(SourceError::Malformed {
8258 message: "GitHub named a board with a blank node id".into(),
8259 });
8260 }
8261 Ok(Self(id.to_owned()))
8262 }
8263
8264 fn as_str(&self) -> &str {
8265 &self.0
8266 }
8267}
8268
8269impl Board {
8270 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8271 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8272 let nodes = fields
8273 .get("nodes")
8274 .and_then(Value::as_array)
8275 .ok_or_else(|| SourceError::Malformed {
8276 message: "GitHub project fields.nodes is not an array".into(),
8277 })?;
8278 Ok(nodes
8279 .iter()
8280 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8281 }
8282}
8283
8284/// One board item, resolved into everything this source reports about it.
8285#[derive(Clone)]
8286struct Resolved {
8287 item_id: String,
8288 id: NativeId,
8289 content_kind: ContentKind,
8290 kind: BoardKind,
8291 title: String,
8292 body: Option<String>,
8293 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8294 /// that changes the slot alone has to keep byte for byte outside it.
8295 raw_body: Option<String>,
8296 status: Status,
8297 /// The name of the board `Status` option this item sits in, as the board spells it.
8298 option: Option<String>,
8299 /// What its `Priority` field says, read through this instance's mapping.
8300 priority: HeldPriority,
8301 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8302 closed: bool,
8303 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8304 delivers: Vec<TaskRef>,
8305 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8306 /// task.
8307 delivered_by: Vec<TaskRef>,
8308 labels: Vec<Label>,
8309 parent: Option<NativeId>,
8310 // 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.
8311 origin: Option<String>,
8312 /// The issue's own number on its repository, as GitHub reports it.
8313 ///
8314 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8315 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8316 /// an issue this run created whose creating mutation answered without one, which is a
8317 /// response GitHub's own schema says cannot happen and which a landed write is not
8318 /// worth failing over. An `Issue` read off the board always has one.
8319 number: Option<u64>,
8320 url: Option<String>,
8321 created_at: Option<DateTime<Utc>>,
8322 updated_at: Option<DateTime<Utc>>,
8323 own_repository: Option<Repository>,
8324 repositories: Vec<Repository>,
8325 slot: BTreeMap<String, Value>,
8326 /// The node id of the board this item sits on, when the read that reached it said.
8327 board_id: Option<String>,
8328 /// The definition of every board field this item holds a value of, in the shape a read
8329 /// of the board's own `fields` gives one.
8330 ///
8331 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8332 /// which says nothing about whether the board has it.
8333 fields: Vec<Value>,
8334 /// Every field the board this item sits on defines, as its own read of the board's
8335 /// `fields` gives them — when the read that reached the item carried them, which a read
8336 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8337 /// of the board.
8338 board_fields: Option<Value>,
8339 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8340 /// selects one — when the read that reached it carried the connection to its end, which a
8341 /// read of it by its own id does for any issue blocked by no more than a page. What a
8342 /// write reconciles that relationship against, and what a read of its forward edges in
8343 /// the same command answers with.
8344 blocked_by: Option<Vec<Value>>,
8345}
8346
8347impl Resolved {
8348 /// The board this item's own read names it on, when that read named one this source can
8349 /// address.
8350 fn named_board(&self) -> Option<BoardId> {
8351 self.board_id
8352 .as_deref()
8353 .and_then(|id| BoardId::parse(id).ok())
8354 }
8355
8356 /// The board's id and every field it defines, when the read that reached this item
8357 /// carried both — which a read of it by its own id does.
8358 fn carried_board(&self) -> Option<BoardFields> {
8359 Some(BoardFields {
8360 id: self.named_board()?,
8361 fields: self.board_fields.clone()?,
8362 })
8363 }
8364
8365 /// Whether this item holds a value of the board field called `name`, and so carries
8366 /// that field's definition. `false` says nothing about whether the board has the field.
8367 fn defines(&self, name: &str) -> bool {
8368 self.fields
8369 .iter()
8370 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8371 }
8372
8373 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8374 /// in a field of its own, and none of the five keys that are only an encoding.
8375 ///
8376 /// The two delivery keys are left out for every kind, not only for a task: they are
8377 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8378 /// document carrying one holds nothing a caller's own metadata could mean by it.
8379 fn metadata(&self) -> BTreeMap<String, Value> {
8380 let mut metadata = self.slot.clone();
8381 metadata.remove(Repository::METADATA_KEY);
8382 metadata.remove(DependencyEdge::RECORDED_KEY);
8383 metadata.remove(ItemKind::METADATA_KEY);
8384 metadata.remove(TaskRef::DELIVERS_KEY);
8385 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8386 // The board field is the origin, and the body's copy of it is only a mirror for the
8387 // issue search to find: an item whose field holds none has none, whatever its body
8388 // says, so no reader ever sees two answers.
8389 metadata.remove(ORIGIN_KEY);
8390 if let Some(origin) = &self.origin {
8391 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8392 }
8393 metadata
8394 }
8395
8396 /// Where this item is, as a link a reader can open.
8397 ///
8398 /// A board is a hosted place and every issue on it has a web address, so that address
8399 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8400 /// of place it is, so a reader knows to open it rather than to read a file out. It
8401 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8402 /// reported before, and this says what that address *is*.
8403 ///
8404 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8405 /// rather than a third variant, which is the contract's "the source did not say". An
8406 /// issue this run created is not one of those: its address comes back from the
8407 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8408 /// rather than from whenever the board read catches up.
8409 fn location(&self) -> Option<Location> {
8410 self.url.clone().map(Location::Url)
8411 }
8412
8413 /// The short handle this board's backend shows people for a task: the issue's number
8414 /// alone, as a decimal string.
8415 ///
8416 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8417 /// value for this backend. A draft has no number and so no handle, which is the
8418 /// contract's *absent* rather than a handle of some other shape — and the native
8419 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8420 /// derives from.
8421 fn key(&self) -> Option<String> {
8422 self.number.map(|number| number.to_string())
8423 }
8424
8425 /// Whether its `Priority` field holds a value at all, mapped or not.
8426 fn holds_priority(&self) -> bool {
8427 self.priority != HeldPriority::Read(Priority::None)
8428 }
8429
8430 /// The task this item is.
8431 ///
8432 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8433 /// reading that as a level would be a guess, and reading it as `none` would let the next
8434 /// copy clear a priority a person set.
8435 fn task(&self) -> Result<Task, SourceError> {
8436 let priority = match &self.priority {
8437 HeldPriority::Read(priority) => *priority,
8438 HeldPriority::Unmapped(option) => {
8439 return Err(SourceError::Malformed {
8440 message: format!(
8441 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8442 this source's priority_mapping does not name, so its priority cannot be \
8443 read; next: name {option:?} under priority_mapping, or move the item to \
8444 a mapped option",
8445 self.id,
8446 self.number
8447 .map(|number| format!(" (#{number})"))
8448 .unwrap_or_default()
8449 ),
8450 });
8451 }
8452 };
8453 Ok(Task {
8454 id: self.id.clone(),
8455 key: self.key(),
8456 title: self.title.clone(),
8457 content: self.body.clone(),
8458 status: self.status.clone(),
8459 priority,
8460 labels: self.labels.clone(),
8461 project: self.parent.clone(),
8462 url: self.url.clone(),
8463 location: self.location(),
8464 created_at: self.created_at,
8465 updated_at: self.updated_at,
8466 metadata: self.metadata(),
8467 repositories: self.repositories.clone(),
8468 delivers: self.delivers.clone(),
8469 delivered_by: self.delivered_by.clone(),
8470 })
8471 }
8472
8473 fn project(&self) -> Project {
8474 Project {
8475 id: self.id.clone(),
8476 title: self.title.clone(),
8477 content: self.body.clone(),
8478 status: self.status.clone(),
8479 labels: self.labels.clone(),
8480 url: self.url.clone(),
8481 location: self.location(),
8482 created_at: self.created_at,
8483 updated_at: self.updated_at,
8484 metadata: self.metadata(),
8485 repositories: self.repositories.clone(),
8486 }
8487 }
8488
8489 /// The same issue as a document: the project it is filed under, and no status and no
8490 /// dependencies, because a document is not work.
8491 fn document(&self) -> Document {
8492 Document {
8493 id: self.id.clone(),
8494 title: self.title.clone(),
8495 content: self.body.clone(),
8496 project: self.parent.clone(),
8497 labels: self.labels.clone(),
8498 url: self.url.clone(),
8499 location: self.location(),
8500 created_at: self.created_at,
8501 updated_at: self.updated_at,
8502 metadata: self.metadata(),
8503 repositories: self.repositories.clone(),
8504 }
8505 }
8506}
8507
8508/// Where one targeted update moves an item's status, and which of its two halves move.
8509struct StatusMove {
8510 /// The board the item's `Status` field is on.
8511 board: BoardId,
8512 /// The `Status` field's id.
8513 field: String,
8514 /// The option's id.
8515 option: String,
8516 /// The option's name, as the board spells it.
8517 name: String,
8518 /// What the status asks of the issue's state.
8519 target: StatusTarget,
8520 /// The status the item reads as once it is there.
8521 landed: Status,
8522 /// Which of the status's two halves differ from what the item holds.
8523 moves: Moves,
8524}
8525
8526/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8527/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8528/// and is not a value of this type.
8529#[derive(Clone, Copy, PartialEq, Eq)]
8530enum Moves {
8531 /// The option alone.
8532 Option,
8533 /// The issue's state alone: open, closed, or closed with another reason.
8534 State,
8535 /// Both.
8536 Both,
8537}
8538
8539impl Moves {
8540 /// What differs, or `None` when nothing does.
8541 const fn of(option: bool, state: bool) -> Option<Self> {
8542 match (option, state) {
8543 (true, true) => Some(Self::Both),
8544 (true, false) => Some(Self::Option),
8545 (false, true) => Some(Self::State),
8546 (false, false) => None,
8547 }
8548 }
8549
8550 /// Whether the option moves.
8551 const fn option(self) -> bool {
8552 matches!(self, Self::Option | Self::Both)
8553 }
8554
8555 /// Whether the issue's state moves.
8556 const fn state(self) -> bool {
8557 matches!(self, Self::State | Self::Both)
8558 }
8559}
8560
8561/// What one write is, and the status that comes with being it.
8562///
8563/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8564/// status and a task or a project always has one, so "a document carrying a status" and
8565/// "a task carrying none" are states a write cannot be in rather than states every use
8566/// site below has to defend against.
8567enum Written<'a> {
8568 /// A document, which is not work and so has no status at all.
8569 Document,
8570 /// A task or a project, and the status it is being written with.
8571 Work(ItemKind, &'a Status),
8572}
8573
8574impl Written<'_> {
8575 /// Which of the board's three kinds this write is.
8576 const fn kind(&self) -> BoardKind {
8577 match self {
8578 Self::Document => BoardKind::Document,
8579 Self::Work(kind, _) => BoardKind::Work(*kind),
8580 }
8581 }
8582
8583 /// The status this write carries. A document carries none, so a write of one says
8584 /// nothing about the issue's open or closed state and selects no board `Status`
8585 /// option.
8586 const fn status(&self) -> Option<&Status> {
8587 match self {
8588 Self::Document => None,
8589 Self::Work(_, status) => Some(status),
8590 }
8591 }
8592
8593 /// The status this write carries with the kind whose half of `status_mapping` it is
8594 /// written through.
8595 const fn work_status(&self) -> Option<(ItemKind, &Status)> {
8596 match self {
8597 Self::Document => None,
8598 Self::Work(kind, status) => Some((*kind, status)),
8599 }
8600 }
8601}
8602
8603/// The item being written, in the one shape all three write methods reach.
8604struct Incoming<'a> {
8605 written: Written<'a>,
8606 /// The title a person wrote. A document's goes onto the issue with
8607 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8608 title: &'a str,
8609 content: Option<&'a str>,
8610 labels: &'a [Label],
8611 metadata: &'a BTreeMap<String, Value>,
8612 repositories: &'a [Repository],
8613 parent: Option<&'a NativeId>,
8614 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8615 /// what keeps either key out of their slot.
8616 delivers: &'a [TaskRef],
8617 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8618 delivered_by: &'a [TaskRef],
8619 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8620 /// project, a document, and every write to an instance with no `priority_mapping` —
8621 /// which is what keeps such a write's requests exactly what they were before.
8622 priority: Option<Priority>,
8623}
8624
8625/// What one write does to an item's `Priority` field.
8626enum PriorityWrite {
8627 /// Select this option of this field.
8628 Select {
8629 /// The `Priority` field's id.
8630 field: String,
8631 /// The mapped option's id.
8632 option: String,
8633 },
8634 /// Clear the field's value, which is what `none` is.
8635 Clear {
8636 /// The `Priority` field's id.
8637 field: String,
8638 },
8639}
8640
8641impl Incoming<'_> {
8642 /// The title this write puts on the issue.
8643 fn written_title(&self) -> String {
8644 match self.written {
8645 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8646 Written::Work(..) => self.title.to_owned(),
8647 }
8648 }
8649}
8650
8651#[derive(Clone, Copy, PartialEq, Eq)]
8652enum ContentKind {
8653 DraftIssue,
8654 Issue,
8655}
8656
8657/// What one board issue is: a document, or the work an [`ItemKind`] names.
8658///
8659/// A type of this source's own rather than an `ItemKind` with a third variant, because
8660/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8661/// document — the contract keeps a document out of that enum deliberately. Holding the
8662/// board's three answers in one value is what makes every place that asks "which is this?"
8663/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8664/// two thirds of the board.
8665#[derive(Clone, Copy, PartialEq, Eq)]
8666enum BoardKind {
8667 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8668 Document,
8669 /// Every other issue, and every draft.
8670 Work(ItemKind),
8671}
8672
8673impl BoardKind {
8674 /// Whose half of `status_mapping` an item of this kind reads its status through. A
8675 /// document has no status of its own, so the task half stands in for whatever the issue
8676 /// holds; nothing reports it.
8677 const fn status_kind(self) -> ItemKind {
8678 match self {
8679 Self::Document => ItemKind::Task,
8680 Self::Work(kind) => kind,
8681 }
8682 }
8683
8684 /// How a refusal names this kind to the person reading it.
8685 const fn describes(self) -> &'static str {
8686 match self {
8687 Self::Document => "document",
8688 Self::Work(kind) => kind.marker(),
8689 }
8690 }
8691}
8692
8693/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8694///
8695/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8696/// the shared cross-source journeys assert one answer to one question, so two sources
8697/// that disagree about what "carries the label bug" means fail them.
8698fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8699 let holds = |name: &String| {
8700 labels
8701 .iter()
8702 .any(|label| label.name.eq_ignore_ascii_case(name))
8703 };
8704 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8705 && filter.all_of.iter().all(holds)
8706 && !filter.none_of.iter().any(holds)
8707}
8708
8709/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8710/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8711fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8712 statuses.is_empty() || statuses.contains(&category)
8713}
8714
8715/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8716///
8717/// `content` is the item's own prose — the body with this source's trailing metadata
8718/// comment already taken off — so a search never matches an encoding the author of the
8719/// issue never wrote.
8720fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8721 let terms = query.terms.to_lowercase();
8722 let in_title = title.to_lowercase().contains(&terms);
8723 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8724 match query.fields {
8725 TextFields::Title => in_title,
8726 TextFields::Content => in_content,
8727 TextFields::TitleOrContent => in_title || in_content,
8728 }
8729}
8730
8731/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8732///
8733/// The project predicate is passed separately because a read narrowed to one project has
8734/// already answered it by asking *that project* for its own items — and re-applying it
8735/// there would compare the caller's selector, which may be a project's **name**, against
8736/// the id of the project that name resolved to, and keep nothing. Every other read passes
8737/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8738/// source really does apply.
8739fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8740 labels_match(&task.labels, &query.labels)
8741 && status_matches(task.status.category, &query.statuses)
8742 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8743 && match project {
8744 ProjectFilter::Any => true,
8745 ProjectFilter::Orphans => task.project.is_none(),
8746 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8747 }
8748 && query
8749 .text
8750 .as_ref()
8751 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8752 // Against the parsed metadata slot, and against the origin field, which is where
8753 // `Resolved::metadata` reads each of them from.
8754 && query.metadata_matches(&task.metadata)
8755 && query.origin_matches(&task.metadata)
8756}
8757
8758fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8759 labels_match(&project.labels, &query.labels)
8760 && status_matches(project.status.category, &query.statuses)
8761 && query
8762 .text
8763 .as_ref()
8764 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8765}
8766
8767/// The same three predicates a task query carries, minus the status filter.
8768///
8769/// A document is not work, so it has no status for one to compare against and the query
8770/// type carries none. The project predicate is the same one — a design issue filed under a
8771/// project issue is in that project, and one filed under nothing is in none — so it is
8772/// spelled the same way here rather than answered differently.
8773fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8774 labels_match(&document.labels, &query.labels)
8775 && match project {
8776 ProjectFilter::Any => true,
8777 ProjectFilter::Orphans => document.project.is_none(),
8778 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8779 }
8780 && query
8781 .text
8782 .as_ref()
8783 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8784}
8785
8786#[async_trait::async_trait]
8787impl TaskSource for GitHubProjectsSource {
8788 fn kind(&self) -> &'static str {
8789 KIND
8790 }
8791 fn capabilities(&self) -> Capabilities {
8792 Capabilities {
8793 projects: Support::Native,
8794 documents: Support::Native,
8795 comments: Support::Native,
8796 priority: if self.priorities.is_some() {
8797 Support::Native
8798 } else {
8799 Support::Unsupported
8800 },
8801 filter_by_priority: Support::Native,
8802 filter_by_comment_activity: Support::Native,
8803 filter_by_metadata: Support::Native,
8804 filter_by_origin: Support::Native,
8805 orphan_tasks: Support::Native,
8806 filter_by_label: Support::Native,
8807 filter_by_status: Support::Native,
8808 search_title: Support::Native,
8809 search_content: Support::Native,
8810 task_dependencies: DependencySupport::BothDirections,
8811 project_dependencies: DependencySupport::BothDirections,
8812 max_page_size: MAX_PAGE_SIZE,
8813 }
8814 }
8815 async fn health(&self) -> Result<Health, SourceError> {
8816 let board = self.board_page(None, 1).await?;
8817 Ok(Health {
8818 reachable: true,
8819 detail: Some(format!(
8820 "reading GitHub project {}/{} ({})",
8821 self.owner,
8822 self.project_number,
8823 required_str(&board, "title")?
8824 )),
8825 })
8826 }
8827 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8828 self.item_by_id(id)
8829 .await?
8830 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8831 .map(|item| item.task())
8832 .transpose()
8833 }
8834 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8835 Ok(self
8836 .item_by_id(id)
8837 .await?
8838 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8839 .map(|item| item.project()))
8840 }
8841 async fn query_tasks(
8842 &self,
8843 query: &TaskQuery,
8844 page: &PageRequest,
8845 ) -> Result<Page<Task>, SourceError> {
8846 validate_page(page)?;
8847 refuse_unsearchable(query)?;
8848 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8849 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8850 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8851 (Some(also), None) => Some(also),
8852 (None, Some(since)) => Some(updated_qualifier(since)),
8853 (None, None) => None,
8854 };
8855 if let Some(also) = qualifiers {
8856 return self.search_tasks(query, page, &also).await;
8857 }
8858 }
8859
8860 // A read narrowed to one project asks that project for its own tasks, so nothing
8861 // about it costs what the rest of the board holds. A read carrying a text, metadata
8862 // or origin predicate asks GitHub the narrower question those predicates are, and a
8863 // read narrowed to comment activity alone asks the board's own issue search for the
8864 // issues updated since, which is every issue a comment could have been written or
8865 // edited on since. Every other task read is a question about the whole board and is
8866 // answered by reading it.
8867 let (held, membership) = match (&query.project, query.commented_since) {
8868 (ProjectFilter::Is(project), _) => (
8869 self.project_children(project).await?,
8870 // Answered by where these items came from; see `task_matches`.
8871 &ProjectFilter::Any,
8872 ),
8873 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8874 match (self.narrowed(query).await?, since) {
8875 (Some(narrowed), _) => (narrowed, &query.project),
8876 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8877 (None, None) => (self.board().await?.items, &query.project),
8878 }
8879 }
8880 };
8881 // Filtered before paged: a page of a filtered result is a page of the survivors,
8882 // never the survivors of a page.
8883 let mut tasks = Vec::new();
8884 for item in held
8885 .iter()
8886 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8887 {
8888 let task = item.task()?;
8889 if task_matches(&task, query, membership)
8890 && self.commented_since(item, query.commented_since).await?
8891 {
8892 tasks.push(task);
8893 }
8894 }
8895 Ok(offset_page(
8896 tasks,
8897 numeric_cursor(page.cursor.as_ref())?,
8898 page.limit.min(MAX_PAGE_SIZE) as usize,
8899 ))
8900 }
8901 async fn query_projects(
8902 &self,
8903 query: &ProjectQuery,
8904 page: &PageRequest,
8905 ) -> Result<Page<Project>, SourceError> {
8906 validate_page(page)?;
8907 refuse_unsearchable_text(query.text.as_ref())?;
8908 // The projects a board holds are found by an issue search scoped to that board,
8909 // never by walking the board's own item connection: what tells a project from a
8910 // task is the `parent` each issue carries, which costs nothing to read. A query
8911 // carrying a text asks that search for the text too, so it reads the issues that
8912 // hold it rather than every issue of the board.
8913 let held = match self.text_searched(query.text.as_ref()).await? {
8914 Some(searched) => searched,
8915 None => self.board_issues().await?,
8916 };
8917 let projects = held
8918 .iter()
8919 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8920 .map(Resolved::project)
8921 .filter(|project| project_matches(project, query))
8922 .collect();
8923 Ok(offset_page(
8924 projects,
8925 numeric_cursor(page.cursor.as_ref())?,
8926 page.limit.min(MAX_PAGE_SIZE) as usize,
8927 ))
8928 }
8929 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8930 Ok(self
8931 .item_by_id(id)
8932 .await?
8933 .filter(|item| item.kind == BoardKind::Document)
8934 .map(|item| item.document()))
8935 }
8936 async fn query_documents(
8937 &self,
8938 query: &DocumentQuery,
8939 page: &PageRequest,
8940 ) -> Result<Page<Document>, SourceError> {
8941 validate_page(page)?;
8942 // Narrowed to one project, this is the same sub-issue read a task list scoped to
8943 // that project makes — a document filed under a project is a sub-issue of it too,
8944 // and which of them come back is the kind this caller asked for. Unscoped, a query
8945 // carrying a text asks the board-scoped issue search for it, as a task query does,
8946 // and only one carrying none reads the board.
8947 let (held, membership) = match &query.project {
8948 ProjectFilter::Is(project) => (
8949 self.project_children(project).await?,
8950 // Answered by where these items came from; see `task_matches`.
8951 &ProjectFilter::Any,
8952 ),
8953 ProjectFilter::Any | ProjectFilter::Orphans => {
8954 refuse_unsearchable_text(query.text.as_ref())?;
8955 match self.text_searched(query.text.as_ref()).await? {
8956 Some(searched) => (searched, &query.project),
8957 None => (self.board().await?.items, &query.project),
8958 }
8959 }
8960 };
8961 // Filtered before paged, exactly as a task read is: a page of a filtered result is
8962 // a page of the survivors, never the survivors of a page.
8963 let documents = held
8964 .iter()
8965 .filter(|item| item.kind == BoardKind::Document)
8966 .map(Resolved::document)
8967 .filter(|document| document_matches(document, query, membership))
8968 .collect();
8969 Ok(offset_page(
8970 documents,
8971 numeric_cursor(page.cursor.as_ref())?,
8972 page.limit.min(MAX_PAGE_SIZE) as usize,
8973 ))
8974 }
8975 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8976 validate_page(page)?;
8977 let offset = numeric_cursor(page.cursor.as_ref())?;
8978 let mut labels = self
8979 .board()
8980 .await?
8981 .items
8982 .into_iter()
8983 .flat_map(|item| item.labels)
8984 .fold(Vec::new(), |mut all, label| {
8985 if !all.iter().any(|x: &Label| x.id == label.id) {
8986 all.push(label);
8987 }
8988 all
8989 });
8990 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8991 Ok(offset_page(
8992 labels,
8993 offset,
8994 page.limit.min(MAX_PAGE_SIZE) as usize,
8995 ))
8996 }
8997 async fn task_dependencies(
8998 &self,
8999 id: &NativeId,
9000 direction: Direction,
9001 page: &PageRequest,
9002 ) -> Result<Page<DependencyEdge>, SourceError> {
9003 self.dependencies(id, ItemKind::Task, direction, page).await
9004 }
9005 async fn project_dependencies(
9006 &self,
9007 id: &NativeId,
9008 direction: Direction,
9009 page: &PageRequest,
9010 ) -> Result<Page<DependencyEdge>, SourceError> {
9011 self.dependencies(id, ItemKind::Project, direction, page)
9012 .await
9013 }
9014
9015 fn writes(&self) -> WriteSupport {
9016 WriteSupport::Supported
9017 }
9018
9019 /// Create or update one task.
9020 ///
9021 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
9022 /// neither may name the task itself or name one task twice — and land in the body's
9023 /// metadata slot under their reserved keys, in place of any caller metadata of those
9024 /// names.
9025 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
9026 let near = write.target.as_ref().unwrap_or(&write.item.id);
9027 for (key, entries) in [
9028 (TaskRef::DELIVERS_KEY, &write.item.delivers),
9029 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
9030 ] {
9031 TaskRef::listed(key, near, Some(&self.name), entries.clone())
9032 .map_err(|message| SourceError::Refused { message })?;
9033 }
9034 if self.priorities.is_none() && write.item.priority != Priority::None {
9035 return Err(self.holds_no_priority());
9036 }
9037 self.write_item(
9038 &Incoming {
9039 written: Written::Work(ItemKind::Task, &write.item.status),
9040 title: &write.item.title,
9041 content: write.item.content.as_deref(),
9042 labels: &write.item.labels,
9043 metadata: &write.item.metadata,
9044 repositories: &write.item.repositories,
9045 parent: write.item.project.as_ref(),
9046 delivers: &write.item.delivers,
9047 delivered_by: &write.item.delivered_by,
9048 priority: self.priorities.as_ref().map(|_| write.item.priority),
9049 },
9050 write.target.as_ref(),
9051 &write.depends_on,
9052 )
9053 .await
9054 }
9055
9056 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
9057 self.write_item(
9058 &Incoming {
9059 written: Written::Work(ItemKind::Project, &write.item.status),
9060 title: &write.item.title,
9061 content: write.item.content.as_deref(),
9062 labels: &write.item.labels,
9063 metadata: &write.item.metadata,
9064 repositories: &write.item.repositories,
9065 parent: None,
9066 delivers: &[],
9067 delivered_by: &[],
9068 priority: None,
9069 },
9070 write.target.as_ref(),
9071 &write.depends_on,
9072 )
9073 .await
9074 }
9075
9076 /// Create or update one document, which is one issue titled the way this board spells
9077 /// a document.
9078 ///
9079 /// Everything else is exactly a task write: caller metadata goes to the same canonical
9080 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
9081 /// or a field this board cannot carry is refused by name rather than dropped, a target
9082 /// naming an issue this board does not hold is refused rather than created, and an
9083 /// issue this call created is taken back when the rest of the write fails.
9084 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
9085 // A document takes part in no dependency graph, so there is no far end to write
9086 // natively and none to record: a caller naming one is told so rather than having it
9087 // stored under the reserved key, where a later read would report an edge the
9088 // contract says cannot exist.
9089 if !write.depends_on.is_empty() {
9090 return Err(SourceError::Refused {
9091 message: format!(
9092 "this write names {} dependencies for a document, and a document takes \
9093 part in no dependency graph; next: put the dependency on the task or \
9094 project the document is about",
9095 write.depends_on.len()
9096 ),
9097 });
9098 }
9099 self.write_item(
9100 &Incoming {
9101 written: Written::Document,
9102 title: &write.item.title,
9103 content: write.item.content.as_deref(),
9104 labels: &write.item.labels,
9105 metadata: &write.item.metadata,
9106 repositories: &write.item.repositories,
9107 parent: write.item.project.as_ref(),
9108 delivers: &[],
9109 delivered_by: &[],
9110 priority: None,
9111 },
9112 write.target.as_ref(),
9113 &[],
9114 )
9115 .await
9116 }
9117
9118 /// Refused exactly as the write refuses it, from what the write reads: the mapping first,
9119 /// which reads nothing; then the board's `Status` option. Over an existing item that is
9120 /// read off the item, as the write reads it, and the item is held among this command's
9121 /// resolved records so the write that follows reuses that read rather than repeating it;
9122 /// an item that does not carry the field takes the board's fields, which are held once
9123 /// read. A create is checked against the board's fields only when this command already
9124 /// holds them, because a create reads them together with its repository, in one request,
9125 /// and refuses a missing option before it writes anything.
9126 async fn check_status_write(
9127 &self,
9128 kind: ItemKind,
9129 category: StatusCategory,
9130 target: Option<&NativeId>,
9131 ) -> Result<(), SourceError> {
9132 let status = self.resolved_target(kind, category)?;
9133 if status.option().is_none() {
9134 return Ok(());
9135 }
9136 let fields = match target {
9137 Some(target) => {
9138 // A target this board does not hold is the write's own refusal to make.
9139 let Some(item) = self.bound_item(target).await? else {
9140 return Ok(());
9141 };
9142 self.resolved_cache()?.insert(target.clone(), item.clone());
9143 self.fields_for(Some(&item), true, false).await?.fields
9144 }
9145 None => {
9146 let held = self
9147 .board_cache()?
9148 .as_ref()
9149 .map(|board| board.fields.clone());
9150 match held.or_else(|| {
9151 self.fields_cache()
9152 .ok()
9153 .and_then(|cache| cache.as_ref().map(|board| board.fields.clone()))
9154 }) {
9155 Some(fields) => fields,
9156 None => return Ok(()),
9157 }
9158 }
9159 };
9160 self.column_for(&fields, kind, category, &status)
9161 .map(|_| ())
9162 }
9163
9164 /// Set one task's status alone.
9165 ///
9166 /// An open target reopens a closed issue with an `updateIssue` carrying only its
9167 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
9168 /// terminal target selects its mapped option, then closes with its fixed reason. No
9169 /// request carries a title, a body or a label. The status
9170 /// answered is what [`BoardStatuses::status`] reads off the state just written, which is
9171 /// what a re-read reports.
9172 async fn set_task_status(
9173 &self,
9174 id: &NativeId,
9175 category: StatusCategory,
9176 ) -> Result<Option<Status>, SourceError> {
9177 self.set_status(id, category).await
9178 }
9179
9180 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
9181 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
9182 /// for `none`. Refused by an instance with no `priority_mapping`.
9183 async fn set_task_priority(
9184 &self,
9185 id: &NativeId,
9186 priority: Priority,
9187 ) -> Result<Option<Priority>, SourceError> {
9188 self.set_priority(id, priority).await
9189 }
9190
9191 /// Replace one task's content with a single body update that keeps the metadata slot
9192 /// byte for byte.
9193 async fn set_task_content(
9194 &self,
9195 id: &NativeId,
9196 content: &str,
9197 ) -> Result<Option<()>, SourceError> {
9198 self.replace_content(id, content).await
9199 }
9200
9201 /// Replace one task issue's content and its provenance slot entry with a single body
9202 /// update. The answers are not kept: see `replace_rendering`.
9203 async fn set_task_rendering(
9204 &self,
9205 id: &NativeId,
9206 content: &str,
9207 provenance: &Value,
9208 _answers: &BTreeMap<String, Value>,
9209 ) -> Result<Option<()>, SourceError> {
9210 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
9211 .await
9212 }
9213
9214 /// Replace one design-document issue's content and its provenance slot entry, on exactly
9215 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
9216 async fn set_document_rendering(
9217 &self,
9218 id: &NativeId,
9219 content: &str,
9220 provenance: &Value,
9221 _answers: &BTreeMap<String, Value>,
9222 ) -> Result<Option<()>, SourceError> {
9223 self.replace_rendering(id, BoardKind::Document, content, provenance)
9224 .await
9225 }
9226
9227 /// Apply a targeted update with one read of the item and a write only for what differs:
9228 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
9229 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
9230 async fn update_task(
9231 &self,
9232 id: &NativeId,
9233 update: &TaskUpdate,
9234 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
9235 self.targeted_update(id, update).await
9236 }
9237
9238 /// Replace one task's `delivered_by` with a single body update that changes the
9239 /// metadata slot and nothing outside it.
9240 async fn set_delivered_by(
9241 &self,
9242 id: &NativeId,
9243 delivered_by: &[TaskRef],
9244 ) -> Result<Option<()>, SourceError> {
9245 self.replace_delivered_by(id, delivered_by).await
9246 }
9247
9248 /// Set one key of one task issue's metadata with a single body update that changes the
9249 /// metadata slot and nothing outside it — no title, label, state or board field request —
9250 /// and sends nothing when the task already holds that value under the key.
9251 async fn set_task_metadata(
9252 &self,
9253 id: &NativeId,
9254 key: &MetadataKey,
9255 value: &Value,
9256 ) -> Result<Option<Task>, SourceError> {
9257 Ok(self
9258 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9259 .await?
9260 .map(|item| item.task())
9261 .transpose()?)
9262 }
9263
9264 /// Set one key of one project issue's metadata, on exactly the terms of
9265 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9266 async fn set_project_metadata(
9267 &self,
9268 id: &NativeId,
9269 key: &MetadataKey,
9270 value: &Value,
9271 ) -> Result<Option<Project>, SourceError> {
9272 Ok(self
9273 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9274 .await?
9275 .map(|item| item.project()))
9276 }
9277
9278 /// Set one key of one design-document issue's metadata, on exactly the terms of
9279 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9280 async fn set_document_metadata(
9281 &self,
9282 id: &NativeId,
9283 key: &MetadataKey,
9284 value: &Value,
9285 ) -> Result<Option<Document>, SourceError> {
9286 Ok(self
9287 .set_slot_key(id, BoardKind::Document, key, value)
9288 .await?
9289 .map(|item| item.document()))
9290 }
9291
9292 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9293 self.delete_item(id).await
9294 }
9295
9296 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9297 self.delete_item(id).await
9298 }
9299
9300 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9301 self.delete_item(id).await
9302 }
9303
9304 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9305 ///
9306 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9307 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9308 ///
9309 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9310 /// board is the read of its comments. A draft this process already resolved is refused
9311 /// without one.
9312 async fn task_comments(
9313 &self,
9314 task: &NativeId,
9315 page: &PageRequest,
9316 ) -> Result<Option<Page<Comment>>, SourceError> {
9317 validate_page(page)?;
9318 let cached = self.resolved_cache()?.get(task).cloned();
9319 if let Some(item) = cached {
9320 if item.kind != BoardKind::Work(ItemKind::Task) {
9321 return Ok(None);
9322 }
9323 if item.content_kind == ContentKind::DraftIssue {
9324 return Err(self.draft_has_no_comments(task));
9325 }
9326 }
9327 match self.issue_detail(task, page).await? {
9328 Some(TaskDetailRead {
9329 comments: Some(comments),
9330 ..
9331 }) => comments,
9332 _ => Ok(None),
9333 }
9334 }
9335
9336 /// Every id's task, with the first page of its comments when `comments` names it:
9337 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9338 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9339 async fn get_task_details(
9340 &self,
9341 ids: &[NativeId],
9342 comments: Option<&PageRequest>,
9343 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9344 if let Some(page) = comments
9345 && let Err(error) = validate_page(page)
9346 {
9347 return ids.iter().map(|_| Err(error.clone())).collect();
9348 }
9349 match (ids, comments) {
9350 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9351 ([id], None) => vec![self.task_read(id).await],
9352 _ => self.issue_details(ids, comments).await,
9353 }
9354 }
9355
9356 /// Add one comment to the task's issue, as the account the token belongs to.
9357 ///
9358 /// The author is refused before anything is sent — not even the task is read — because
9359 /// no answer GitHub could give would make posting under another name than the one asked
9360 /// for the right outcome.
9361 async fn add_comment(
9362 &self,
9363 task: &NativeId,
9364 comment: &NewComment,
9365 ) -> Result<Option<Comment>, SourceError> {
9366 if let Some(author) = &comment.author {
9367 return Err(SourceError::Refused {
9368 message: format!(
9369 "source {} cannot post a comment as {author:?}: GitHub records the account \
9370 the token signs in as the author of every comment; next: leave --author \
9371 out, and the comment is posted as that account",
9372 self.name
9373 ),
9374 });
9375 }
9376 let Some(issue) = self.commented_issue(task).await? else {
9377 return Ok(None);
9378 };
9379 let data = self
9380 .graphql(
9381 graphql::ADD_COMMENT,
9382 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9383 )
9384 .await?;
9385 let subject = data
9386 .pointer("/addComment/subject")
9387 .filter(|value| !value.is_null())
9388 .ok_or_else(|| SourceError::Malformed {
9389 message: "GitHub comment addition returned no subject".into(),
9390 })?;
9391 if required_str(subject, "id")? != issue.0 {
9392 return Err(SourceError::Malformed {
9393 message: "GitHub comment addition answered about another issue".into(),
9394 });
9395 }
9396 let added = data
9397 .pointer("/addComment/commentEdge/node")
9398 .filter(|value| !value.is_null())
9399 .ok_or_else(|| SourceError::Malformed {
9400 message: "GitHub comment addition returned no comment".into(),
9401 })?;
9402 comment_from(added).map(Some)
9403 }
9404
9405 async fn edit_comment(
9406 &self,
9407 task: &NativeId,
9408 comment: &NativeId,
9409 body: &CommentBody,
9410 ) -> Result<Option<Comment>, SourceError> {
9411 let Some(issue) = self.commented_issue(task).await? else {
9412 return Ok(None);
9413 };
9414 if !self.comment_is_on(&issue, comment).await? {
9415 return Ok(None);
9416 }
9417 let data = self
9418 .graphql(
9419 graphql::UPDATE_COMMENT,
9420 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9421 )
9422 .await?;
9423 let edited = data
9424 .pointer("/updateIssueComment/issueComment")
9425 .filter(|value| !value.is_null())
9426 .ok_or_else(|| SourceError::Malformed {
9427 message: "GitHub comment update returned no comment".into(),
9428 })?;
9429 let edited = comment_from(edited)?;
9430 if edited.id != *comment {
9431 return Err(SourceError::Malformed {
9432 message: "GitHub comment update returned the wrong comment".into(),
9433 });
9434 }
9435 Ok(Some(edited))
9436 }
9437
9438 async fn delete_comment(
9439 &self,
9440 task: &NativeId,
9441 comment: &NativeId,
9442 ) -> Result<Option<NativeId>, SourceError> {
9443 let Some(issue) = self.commented_issue(task).await? else {
9444 return Ok(None);
9445 };
9446 if !self.comment_is_on(&issue, comment).await? {
9447 return Ok(None);
9448 }
9449 let data = self
9450 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9451 .await?;
9452 // The payload says nothing about the comment it removed, so what is checked is that
9453 // GitHub answered the mutation at all rather than leaving it unanswered.
9454 data.get("deleteIssueComment")
9455 .filter(|value| !value.is_null())
9456 .ok_or_else(|| SourceError::Malformed {
9457 message: "GitHub comment deletion returned no payload".into(),
9458 })?;
9459 Ok(Some(comment.clone()))
9460 }
9461
9462 /// Every request this source has recorded, and what each of GitHub's two budgets was
9463 /// attributed — read off the same accounting the session report is rendered from, so
9464 /// the two cannot count one request two ways.
9465 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9466 Ok(Some(self.ledger.snapshot().metering()))
9467 }
9468}
9469
9470/// One issue comment as the contract carries it.
9471///
9472/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9473/// and when it answers an actor with no login, because either way the source did not say who
9474/// wrote it — which is what an absent author means, rather than an author called nothing.
9475fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9476 Ok(Comment {
9477 id: NativeId(required_str(value, "id")?.to_owned()),
9478 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9479 .map(str::to_owned),
9480 created_at: optional_time(value, "createdAt")?,
9481 updated_at: optional_time(value, "updatedAt")?,
9482 body: required_str(value, "body")?.to_owned(),
9483 url: optional_str(value, "url")?.map(str::to_owned),
9484 })
9485}
9486
9487/// The page of comments one issue node carries, resumed from `after`.
9488fn comment_page(
9489 node: &Value,
9490 issue: &str,
9491 after: Option<&str>,
9492) -> Result<Page<Comment>, SourceError> {
9493 let connection = node
9494 .get("comments")
9495 .filter(|value| !value.is_null())
9496 .ok_or_else(|| SourceError::Malformed {
9497 message: format!("GitHub issue {issue} answered with no comments connection"),
9498 })?;
9499 let items = optional_nodes(Some(connection), "issue comments")?
9500 .into_iter()
9501 .flatten()
9502 .map(comment_from)
9503 .collect::<Result<Vec<_>, _>>()?;
9504 let next = next_cursor(connection)?;
9505 if let Some(next) = &next {
9506 validate_cursor_progress(after, &next.0)?;
9507 }
9508 Ok(Page { items, next })
9509}
9510
9511/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9512/// end — `None` when it carried none, or a page with more past it.
9513fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9514 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9515 return Ok(None);
9516 };
9517 if next_cursor(connection)?.is_some() {
9518 return Ok(None);
9519 }
9520 Ok(Some(
9521 optional_nodes(Some(connection), "blocked-by issues")?
9522 .into_iter()
9523 .flatten()
9524 .cloned()
9525 .collect(),
9526 ))
9527}
9528
9529/// Where the recorded tail of a dependency walk resumes; see
9530/// [`GitHubProjectsSource::recorded_edges`].
9531const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9532
9533/// The board text field this source keeps a copy's origin in.
9534///
9535/// Named after the key it holds, and held to that name by the guard below rather than by
9536/// a reader noticing.
9537const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9538
9539/// The metadata key that field holds.
9540///
9541/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9542/// constructs or interprets the qualified id it carries. This source names it only to
9543/// route it — a short, typed value belongs in a typed field rather than in the body slot
9544/// a caller's own prose shares.
9545///
9546/// Restated rather than imported, because no plugin crate may depend on the engine. What
9547/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9548/// target in `check`: it reads the engine's own literal and fails naming the file and the
9549/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9550/// that creates a second item every run instead of finding the one it wrote — and that is
9551/// too late to learn it.
9552const ORIGIN_KEY: &str = "onetaskgraph.origin";
9553
9554/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9555///
9556/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9557/// is derived from the far end, never written down on the near item — so only a forward
9558/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9559/// it did not come from, and it is told so rather than answered with an empty page that
9560/// reads as a walk which ended.
9561fn recorded_offset(
9562 cursor: Option<&str>,
9563 direction: Direction,
9564) -> Result<Option<usize>, SourceError> {
9565 cursor
9566 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9567 .map(|offset| {
9568 if direction != Direction::DependsOn {
9569 return Err(SourceError::Config {
9570 message: format!(
9571 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9572 reverse dependency read never issues; resume it in the direction \
9573 that reported it"
9574 ),
9575 });
9576 }
9577 offset.parse().map_err(|_| SourceError::Config {
9578 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9579 })
9580 })
9581 .transpose()
9582}
9583
9584fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9585 let mut page = offset_page(edges, offset, limit.max(1));
9586 page.next = page
9587 .next
9588 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9589 page
9590}
9591
9592/// The kind of one issue reached through a dependency connection.
9593///
9594/// The same questions the board scan asks, over the fields the dependency document
9595/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9596/// then anything with sub-issues or the marker is a project.
9597///
9598/// # Errors
9599///
9600/// A far end this board holds as a document is refused rather than reported. The two
9601/// answers that are not refusals would both be wrong: reporting it as a task names an id
9602/// no task read of this source can find, and reporting it as a project names one no
9603/// project read can. There is no third value to return — `ItemKind` has no document
9604/// variant, because nothing may point at a document — so the relationship itself is what
9605/// the person is told about.
9606fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9607 let id = required_str(value, "id")?;
9608 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9609 return Err(SourceError::Refused {
9610 message: format!(
9611 "GitHub issue {id} is a document of this board — its title begins \
9612 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9613 on by one; next: remove that issue's blocking relationship on this board"
9614 ),
9615 });
9616 }
9617 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9618 if parent.is_some() {
9619 return Ok(ItemKind::Task);
9620 }
9621 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9622 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9623 message: format!("GitHub issue {id}: {message}"),
9624 })?;
9625 let sub_issues = sub_issue_total(value)?;
9626 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9627 ItemKind::Project
9628 } else {
9629 ItemKind::Task
9630 })
9631}
9632
9633/// The `IssueStateUpdateInput` one status target asks for.
9634///
9635/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9636/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9637/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9638/// would report a change forever. A document has no status at all, and asks for neither.
9639fn state_input(target: Option<&StatusTarget>) -> Value {
9640 match target {
9641 Some(StatusTarget::Terminal(_, reason)) => {
9642 json!({"value":"CLOSED","stateReason":reason.reason()})
9643 }
9644 Some(StatusTarget::Column(_) | StatusTarget::Disabled(_)) => json!({"value":"OPEN"}),
9645 // A document has no status, so a write of one says nothing about the issue's open
9646 // or closed state rather than forcing it open: `stateInput` is what carries that
9647 // instruction, and an explicit null asks for no change to it.
9648 None => Value::Null,
9649 }
9650}
9651
9652/// The metadata one write stores in the item's body slot.
9653///
9654/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9655/// rather than carried: the kind marker so an empty project stays readable, the
9656/// repository list only when it is not exactly the issue's own repository, and the far
9657/// ends no relationship here can name.
9658///
9659/// The copy origin is the one typed field that is also mirrored here, and only as a
9660/// mirror: it lands in the board's origin field as well, which stays the one every reader
9661/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9662/// and catches up with a write in seconds rather than minutes — can find the item by it.
9663/// A reader of the release before this one drops the slot's copy and reads the field, so an
9664/// item written here still reads with exactly one origin there.
9665fn slot_metadata(
9666 incoming: &Incoming<'_>,
9667 own_repository: Option<&Repository>,
9668 fallback: &[DependencyEdge],
9669) -> BTreeMap<String, Value> {
9670 let mut metadata = incoming.metadata.clone();
9671 match metadata.remove(ORIGIN_KEY) {
9672 Some(Value::String(origin)) if !origin.is_empty() => {
9673 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9674 }
9675 _ => {}
9676 }
9677 match incoming.written.kind() {
9678 BoardKind::Work(kind) => metadata.insert(
9679 ItemKind::METADATA_KEY.to_owned(),
9680 Value::String(kind.marker().to_owned()),
9681 ),
9682 // A document is told by its title, so it carries no kind marker: that key names
9683 // what a dependency endpoint points at, and nothing may point at a document.
9684 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9685 };
9686 let derivable = own_repository
9687 .map(|own| incoming.repositories == [own.clone()])
9688 .unwrap_or(incoming.repositories.is_empty());
9689 if derivable {
9690 metadata.remove(Repository::METADATA_KEY);
9691 } else {
9692 metadata.insert(
9693 Repository::METADATA_KEY.to_owned(),
9694 Value::Array(
9695 incoming
9696 .repositories
9697 .iter()
9698 .map(|repository| Value::String(repository.as_str().to_owned()))
9699 .collect(),
9700 ),
9701 );
9702 }
9703 // The typed lists are what land, whatever the caller's own metadata held under their
9704 // keys: a key of either name travelling beside the field would otherwise be a second
9705 // answer to the same question, and the field is the one the contract names.
9706 for (key, entries) in [
9707 (TaskRef::DELIVERS_KEY, incoming.delivers),
9708 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9709 ] {
9710 set_task_list(&mut metadata, key, entries);
9711 }
9712 record_edges(&mut metadata, fallback);
9713 metadata
9714}
9715
9716/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9717/// one slot's metadata, or no such key when there are none.
9718fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9719 if fallback.is_empty() {
9720 metadata.remove(DependencyEdge::RECORDED_KEY);
9721 } else {
9722 metadata.insert(
9723 DependencyEdge::RECORDED_KEY.to_owned(),
9724 Value::Array(
9725 fallback
9726 .iter()
9727 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9728 .collect(),
9729 ),
9730 );
9731 }
9732}
9733
9734/// Every label one item carries, from its content's own connection and nowhere else.
9735///
9736/// There is no second place to read one from: no document this source sends selects the
9737/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9738/// cannot carry one at all. The module documentation records the three schema facts that
9739/// settle it.
9740fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9741 optional_nodes(content.get("labels"), "content labels")?
9742 .into_iter()
9743 .flatten()
9744 .map(|v| {
9745 Ok(Label {
9746 id: NativeId(required_str(v, "id")?.to_owned()),
9747 name: required_str(v, "name")?.to_owned(),
9748 color: optional_str(v, "color")?.map(str::to_owned),
9749 })
9750 })
9751 .collect()
9752}
9753
9754/// The definition of each board field one item's values are values of, in the shape a read
9755/// of the board's own `fields` gives one.
9756///
9757/// A value names its field through a fragment on that field's own type, so the type is
9758/// known from which kind of value it is: a single-select value's field is a
9759/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9760/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9761fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9762 field_values
9763 .iter()
9764 .filter_map(|value| {
9765 let field = value.get("field")?.as_object()?;
9766 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9767 let typename = if value.get("text").is_some() {
9768 "ProjectV2Field"
9769 } else if value.get("name").is_some() {
9770 "ProjectV2SingleSelectField"
9771 } else {
9772 return None;
9773 };
9774 let mut defined = field.clone();
9775 defined.insert("__typename".to_owned(), json!(typename));
9776 Some(Value::Object(defined))
9777 })
9778 .collect()
9779}
9780
9781fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9782 let Some(node) = field_values
9783 .iter()
9784 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9785 else {
9786 return Ok(None);
9787 };
9788 Ok(optional_str(node, "text")?.map(str::to_owned))
9789}
9790
9791fn valid_github_owner(owner: &str) -> bool {
9792 !owner.is_empty()
9793 && owner.len() <= 39
9794 && !owner.starts_with('-')
9795 && !owner.ends_with('-')
9796 && !owner.contains("--")
9797 && owner
9798 .bytes()
9799 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9800}
9801
9802/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9803/// neither of the two names a path segment already means.
9804fn valid_github_repository_name(name: &str) -> bool {
9805 !name.is_empty()
9806 && name.len() <= 100
9807 && name != "."
9808 && name != ".."
9809 && name
9810 .bytes()
9811 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9812}
9813
9814fn valid_environment_name(name: &str) -> bool {
9815 let mut bytes = name.bytes();
9816 bytes
9817 .next()
9818 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9819 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9820}
9821
9822/// How many sub-issues one issue has.
9823///
9824/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9825/// absent or non-integer one is a response this source cannot read — and reading it as
9826/// zero would classify a project as a task, which is exactly the mistake the marker
9827/// exists to keep from happening quietly.
9828fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9829 let summary = issue
9830 .get("subIssuesSummary")
9831 .ok_or_else(|| SourceError::Malformed {
9832 message: "GitHub issue is missing subIssuesSummary".into(),
9833 })?;
9834 summary
9835 .get("total")
9836 .and_then(Value::as_u64)
9837 .ok_or_else(|| SourceError::Malformed {
9838 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9839 })
9840}
9841
9842/// One issue's own `number`.
9843///
9844/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9845/// an issue in this module asks for it. So a read of one that comes back without it, or
9846/// with something that is not an unsigned integer, is a response this source cannot read —
9847/// absence here is **not** "this issue has no number". A draft is the content that has
9848/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9849/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9850fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9851 issue
9852 .get("number")
9853 .and_then(Value::as_u64)
9854 .ok_or_else(|| SourceError::Malformed {
9855 message: "GitHub issue number is missing or is not an unsigned integer".into(),
9856 })
9857}
9858
9859/// The `number` a creating mutation answered with, and `None` when it answered without one;
9860/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9861fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9862 match created.get("number") {
9863 None | Some(Value::Null) => Ok(None),
9864 Some(value) => value
9865 .as_u64()
9866 .map(Some)
9867 .ok_or_else(|| SourceError::Malformed {
9868 message: "GitHub created issue number is not an unsigned integer".into(),
9869 }),
9870 }
9871}
9872
9873fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9874 value
9875 .get(field)
9876 .and_then(Value::as_str)
9877 .ok_or_else(|| SourceError::Malformed {
9878 message: format!("GitHub response is missing string field {field}"),
9879 })
9880}
9881
9882fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9883 let found = required_str(value, field)?;
9884 if found.trim().is_empty() {
9885 return Err(SourceError::Malformed {
9886 message: format!("GitHub response has blank string field {field}"),
9887 });
9888 }
9889 Ok(found)
9890}
9891
9892/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9893/// needs one — Linear spells them too, in its own description field.
9894///
9895/// Restated rather than shared, because a plugin crate depends on the contract crate and
9896/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9897/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9898/// source round-trips its own writes perfectly well under its own spelling.
9899const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9900const METADATA_CLOSE: &str = "\n-->";
9901
9902/// What the composer puts between a non-empty visible body and the slot, and the one thing
9903/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9904/// other trailing byte of the body comes back as it was written.
9905// 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.
9906const METADATA_SEPARATOR: &str = "\n\n";
9907
9908/// The visible body and the metadata slot at the end of it.
9909///
9910/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9911/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9912/// own content and is left alone. The visible body is everything before the slot less the
9913/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9914fn metadata_body(
9915 body: Option<String>,
9916) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9917 let Some(body) = body else {
9918 return Ok((None, BTreeMap::new()));
9919 };
9920 let Some(slot) = slot_span(&body)? else {
9921 return Ok((Some(body), BTreeMap::new()));
9922 };
9923 let metadata =
9924 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9925 SourceError::Malformed {
9926 message: format!(
9927 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9928 ),
9929 }
9930 })?;
9931 let before = &body[..slot.start];
9932 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9933 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9934}
9935
9936/// Where the metadata slot sits in one body, as byte offsets into it.
9937struct SlotSpan {
9938 /// Where [`METADATA_OPEN`] begins.
9939 start: usize,
9940 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9941 encoded_start: usize,
9942 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9943 encoded_end: usize,
9944 /// Just past [`METADATA_CLOSE`].
9945 end: usize,
9946}
9947
9948/// The slot at the very end of `body`, or `None` when it has none.
9949///
9950/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9951/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9952/// slot.
9953fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9954 let Some(start) = body.rfind(METADATA_OPEN) else {
9955 return Ok(None);
9956 };
9957 let encoded_start = start + METADATA_OPEN.len();
9958 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9959 return Err(SourceError::Malformed {
9960 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9961 });
9962 };
9963 let encoded_end = encoded_start + relative_end;
9964 let end = encoded_end + METADATA_CLOSE.len();
9965 if !body[end..].trim().is_empty() {
9966 return Ok(None);
9967 }
9968 Ok(Some(SlotSpan {
9969 start,
9970 encoded_start,
9971 encoded_end,
9972 end,
9973 }))
9974}
9975
9976/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9977/// slot as it was.
9978///
9979/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9980/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9981/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9982/// or alone in an empty body — and a body with no slot that is given no metadata is
9983/// returned as it is.
9984fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9985 let encoded = if metadata.is_empty() {
9986 None
9987 } else {
9988 Some(
9989 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9990 message: error.to_string(),
9991 })?,
9992 )
9993 };
9994 Ok(match (slot_span(body)?, encoded) {
9995 (Some(slot), Some(encoded)) => format!(
9996 "{}{encoded}{}",
9997 &body[..slot.encoded_start],
9998 &body[slot.encoded_end..]
9999 ),
10000 (Some(slot), None) => {
10001 let before = &body[..slot.start];
10002 format!(
10003 "{}{}",
10004 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
10005 &body[slot.end..]
10006 )
10007 }
10008 (None, None) => body.to_owned(),
10009 (None, Some(encoded)) if body.is_empty() => {
10010 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10011 }
10012 (None, Some(encoded)) => {
10013 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10014 }
10015 })
10016}
10017
10018/// `body` with everything before its metadata slot replaced by `content`, and the slot
10019/// itself kept byte for byte.
10020///
10021/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
10022/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
10023/// `content` is empty — so a read of the result reports `content` as the visible body and
10024/// the slot's metadata exactly as it was.
10025fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
10026 let Some(slot) = slot_span(body)? else {
10027 return Ok(content.to_owned());
10028 };
10029 let kept = &body[slot.start..];
10030 Ok(if content.is_empty() {
10031 kept.to_owned()
10032 } else {
10033 format!("{content}{METADATA_SEPARATOR}{kept}")
10034 })
10035}
10036
10037/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
10038fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
10039 if entries.is_empty() {
10040 metadata.remove(key);
10041 } else {
10042 metadata.insert(
10043 key.to_owned(),
10044 Value::Array(
10045 entries
10046 .iter()
10047 .map(|entry| Value::String(entry.as_str().to_owned()))
10048 .collect(),
10049 ),
10050 );
10051 }
10052}
10053
10054fn compose_body(
10055 content: Option<&str>,
10056 metadata: &BTreeMap<String, Value>,
10057) -> Result<Option<String>, SourceError> {
10058 let visible = content.unwrap_or_default();
10059 if metadata.is_empty() {
10060 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
10061 }
10062 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
10063 message: error.to_string(),
10064 })?;
10065 Ok(Some(if visible.is_empty() {
10066 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10067 } else {
10068 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
10069 }))
10070}
10071
10072fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
10073 value
10074 .get(field)
10075 .and_then(Value::as_bool)
10076 .ok_or_else(|| SourceError::Malformed {
10077 message: format!("GitHub response is missing boolean field {field}"),
10078 })
10079}
10080fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
10081 match value.get(field) {
10082 None | Some(Value::Null) => Ok(None),
10083 Some(value) => value
10084 .as_str()
10085 .map(Some)
10086 .ok_or_else(|| SourceError::Malformed {
10087 message: format!("GitHub response field {field} is not a string or null"),
10088 }),
10089 }
10090}
10091fn optional_nodes<'a>(
10092 connection: Option<&'a Value>,
10093 name: &str,
10094) -> Result<Option<&'a Vec<Value>>, SourceError> {
10095 match connection {
10096 None | Some(Value::Null) => Ok(None),
10097 Some(value) => value
10098 .get("nodes")
10099 .and_then(Value::as_array)
10100 .map(Some)
10101 .ok_or_else(|| SourceError::Malformed {
10102 message: format!("GitHub {name}.nodes is not an array"),
10103 }),
10104 }
10105}
10106fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
10107 let page_info = connection
10108 .get("pageInfo")
10109 .ok_or_else(|| SourceError::Malformed {
10110 message: format!("GitHub {name} has no pageInfo"),
10111 })?;
10112 if required_bool(page_info, "hasNextPage")? {
10113 return Err(SourceError::Malformed {
10114 message: format!(
10115 "GitHub {name} exceeds the supported nested connection size of {size}"
10116 ),
10117 });
10118 }
10119 Ok(())
10120}
10121fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
10122 optional_str(value, field)?
10123 .map(|timestamp| {
10124 timestamp.parse().map_err(|error| SourceError::Malformed {
10125 message: format!("GitHub response field {field} is not a timestamp: {error}"),
10126 })
10127 })
10128 .transpose()
10129}
10130fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
10131 if page.limit == 0 {
10132 Err(SourceError::Config {
10133 message: "page limit must be at least 1".into(),
10134 })
10135 } else {
10136 Ok(())
10137 }
10138}
10139fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
10140 let page = connection
10141 .get("pageInfo")
10142 .filter(|value| value.is_object())
10143 .ok_or_else(|| SourceError::Malformed {
10144 message: "GitHub connection is missing pageInfo".into(),
10145 })?;
10146 if required_bool(page, "hasNextPage")? {
10147 let cursor = required_str(page, "endCursor")?;
10148 validate_cursor_progress(None, cursor)?;
10149 Ok(Some(Cursor(cursor.into())))
10150 } else {
10151 Ok(None)
10152 }
10153}
10154fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
10155 if next.is_empty() || previous == Some(next) {
10156 Err(SourceError::Malformed {
10157 message: "GitHub pagination cursor is empty or did not advance".into(),
10158 })
10159 } else {
10160 Ok(())
10161 }
10162}
10163/// The version of this plugin's opaque narrowing-search cursor.
10164pub const SEARCH_CURSOR_VERSION: u32 = 4;
10165
10166#[derive(Serialize, Deserialize)]
10167#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
10168enum SearchConnection {
10169 Initial {},
10170 Continuing { after: Cursor },
10171 Exhausted {},
10172}
10173impl SearchConnection {
10174 fn after(&self) -> Option<&str> {
10175 match self {
10176 Self::Continuing { after } => Some(&after.0),
10177 _ => None,
10178 }
10179 }
10180 fn exhausted(&self) -> bool {
10181 matches!(self, Self::Exhausted { .. })
10182 }
10183 /// Whether a cursor naming this position, `offset` rows into its page, is one this
10184 /// plugin could have handed out: a page is resumed only part of the way through it — an
10185 /// offset of a whole page or more would skip rows nobody was given — an initial page
10186 /// only once some of it was handed out, and an exhausted connection has no page to be
10187 /// part of the way through.
10188 fn valid_resume(&self, offset: usize) -> bool {
10189 let within = offset < SEARCH_PAGE_SIZE as usize;
10190 match self {
10191 Self::Initial { .. } => offset > 0 && within,
10192 Self::Continuing { after } => !after.0.is_empty() && within,
10193 Self::Exhausted { .. } => offset == 0,
10194 }
10195 }
10196}
10197
10198/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
10199#[derive(Serialize, Deserialize)]
10200#[serde(deny_unknown_fields)]
10201struct SearchPosition {
10202 version: u32,
10203 connection: SearchConnection,
10204 /// How many rows of the page `connection` starts were already handed out.
10205 #[serde(default, skip_serializing_if = "is_zero")]
10206 offset: usize,
10207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10208 seen: Vec<NativeId>,
10209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
10210 own: Vec<NativeId>,
10211}
10212impl Default for SearchPosition {
10213 fn default() -> Self {
10214 Self {
10215 version: SEARCH_CURSOR_VERSION,
10216 connection: SearchConnection::Initial {},
10217 offset: 0,
10218 seen: Vec::new(),
10219 own: Vec::new(),
10220 }
10221 }
10222}
10223
10224fn is_zero(offset: &usize) -> bool {
10225 *offset == 0
10226}
10227
10228fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
10229 cursor.map_or(Ok(0), |c| {
10230 c.0.parse().map_err(|_| SourceError::Config {
10231 message: "page cursor is invalid".into(),
10232 })
10233 })
10234}
10235fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10236 if offset > items.len() {
10237 return Page::last(vec![]);
10238 }
10239 let tail = items.split_off(offset);
10240 let mut selected = tail;
10241 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10242 selected.truncate(limit);
10243 Page {
10244 items: selected,
10245 next,
10246 }
10247}