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 from a status category to
85//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
86//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
87//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
88//! A missing mapped option refuses the write before either representation changes. Reads
89//! give a closed issue's reason precedence over its option, while an open issue's option
90//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
91//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
92//! list, so it preserves every existing option id and verifies the field and item
93//! assignments immediately afterwards. It counts a terminal category's mapped option as
94//! configured, because a terminal write refuses without it. No ordinary source read or
95//! write calls that mutation, whose
96//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
97//! and a mistake destroys every item's status. A status this board cannot represent is a
98//! refusal naming the status and the instance instead.
99//!
100//! `unknown` is disabled by default because this source cannot preserve an open-ended
101//! status word: it writes an existing board option and never
102//! creates an option. An operator may map `unknown` to one existing option, in which case
103//! every unknown word lands on that option and reads back as `unknown` under the option's
104//! name. This differs from `local-md`, which writes and reads the original word itself.
105//!
106//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
107//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
108//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
109//! finished tasks were only moved to a "Done" column would read 0% complete forever.
110// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
111//!
112//! # What this source declares, field by field
113//!
114//! One verdict per field of [`Capabilities`], and what `Native` means when this source
115//! says it. *Proven* means a shared journey drives it against the real
116//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
117//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
118//! [`capabilities`](TaskSource::capabilities) from parting.
119//!
120//! | Field | Verdict |
121//! | --- | --- |
122//! | `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. |
123//! | `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. |
124//! | `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. |
125//! | `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. |
126//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
127//! | `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. |
128//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
129//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
130//! | `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`. |
131//! | `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. |
132//! | `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. |
133//! | `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 or document query's text is applied by that same substring rule over the issues its read already holds, and narrows nothing. |
134//! | `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. |
135//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
136//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
137//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
138//!
139//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
140//! source has documents and that its tasks have comments, both of which hold — and the three
141//! facts behind the uniform `Native` on the
142//! predicates beside it are recorded below rather than re-derived, because a reader who
143//! takes `Native` to mean *the remote service filters* will read that uniformity as a
144//! lie.
145//!
146//! First, the plugin contract defines `Support::Native` as *the source applies this
147//! predicate itself*, and says nothing about where it applies it. What the declaration
148//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
149//! — so that the engine may push it down and apply nothing of its own.
150//!
151//! Second, this source can keep that promise for every predicate at no additional API
152//! cost, because whichever of the reads below answers a query has already read every
153//! candidate that query will return before it filters anything. Filtering those items is
154//! in-process work over data already in hand.
155//!
156//! Third, six task predicates are asked of GitHub as a narrower question and the rest are
157//! applied in process over what that question returned. A project filter has a relationship — a
158//! project's tasks are that issue's sub-issues, and asking the issue for them is both cheaper
159//! and exact. Comment activity is the issue search's `updated:` qualifier. A text search, and
160//! a search for metadata values, is the board-scoped issue search carrying the text and each
161//! value as quoted phrases; an origin is the board's own field filter over its origin field
162//! beside the same search for the id. **The text search narrows, and that is this source's
163//! declared semantics:** GitHub matches whole words where the substring rule this source and
164//! the local Markdown source confirm with would match inside one, so an item holding the text
165//! only inside a longer word is never a candidate. Every item returned does contain the text.
166//! GitHub's issue search offers no qualifier for a label set, a status column or a priority,
167//! so those three are applied in process over the candidates, and a query carrying none of
168//! the six narrowing predicates reads the board. Declaring one `Unsupported` would make the
169//! engine compensate for work this source has already done, and declaring `projects` native
170//! while ignoring the filter (which this source once did) silently returns another project's
171//! tasks, because the engine trusts the declaration and applies nothing locally.
172//!
173//! # The three ways this source reaches an item, and what each costs
174//!
175//! A board read is charged for what its *nested* connections could return rather than for
176//! what was asked, so one whole-board read costs the same whether the question was about
177//! one project or about all of them. That is why a question about one project is never
178//! answered by reading the board:
179//!
180//! | The question | What is sent | What it costs |
181//! | --- | --- | --- |
182//! | 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 |
183//! | 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 |
184//! | 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 |
185//! | 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 |
186//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
187//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
188//! | 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 |
189//! | 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 |
190//! | 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 |
191//! | 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 |
192//! | 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 |
193//!
194//! The following standalone-ticket requests are pinned by the real CLI fixture journeys
195//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields` and
196//! `a_batched_detail_read_costs_one_request_and_one_point_per_detail_batch`, as request count
197//! equal to declared points equal to the row. They include the origin lookup and the
198//! field/repository discovery a create needs. A bound re-copy changes status, priority,
199//! content and metadata; comment recount means a subsequent detail read. Each request here
200//! costs one declared point. A membership beyond the embedded page can additionally require
201//! the one-point membership recovery described above. A bound re-copy of a task filed under a
202//! project adds one read, the engine confirming that project's link by its own id once per
203//! command; and the same-source far ends a write newly names — those that do not already block
204//! the item, whose own read answered for them — are read together by their own ids,
205//! [`DETAIL_BATCH`] to one [`graphql::ISSUE_DETAILS`] request, each new edge then one
206//! [`graphql::ADD_BLOCKED_BY`]. Both additions are rows of the table below, pinned by
207//! `a_bound_recopy_adds_one_project_read_and_batches_the_dependencies_it_newly_names`.
208//!
209//! **[`DETAIL_BATCH`] is 24**: the largest batch of [`graphql::ISSUE_DETAILS`] the node-count
210//! model prices at one point. Each aliased item is six of GitHub's aggregate, so 24 are 144,
211//! which rounds to one point, and 25 are 150, which rounds to two; `tests/point_cost.rs`
212//! holds both halves.
213//!
214//! **An existing item is written body last.** A bound re-copy and a `task update` send its
215//! board fields first — the `Status` option and the `Priority` together, in one request — then
216//! its parent and its `blockedBy`, and its title, body and state in one `updateIssue` last.
217//! GitHub runs no two requests as one, and runs a document's mutation fields in order without
218//! undoing an earlier field when a later one fails, so that order is what makes a write
219//! refused part-way leave the item's body, and every metadata key in it, exactly as it stood;
220//! the one piece of metadata written before the body, an origin a copy re-points, is put back
221//! when a later write is refused — and when putting it back is refused too, the write's own
222//! refusal names that key, what it now holds and what it held. `crates/onetaskgraph/tests/e2e/write_order.rs` refuses each
223//! of those writes in turn, whole and as one aliased field failing after the one before it.
224//!
225//! **Two facts about GitHub the write rows rest on, each read off GitHub's published schema
226//! artifact <https://docs.github.com/public/fpt/schema.docs.graphql> on 2026-10-01 and pinned
227//! in `tests/fixtures/schema.graphql`, and the first then put to GitHub itself:**
228//!
229//! - **A board is accepted at creation but its item is not answered, so a create still files
230//! the issue itself: a new copy is 5 requests, and 4 with `--create`.**
231//! `CreateIssueInput.projectV2Ids: [ID!]` is declared there — "An array of Node IDs for
232//! Projects V2 associated with this issue", `@possibleTypes(concreteTypes: ["ProjectV2"])`.
233//! The credentialed journey `real_projects_v2_contract_writes_and_leaves_no_residue` was run
234//! against a real board on 2026-10-01 with a create sending the board there and reading the
235//! item off the payload's `Issue.projectItems`: every one of its four creates answered with
236//! no item on the board, so each went on to [`graphql::ADD_TO_BOARD`], and the fourth was
237//! refused "Content already exists in this project" — GitHub had filed the issue after
238//! answering, and refuses a second filing rather than answering with the item it holds. A
239//! create therefore sends no `projectV2Ids` and files the issue with
240//! `addProjectV2ItemById`, the one call whose answer names the board item. The saving that is
241//! real is the read before it: the board's fields and the repository's id together, in
242//! [`graphql::CREATION_CONTEXT`], at the point the repository is known.
243//! - **A comment still reads its target first, so a comment is 2 requests.**
244//! `AddCommentInput.subjectId: ID!` is declared there with
245//! `@possibleTypes(concreteTypes: ["Issue", "PullRequest"], abstractType:
246//! "IssueOrPullRequest")`. A board draft is no such subject and would be refused, but a
247//! project's issue, a document's issue, an issue on no board of this source and a pull
248//! request all are: GitHub writes the comment, so there is no refusal to map into "that is
249//! not a task of this board". [`graphql::ISSUE`] before [`graphql::ADD_COMMENT`] is what
250//! refuses those by name.
251//!
252//! | Verb | Requests / points | Documents |
253//! | --- | --- | --- |
254//! | new copy | 5 | ORIGIN_LOOKUP, CREATION_CONTEXT (the board's fields and the repository's id together), CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
255//! | copy --create | 4 | CREATION_CONTEXT, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS: the new copy without its ORIGIN_LOOKUP |
256//! | 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 |
257//! | 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 |
258//! | 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 |
259//! | comment | 2 | ISSUE, ADD_COMMENT: the target is read first, because GitHub accepts a comment on any issue or pull request (see below) |
260//! | detail | 1 | ISSUE_DETAIL: the item and its first page of comments, for `task show` and `task comment list`; `--no-comments` is ISSUE alone |
261//! | batched detail | ceil(n / DETAIL_BATCH) | ISSUE_DETAILS: `task show-many` of `n` items, DETAIL_BATCH (24) at a time, comments included or not |
262//! | recount | 1 | ISSUE_DETAIL |
263//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
264//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
265//! | content | 2 | ISSUE, UPDATE_ISSUE |
266//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
267//! | 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 |
268//! | record only | 1 | ISSUE |
269//!
270//! <!-- github-search-paging:start -->
271//! Board-scoped text, metadata, project-name and comment-activity searches send every
272//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
273//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
274//! needs rows. A page is never resized to the rows still needed: GitHub orders one
275//! search differently at different page sizes, so one fixed size makes a paged walk
276//! send exactly the requests one whole read sends, and the answer's order is the order
277//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
278//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
279//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
280//! returned and fetched pages: a limit is sliced from the pages it needs, and local
281//! confirmation can require more candidates than matching rows. Walking all pages
282//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
283//! cursor and how far into that page the last answer stopped, and resumes in the same
284//! process or a new one, without duplicates or gaps. It carries no rows: one process
285//! sends each page's search once, and a new process re-reads only the page it resumes
286//! in, then sends a further page once, never as a re-read, only when its limit still
287//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
288//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
289//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
290//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
291//! not required to include the original process's writes still omitted by the index.
292//! <!-- github-search-paging:end -->
293//!
294//! The board half of an issue — its board item's id, its `Status` option and this
295//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
296//! an item reached any of those ways resolves through the same
297//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
298//! same status, the same labels and the same qualified id. That connection comes back a
299//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
300//! on the page in hand and — only if that page reports more of the connection — in the
301//! last row's read of that one issue's memberships, resumed from the page's own cursor and
302//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
303//! report, which is what keeps an id naming another repository's issue from being answered
304//! as an item of this board; and because the page is where the search starts rather than
305//! where it ends, that answer is one about a connection read to exhaustion and never about
306//! an unread page. Nothing costs the extra read but an issue on more boards than a page
307//! holds: an issue this board really does not hold reports no next page, so its
308//! memberships are already exhausted where they arrived.
309//!
310//! **No document here selects the board's own `Labels` field, and nothing is lost by
311//! that.** An item's labels are read from its content alone, wherever that content is
312//! reached: the three documents above select `Issue.labels` on the fragment, and
313//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
314//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
315//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
316//! create one, and `ProjectV2FieldValue` — the whole of what
317//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
318//! it from the content, for every content type it exists on, and there is nothing it can
319//! hold that the content does not already say: for an `Issue` it *is* that issue's own
320//! labels, so selecting it beside them unions a set with itself.
321//!
322//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
323//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
324//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
325//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
326//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
327//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
328//! label set, and that set is the fixture issue's own, by
329//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
330//! item whose content is a draft reports an empty set, by
331//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
332//! selection is held over [`graphql::DOCUMENTS`] by
333//! `no_document_selects_the_boards_own_labels_field`.
334//!
335//! The whole-board row is still the board's own item connection, and deliberately: a
336//! **draft** board item is not an issue, so no search can list one, and the reads that have
337//! to answer for the whole board are the ones whose cost is the board's size anyway.
338//!
339//! **A question about one item this source already names by id never lists the board.**
340//! Whether that item is on this board, and what its board fields are, is answered by reading
341//! that item — its own `Issue.projectItems`, walked to exhaustion by
342//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
343//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
344//! holds. That covers a write's destination, the project a new item is filed under, a
345//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
346//! and the delete that takes back an item a copy made. What such a write needs of the board
347//! and the item does not carry — the board's id, the `Status` and origin field definitions —
348//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
349//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
350//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
351//! minutes. Scanning this host's 842-item board has refused a document copy and an update
352//! even though the items' own reads named that board. A scan there gives the wrong answer
353//! as well as paying for every page. So a `board.items` lookup does not belong on any of
354//! those paths.
355//!
356//! **What a read may return is capped too, and that cap is on the document rather than on
357//! the board.** GitHub limits the number of nodes **one query may return** to
358//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
359//! an error naming the connection the count crossed at, not a slow or a partial result.
360//! Every board this source reads is refused the same way, so no board is too big for these
361//! documents and none is small enough to save one that is over.
362//!
363//! The count is arithmetic over the document's own text: each connection contributes the
364//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
365//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
366//! restate them — `github-graphql-node-count` implements them, and
367//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
368//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
369//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
370//! same text on every run and fails naming any that reaches the limit — so a connection
371//! added to a shared fragment is caught there rather than by GitHub.
372//!
373//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
374//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
375//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
376//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
377//! effectively squared there, which is why it is the one the limit is most sensitive to.
378//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
379//! page of memberships misses is recovered by one further read rather than refused, so it
380//! buys a bound every read pays for at the price of a request only a multi-board issue
381//! pays.
382//!
383//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
384//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
385//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
386//! `cost` is rate-limit points, metered per hour across everything one credential does; it
387//! is what the two limiters [`Limiter`] tells apart meter, and a document under
388//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
389//! that second number, and `tests/point_cost.rs` pins every document in
390//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
391//! one under, the pin itself is the check. The credentialed lane reconciles both figures
392//! against GitHub's own, off a probe it already sends.
393//!
394//! **What is pinned that way is a per-document price and never a session's.** The record in
395//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
396//! **requests** and **worst-case nodes** — and neither is points. What one whole session
397//! consumes of the hourly point allowance is observable only from a credentialed run's own
398//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
399//! and what `tests/live.rs` prints at the end of every run.
400//!
401//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
402//!
403//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
404//! enumerations of a board can supply one alone.** Resolving a node id is strongly
405//! consistent, so a read by id and a project's own sub-issues are already current. The
406//! other two are not, and they are behind by different amounts and in different directions:
407//!
408//! - GitHub's **issue search** is an index and answers a write made moments ago with the
409//! value from before it — usually for a second or two.
410//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
411//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
412//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
413//! for *minutes*, while `Issue.projectItems` names the same membership at once.
414//!
415//! That second one is a measurement rather than a caution. This repository's own
416//! credentialed journey writes a project and waits for the board to report it, then writes a
417//! task and waits for the same thing seconds later on the same board: the project wait is
418//! answered through the search and converged in two or three attempts in each of three runs,
419//! and the task wait is answered through `ProjectV2.items` and converged in none of them
420//! inside thirty. Separately, an item added to a second and larger board was read back by
421//! `Issue.projectItems` on that board's own id while every one of that connection's nine
422//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
423//! through the lagging one alone is what had a board read deny an issue that had certainly
424//! landed on it.
425//!
426//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
427//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
428//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
429//! search reports what the projection is behind on. What closes the last
430//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
431//! source answers is completed with what this process itself wrote, so an item created
432//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
433//! nothing is written down, and the record dies with the process. **A wait that has to
434//! observe GitHub's own data cannot be answered from that record** — which is why the
435//! credentialed journey asks through a source built afresh, and why the union above rather
436//! than a longer wait is what makes such a wait converge.
437//!
438//! **A narrowed read is the same bargain, stated for each of the three predicates it
439//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
440//! than walking the board, and every such answer is completed with what this process wrote —
441//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
442//! each filtered by the same predicates as the rest — so an item this command wrote a moment
443//! ago is returned by a query that matches it whether or not the index has caught up. An item
444//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
445//! consistent. What is left is stated rather than papered over:
446//!
447//! | Read | Finds | Behind by |
448//! | --- | --- | --- |
449//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
450//! | 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 |
451//! | 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 |
452//! | origin, third read | this process's own writes | nothing |
453//!
454//! So an origin carrier another process added within the last second or two, before either
455//! index has it, can be missing from an origin query, and one written by the release before
456//! this one — its origin in the field alone — can be missing for as long as the board's own
457//! item connection is behind on it. A copy that must not duplicate its own earlier write
458//! relies on the link it records, not on either index. **A board draft is not an issue**, so
459//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
460//! lists one, the origin lookup drops any the board's own field filter names, and one this
461//! process wrote is not added back either.
462//!
463//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
464//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
465//! body's metadata slot, so the issue search can find it in seconds. The field is
466//! authoritative: this source reads an item's origin from the field alone, so a slot that
467//! disagrees with it, or holds one where the field holds none, is never read as a second
468//! origin — and the release before this one reads the slot, drops that key's copy for the
469//! field's, and sees the same one origin.
470//!
471//! Filtering happens before paging, so a page of a filtered result is a page of the
472//! survivors rather than the survivors of a page. Label matching and the substring rule a
473//! text candidate is confirmed by answer the same question the same way the local Markdown
474//! source's do; which candidates a text search has to confirm is GitHub's word match, which
475//! is the one place the two sources can answer the same text differently.
476//!
477//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
478//! has one source, `capabilities`, and the note above is the reasoning behind it rather
479//! than a second copy of it: without the three facts recorded here a reader takes the
480//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
481//! crate's own capabilities test, which pins every field of it against a fully spelled-out
482//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
483//! fails to compile there rather than going unasserted. -->
484//! The fixture-server tests above run wherever this crate is selected; the credentialed
485//! lane runs in the same required check, beside them, and can fail it — it verifies the
486//! 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,
487//! one filed under neither, a label on one of the three and a closed status on another —
488//! because that shape is what tells an honoured predicate from an ignored one: a board
489//! holding a single project answers a project filter the same way whether or not this
490//! source applies it, which is exactly how the defect above went unseen.
491//!
492//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
493//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
494//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
495//! nominated is what keeps a credentialed write lane off a board and a repository nobody
496//! nominated; it never asks GitHub which project was updated most recently. Before it
497//! starts, the lane also clears any item titled — and any repository label named — the way
498//! it titles and names its own artifacts, which is self-healing after an interrupted run:
499//! a process killed between its writes and its cleanup leaves artifacts the next run
500//! removes.
501//!
502//! # What a session of requests costs, and where the report is
503//!
504//! This source records **every** request it sends into [`accounting::Accounting`], at
505//! `send_once` — the one place a request leaves this crate, which is why a read path added
506//! later is counted without anybody remembering to count it. That is the whole of what this
507//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
508//! session's spend is arrived at, and what it deliberately does not know are set out.
509//!
510//! What one whole session of the live journey costs, counted that way against this crate's
511//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
512//! reduction it came out of, and with what it does and does not say about rate-limit points.
513//!
514//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
515//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
516//! code path — no environment variable, no feature, no build configuration — because an
517//! instrument nobody switches on measures nothing, and
518//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
519//! source's counts the whole session rather than this source's share. The credentialed lane
520//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
521//! passed or failed.
522//!
523//! **A live session refuses to start unless the account can afford it.** Before it does any
524//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
525//! GitHub documents as not counting against the REST rate limit and which answers both of
526//! its budgets at once — and starts only if, for each of them, what remains minus this
527//! session's estimated cost is still at least
528//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
529//! allowance. A session that cannot **declines**: it did not run, so it is
530//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
531//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
532//! waiting for the budget to come back. The estimate is derived offline from
533//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
534//! which is also where the published rule that model rests on is cited; the accounting
535//! above records the gate's own read like any other request, and
536//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
537//!
538//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
539//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
540//! text, which is what lets it run on every platform and on a pull request from a fork with
541//! no credential — and that is what actually stops a regression merging. But an offline
542//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
543//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
544//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
545//! number of nodes this query may return"* and whose `cost` is what that document would
546//! spend, both for a document **without executing it**, and the lane asks it for every query
547//! document this source sends, under the largest bindings this source sends, and fails when
548//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
549//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
550//! one; the offline pins still cover it. It records what those calls reported about the
551//! account's own allowance, because whether asking is free is a thing to observe rather than
552//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
553//! and `cost` is metered against an hourly allowance the accounting above reads off a
554//! credentialed run's own response headers.
555//!
556//! **GitHub has two rate limiters and this source is refused by both, so nothing here
557//! treats them as one thing.** The primary budget is the hourly allowance `gh api
558//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
559//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
560//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
561//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
562//! one part of.
563#![deny(missing_docs)]
564
565use std::collections::BTreeMap;
566use std::sync::{Arc, Mutex};
567use std::time::{Duration, Instant};
568
569use chrono::{DateTime, Utc};
570use onetaskgraph_plugin_api::{
571 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
572 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
573 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
574 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
575 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskDetailRead, TaskQuery,
576 TaskRef, TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField,
577 WriteSupport,
578};
579use reqwest::{Client, StatusCode, Url};
580use schemars::{Schema, schema_for};
581use secrecy::{ExposeSecret, SecretString};
582use serde::{Deserialize, Serialize};
583use serde_json::{Value, json};
584
585pub mod accounting;
586
587use accounting::Accounting;
588
589/// The registry name for this plugin.
590pub const KIND: &str = "github-projects";
591/// GitHub's maximum connection page size.
592pub const MAX_PAGE_SIZE: u32 = 100;
593/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
594/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
595/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
596pub const SEARCH_PAGE_SIZE: u32 = 20;
597/// How many items one [`graphql::ISSUE_DETAILS`] request reads, each with the first page of
598/// its comments: the largest batch the node-count model prices at one point.
599///
600/// Each aliased item is resolved once, and what GitHub charges for it is the connections
601/// under it — its labels, its page of board memberships, the field values of each of those
602/// three memberships, and its comments: six requests' worth of the aggregate GitHub divides
603/// by a hundred and rounds. Twenty-four items come to 144, which rounds to one point;
604/// twenty-five come to 150, which rounds to two. `tests/point_cost.rs` prices the document at
605/// one point and fails if one item more would still be priced at one.
606pub const DETAIL_BATCH: usize = 24;
607
608/// The most nodes any one document this source sends may be asked to return.
609///
610/// GitHub's own published per-query ceiling, taken from
611/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
612/// workspace cannot hold a stale copy of somebody else's number. A query above it is
613/// **refused before it is executed**, whoever is asking and whatever board they are
614/// asking about — so this is a bound on the documents rather than a budget that runs out.
615///
616/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
617/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
618/// everything the credential does — two numbers against two limits, and this constant
619/// bounds only the first. The second is computed offline too, per document:
620/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
621/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
622/// lane. There is no constant like this one to hold a price under, because points are an
623/// hourly allowance rather than a per-call bound.
624///
625/// Neither is a session's price. What `session-cost.md` records of a whole session is its
626/// **requests** and its **worst-case nodes**; what a whole session spends in points is
627/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
628/// [`accounting`]. The module section on the three ways this source reaches an item says how
629/// the count is arrived at, and which of the page sizes below decide it.
630pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
631
632/// Nested connection size for the connections that hang off one item.
633///
634/// It multiplies through every document that reaches an item under a page — the count
635/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
636/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
637/// every document under these constants and fails naming any that reaches the limit, so
638/// raising this is caught there rather than by GitHub.
639const NESTED_PAGE_SIZE: u32 = 50;
640/// How many of one issue's board memberships are read when an issue is reached directly.
641///
642/// An issue reached through a search or through its own node id carries its board half in
643/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
644/// under a page of issues, so every point of it multiplies through the whole document and
645/// is paid for whether or not any issue is on a second board — which is why it is
646/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
647///
648/// **Three, because what a page misses is now recovered rather than refused**, and the
649/// recovery is what the value is chosen against. An issue whose entry for this board sits
650/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
651/// that page's own cursor — so the value trades a bound every read pays for a request only
652/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
653/// boards would pay that request *per issue*, which is order N against the one page per
654/// hundred issues a read costs today. At three it is only reached by an issue on four or
655/// more boards at once, which keeps the recovery path exceptional rather than routine for
656/// a plausible deployment.
657const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
658/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
659/// its two connections for.
660///
661/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
662/// second is a duplicate a copy already takes the first of. Both connections are walked to
663/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
664/// never what the answer is. It is small because every point of it is paid on every lookup,
665/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
666/// worst-case nodes than the one whole-board read they replaced.
667const ORIGIN_PAGE_SIZE: u32 = 3;
668
669pub use github_graphql_node_count::{NodeCountError, Variables};
670
671/// The largest value this source can bind to each page-size variable its documents name.
672///
673/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
674/// constant above it wherever a caller's own limit could reach it — `$first` at
675/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
676/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
677/// not one configuration of it, which is what makes a bound computed under it a bound on
678/// every read.
679pub fn largest_page_sizes() -> Variables {
680 Variables::from([
681 ("first".to_owned(), MAX_PAGE_SIZE),
682 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
683 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
684 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
685 ])
686}
687
688/// The most nodes `document` could be asked to return, by GitHub's published rules.
689///
690/// Computed offline from the document's own text under [`largest_page_sizes`] — no
691/// network, no credential and no schema — by
692/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
693/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
694/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
695///
696/// # Errors
697///
698/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
699/// no single operation, or binds a page size this source does not name — each of which is
700/// a defect in the document rather than a number.
701pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
702 node_count(document, &largest_page_sizes())
703}
704
705/// The most rate-limit points one call of `document` could spend, by GitHub's published
706/// rules.
707///
708/// Computed offline from the document's own text under [`largest_page_sizes`] — no
709/// network, no credential and no schema — by
710/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
711/// This is `cost`, metered **per hour** against the allowance one credential shares across
712/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
713/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
714/// under, so what `tests/point_cost.rs` does with it is pin every document in
715/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
716/// figures against GitHub's own reported `cost`.
717///
718/// # Errors
719///
720/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
721/// no single operation, or binds a page size this source does not name — each of which is
722/// a defect in the document rather than a number.
723pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
724 github_graphql_node_count::point_cost(document, &largest_page_sizes())
725}
726
727/// The most nodes `document` could be asked to return under `variables`.
728///
729/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
730/// [`accounting`] is this under the bindings one request really sent — one spelling of the
731/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
732/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
733///
734/// # Errors
735///
736/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
737/// no single operation, or binds a page size `variables` does not name.
738pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
739 github_graphql_node_count::node_count(document, variables)
740}
741
742/// The issue-title prefix that makes a board issue a document.
743///
744/// A GitHub Projects board has no document type — it holds issues — so the discriminator
745/// is the title, and this is the whole of it: an issue whose title begins with these bytes
746/// is a document and every other issue is the task or project the sub-issue rule makes it.
747///
748/// It is spelled **once**, here, and read rather than restated everywhere else — including
749/// by the shared journeys, which take it from this constant so a board fixture cannot
750/// drift from what this source reads. `docs/metadata.md` records the two consequences that
751/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
752/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
753/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
754pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
755
756/// Exact GraphQL query documents issued by this plugin.
757///
758/// Keeping the production documents here lets the pinned-schema test validate the same
759/// bytes that are sent to GitHub, rather than a test-only copy which could drift
760/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
761/// field, and its guarded caller always supplies the complete existing option set with ids.
762pub mod graphql {
763 /// The board half of one item: the field values every document here reads it from.
764 ///
765 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
766 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
767 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
768 /// *the same value*, because
769 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
770 /// one path. Three spellings of it is what would drift, so there is one.
771 ///
772 /// The `Status` option and this source's own origin text field are the whole of it. It
773 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
774 /// content, so it holds nothing the content's own `labels` do not already say, and it
775 /// would sit a label connection two page sizes deep.
776 macro_rules! board_item_values {
777 () => {
778 r#"fieldValues(first:$nestedFirst){nodes{
779 ... on ProjectV2ItemFieldSingleSelectValue{name field{
780 ... on ProjectV2SingleSelectField{id name options{id name}}
781 }}
782 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
783 }pageInfo{hasNextPage}}"#
784 };
785 }
786
787 /// Everything this source reads about one issue, wherever it reaches that issue.
788 ///
789 /// A macro rather than a constant so the three documents below can `concat!` it: one
790 /// spelling of these fields is what makes an issue read through the board-scoped
791 /// search, through its own node id, and through its project's sub-issue relationship
792 /// resolve to *the same* item, which is the whole of what
793 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
794 ///
795 /// `projectItems` is what carries the board half of an issue: the board item's own id
796 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
797 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
798 /// issue rather than on the board, which is what makes the cost of a read proportional
799 /// to what was asked for instead of to the board's size.
800 ///
801 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
802 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
803 /// not on that page: a page here is where the search for the entry starts rather than
804 /// where it ends.
805 ///
806 /// It does **not** select the board's `Labels` field value, and that is the whole of
807 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
808 /// a label connection there sits under `fieldValues` under `projectItems` under a page
809 /// of issues, spending `$nestedFirst` twice down one path, and took
810 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
811 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
812 /// above, and that connection is where every label this source reports comes from. No
813 /// document in this module selects the board field any longer, [`BOARD`] included; the
814 /// module documentation records why nothing it could have held is lost.
815 macro_rules! board_issue {
816 () => {
817 concat!(
818 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
819 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
820 projectItems(first:$boardItems){nodes{id project{id number}
821 "#,
822 board_item_values!(),
823 r#"}pageInfo{hasNextPage endCursor}}}"#
824 )
825 };
826 }
827
828 /// Every issue of one board, found by a search scoped to that board.
829 ///
830 /// This is how the projects a board holds are listed, and it selects no `items`
831 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
832 /// container walked page by page, so nothing nested inside a board item is paid for.
833 /// Which of the issues it returns is a project is then read off `parent` — GitHub
834 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
835 /// discriminator has to be applied to the field, which is a scalar on the issue and
836 /// costs nothing.
837 pub const SEARCH_ISSUES: &str = concat!(
838 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
839 search(query:$search,type:$type,first:$first,after:$after){
840 pageInfo{hasNextPage endCursor}
841 nodes{__typename ...BoardIssue}
842 }
843 }"#,
844 board_issue!()
845 );
846
847 /// What a dependency read selects of each far end: enough to say which kind of item it
848 /// is, its body included for the kind marker.
849 macro_rules! related_issue {
850 () => {
851 " fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"
852 };
853 }
854
855 /// One issue by its own node id, which is what a qualified id names here — with what a
856 /// write of it needs and the issue does not carry in `board_issue!`: the field
857 /// definitions of the boards it sits on, and the far ends of its `blockedBy`.
858 ///
859 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
860 /// answers a write made moments ago with the value from before it, and resolving a node
861 /// id does not.
862 ///
863 /// **Why those two ride here and not on the fragment.** A copy or an update of an item
864 /// reads it by its own id, and with them that one read answers everything the write
865 /// needs: which option ids the board's `Status` and `Priority` fields hold — so no
866 /// [`BOARD_FIELDS`] — and which issues block it, with each one's kind — so no
867 /// [`ISSUE_DEPENDENCIES`]. On `board_issue!` they would sit under the hundred-issue
868 /// pages of [`SEARCH_ISSUES`] and [`SUB_ISSUES`], multiplying both documents' price. Here
869 /// they sit under one item, and this read is still one point.
870 pub const ISSUE: &str = concat!(
871 r#"query($id:ID!,$first:Int!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
872 node(id:$id){__typename ...BoardIssue ... on Issue{
873 boards:projectItems(first:$boardItems){nodes{project{id number fields(first:$nestedFirst){nodes{
874 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
875 ... on ProjectV2Field{__typename id name}
876 }pageInfo{hasNextPage}}}}}
877 blockedBy(first:$first){nodes{...Related}pageInfo{hasNextPage endCursor}}
878 }}
879 }"#,
880 board_issue!(),
881 related_issue!()
882 );
883
884 /// One project's tasks: the sub-issues of the issue that project is.
885 ///
886 /// The work this costs is the project's own size. Nothing about it grows as the board
887 /// gains projects, or as those projects gain tasks.
888 pub const SUB_ISSUES: &str = concat!(
889 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
890 node(id:$id){__typename
891 ... on Issue{subIssues(first:$first,after:$after){
892 pageInfo{hasNextPage endCursor}
893 nodes{__typename ...BoardIssue}
894 }}}
895 }"#,
896 board_issue!()
897 );
898
899 /// What a read of the board's own `items` selects of each item's content.
900 ///
901 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
902 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
903 /// content by one spelling.
904 macro_rules! board_item_content {
905 () => {
906 r#" content{
907 ... 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}}}
908 ... on PullRequest{__typename id}
909 ... on DraftIssue{__typename id title body createdAt updatedAt}
910 }"#
911 };
912 }
913
914 /// Reads the board's fields and one page of its items.
915 pub const BOARD: &str = concat!(
916 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
917 owner:repositoryOwner(login:$owner){
918 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
919 }
920 } fragment Board on ProjectV2 { id title
921 fields(first:$nestedFirst){nodes{
922 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
923 ... on ProjectV2Field{__typename id name}
924 }pageInfo{hasNextPage}}
925 items(first:$first,after:$after){nodes{id "#,
926 board_item_values!(),
927 board_item_content!(),
928 r#"} pageInfo{hasNextPage endCursor}}
929 }"#
930 );
931
932 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
933 /// the board.
934 ///
935 /// **`originItems`** is the board's own items narrowed by its own field filter —
936 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
937 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
938 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
939 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
940 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
941 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
942 /// fresh `addProjectV2ItemById` the way that connection does.
943 ///
944 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
945 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
946 /// indexes that comment, and the index catches up with a write in a second or two rather
947 /// than in minutes, so it finds a carrier another process wrote that the first read is
948 /// still behind on.
949 ///
950 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
951 /// — and resumes from its own cursor; a connection already walked to its end is resumed
952 /// from its last cursor, which answers an empty page. Every candidate either read returns
953 /// is confirmed against its own origin field before it is reported, so a token match of
954 /// the search or anything else the filter admits never is.
955 ///
956 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
957 /// own whole reads counts this one among them.
958 pub const ORIGIN_LOOKUP: &str = concat!(
959 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
960 originItems:repositoryOwner(login:$owner){
961 ... on ProjectV2Owner{projectV2(number:$number){
962 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
963 board_item_values!(),
964 board_item_content!(),
965 r#"} pageInfo{hasNextPage endCursor}}
966 }}
967 }
968 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
969 pageInfo{hasNextPage endCursor}
970 nodes{__typename ...BoardIssue}
971 }
972 }"#,
973 board_issue!()
974 );
975
976 /// The board's own id and field definitions, and not one of its items.
977 ///
978 /// What a write needs of the board when the item it writes does not say: the id a field
979 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
980 /// origin fields. It selects no `items`, so what it costs is the board's field list
981 /// however many items the board holds — and it decides nothing about which items those
982 /// are, which is the question a read of one item by its own id answers instead.
983 ///
984 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
985 /// board's item reads by their root counts this one among them.
986 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
987 boardFields:repositoryOwner(login:$owner){
988 ... on ProjectV2Owner{projectV2(number:$number){id
989 fields(first:$nestedFirst){nodes{
990 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
991 ... on ProjectV2Field{__typename id name}
992 }pageInfo{hasNextPage}}
993 }}
994 }
995 }"#;
996
997 /// One board draft by its own node id, with the board item it sits in.
998 ///
999 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
1000 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
1001 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
1002 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
1003 /// board listing hands it to, and nothing has to list the board to find one.
1004 pub const DRAFT: &str = concat!(
1005 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
1006 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
1007 projectV2Items(first:$boardItems){nodes{id project{id number}
1008 "#,
1009 board_item_values!(),
1010 r#"}pageInfo{hasNextPage endCursor}}}}
1011 }"#
1012 );
1013
1014 /// One issue's board memberships alone, walked past the page a read of it carried.
1015 ///
1016 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
1017 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
1018 /// boards than that page holds may have this board's entry past its end. This asks that
1019 /// one issue for its memberships and nothing else — the caller already holds the issue —
1020 /// so an answer of "this board does not hold it" is only ever given about a connection
1021 /// read to exhaustion.
1022 ///
1023 /// It selects the board item's id, its project number and the same
1024 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
1025 /// very same resolver: an issue recovered this way reports the same title, the same
1026 /// status, the same labels and the same qualified id as one whose entry was on the
1027 /// page.
1028 ///
1029 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
1030 /// multiplies through it and the membership connection can be walked at
1031 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
1032 /// further request for any issue a person really keeps.
1033 pub const ISSUE_BOARD_ITEMS: &str = concat!(
1034 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
1035 node(id:$id){
1036 ... on Issue{projectItems(first:$first,after:$after){
1037 nodes{id project{id number}
1038 "#,
1039 board_item_values!(),
1040 r#"}
1041 pageInfo{hasNextPage endCursor}}}
1042 }
1043 }"#
1044 );
1045 /// Resolves the configured repository's node id, which creating an issue requires.
1046 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
1047 /// What creating an issue needs and has not read yet: the board's own id and field
1048 /// definitions, as [`BOARD_FIELDS`] reads them, and the node id of the repository the
1049 /// issue is created in, as [`REPOSITORY`] reads it — in one request.
1050 ///
1051 /// Sent at the point a create knows which repository it is for, when neither half is
1052 /// already known to this process; a create needing only one of them sends that one's own
1053 /// document. Neither half is kept past the process: a field's option ids are re-minted by
1054 /// `sources fields --apply`, so a copy of them held between runs would write the wrong
1055 /// status.
1056 pub const CREATION_CONTEXT: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!,$repositoryOwner:String!,$repositoryName:String!){
1057 boardFields:repositoryOwner(login:$owner){
1058 ... on ProjectV2Owner{projectV2(number:$number){id
1059 fields(first:$nestedFirst){nodes{
1060 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
1061 ... on ProjectV2Field{__typename id name}
1062 }pageInfo{hasNextPage}}
1063 }}
1064 }
1065 repository(owner:$repositoryOwner,name:$repositoryName){id nameWithOwner}
1066 }"#;
1067 /// Reads both dependency directions for one issue, with each far end's own kind — and
1068 /// the issue's own body, which is where an edge to another source is recorded, so that
1069 /// half of a dependency read needs no second read of the issue or of the board.
1070 pub const ISSUE_DEPENDENCIES: &str = concat!(
1071 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
1072 ... on Issue{body
1073 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1074 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
1075 }}}"#,
1076 related_issue!()
1077 );
1078 /// Creates one issue in the configured repository, on no board: [`ADD_TO_BOARD`] files
1079 /// it. `CreateIssueInput.projectV2Ids` is not sent — see the crate's notes on what GitHub
1080 /// answered when it was.
1081 pub const CREATE_ISSUE: &str =
1082 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
1083 /// Puts an existing issue on the configured board.
1084 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
1085 /// Updates an issue's visible fields and its open or closed state in one call.
1086 pub const UPDATE_ISSUE: &str =
1087 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
1088 /// Updates an existing draft's user-visible fields.
1089 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
1090 /// Updates a text or single-select value on one project item.
1091 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}}}}}}}}"#;
1092 /// Writes up to three board fields and an optional clear in one ordered mutation.
1093 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}}}"#;
1094 /// Clears one project item's value of one field, which is what a `none` priority is.
1095 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}}}}}}}}"#;
1096 /// Creates one single-select field with its options. Only the guarded field setup may use
1097 /// this document, and only for a field the board lacks.
1098 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
1099 /// Replaces a single-select field's options. Only the guarded field setup — the
1100 /// `status-options` and `fields` operations — may use this document, because GitHub
1101 /// treats the input as the complete option list.
1102 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
1103 /// A fresh snapshot of the Status field and every board item's assignment.
1104 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}}}}}}"#;
1105 /// Files one issue under another as a sub-issue, which is what project membership is.
1106 pub const ADD_SUB_ISSUE: &str =
1107 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1108 /// Takes one issue back out of its parent.
1109 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
1110 /// Adds GitHub's native issue blocked-by relationship.
1111 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1112 /// Removes one native issue blocked-by relationship.
1113 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
1114 /// Deletes one issue, which takes its board item with it.
1115 ///
1116 /// The engine sends this in one situation only: undoing a copy that could not finish,
1117 /// over the items that same copy created. Deleting the issue removes the board item
1118 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
1119 pub const DELETE_ISSUE: &str =
1120 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1121
1122 /// Everything this source reads about one issue comment, wherever it reaches one.
1123 ///
1124 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1125 /// and a comment just edited are handed to one mapper, so they are selected by one
1126 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1127 /// longer exists, and `login` is the one member every kind of actor carries.
1128 macro_rules! issue_comment {
1129 () => {
1130 "id author{login} createdAt updatedAt body url"
1131 };
1132 }
1133
1134 /// One task's comments: a page of its issue's own `comments` connection.
1135 ///
1136 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1137 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1138 /// list every time somebody edited it; left unordered the connection answers in the order
1139 /// the comments were written, which is the order GitHub documents for the same collection
1140 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1141 /// node count and the caller's own page size is pushed straight down.
1142 pub const ISSUE_COMMENTS: &str = concat!(
1143 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1144 issue_comment!(),
1145 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1146 );
1147 /// One issue by its own node id, with a page of its comments: what `task show` and a
1148 /// comment listing read, in one request.
1149 ///
1150 /// [`ISSUE`] and [`ISSUE_COMMENTS`] in one document, rather than one then the other. The
1151 /// comments are selected here and **not** on the shared `board_issue!` fragment, which
1152 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] nest under a page of a hundred issues: a comment
1153 /// connection there would multiply through both of those documents' price, and neither
1154 /// needs one.
1155 pub const ISSUE_DETAIL: &str = concat!(
1156 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
1157 node(id:$id){__typename ...BoardIssue ... on Issue{comments(first:$first,after:$after){nodes{"#,
1158 issue_comment!(),
1159 r#"}pageInfo{hasNextPage endCursor}}}}
1160 }"#,
1161 board_issue!()
1162 );
1163
1164 /// One alias of [`ISSUE_DETAILS`]: the item a batch's `$id<n>` names, with the first
1165 /// page of its comments when `$comments` asks for them.
1166 macro_rules! issue_details_alias {
1167 ($n:literal) => {
1168 concat!(
1169 "\n i",
1170 stringify!($n),
1171 ":node(id:$id",
1172 stringify!($n),
1173 "){__typename ...BoardIssue ... on Issue{comments(first:$first) @include(if:$comments){nodes{",
1174 issue_comment!(),
1175 "}pageInfo{hasNextPage endCursor}}}}"
1176 )
1177 };
1178 }
1179
1180 /// [`ISSUE_DETAIL`] for [`DETAIL_BATCH`](super::DETAIL_BATCH) items at once, each by its
1181 /// own node id, as one fixed-size document of aliased `node(id:)` fields.
1182 ///
1183 /// **Aliased `node(id:)` rather than `nodes(ids:)`, and that is what keeps its price
1184 /// honest.** The `github-graphql-node-count` model this workspace prices with treats a
1185 /// field that supplies neither `first` nor `last` as free, and `nodes(ids:)` supplies
1186 /// neither — so every connection under it would be priced at nothing and the pin in
1187 /// `tests/point_cost.rs` would understate what GitHub charges. Each alias here is the
1188 /// one-item read the model already prices, so the batch costs what its aliases cost.
1189 ///
1190 /// **Fixed-size, so there is one document to price.** A batch of fewer items binds the
1191 /// slots it has no item for to the last item it does, and reads that item again; the
1192 /// price is the document's, whatever its variables, so a short batch costs what a full
1193 /// one does and nothing more.
1194 pub const ISSUE_DETAILS: &str = concat!(
1195 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!){"#,
1196 issue_details_alias!(0),
1197 issue_details_alias!(1),
1198 issue_details_alias!(2),
1199 issue_details_alias!(3),
1200 issue_details_alias!(4),
1201 issue_details_alias!(5),
1202 issue_details_alias!(6),
1203 issue_details_alias!(7),
1204 issue_details_alias!(8),
1205 issue_details_alias!(9),
1206 issue_details_alias!(10),
1207 issue_details_alias!(11),
1208 issue_details_alias!(12),
1209 issue_details_alias!(13),
1210 issue_details_alias!(14),
1211 issue_details_alias!(15),
1212 issue_details_alias!(16),
1213 issue_details_alias!(17),
1214 issue_details_alias!(18),
1215 issue_details_alias!(19),
1216 issue_details_alias!(20),
1217 issue_details_alias!(21),
1218 issue_details_alias!(22),
1219 issue_details_alias!(23),
1220 "\n }",
1221 board_issue!()
1222 );
1223
1224 /// Which issue one comment is on, read before that comment is edited or removed.
1225 ///
1226 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1227 /// comment id given against the wrong task would change a comment on another issue.
1228 pub const COMMENT_ISSUE: &str =
1229 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1230 /// Adds one comment to an issue, signed as the account the token belongs to.
1231 pub const ADD_COMMENT: &str = concat!(
1232 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1233 issue_comment!(),
1234 r#"}}}}"#
1235 );
1236 /// Replaces the body of one issue comment.
1237 pub const UPDATE_COMMENT: &str = concat!(
1238 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1239 issue_comment!(),
1240 r#"}}}"#
1241 );
1242 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1243 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1244
1245 /// Every document above, with what this source is doing when it sends one.
1246 ///
1247 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1248 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1249 /// document added later with "talking to GitHub" and never say so.
1250 ///
1251 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1252 /// const` here that this list omits, so the two cannot part — which is the same guard
1253 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1254 pub const DOCUMENTS: [(&str, &str); 33] = [
1255 (SEARCH_ISSUES, "searching this board's issues"),
1256 (ISSUE, "reading one issue"),
1257 (
1258 ISSUE_BOARD_ITEMS,
1259 "reading one issue's board memberships past the page it came with",
1260 ),
1261 (SUB_ISSUES, "reading a project's tasks"),
1262 (BOARD, "reading the board"),
1263 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1264 (BOARD_FIELDS, "reading the board's fields"),
1265 (DRAFT, "reading one draft"),
1266 (REPOSITORY, "reading the destination repository"),
1267 (
1268 CREATION_CONTEXT,
1269 "reading the board's fields and the destination repository",
1270 ),
1271 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1272 (CREATE_ISSUE, "creating an issue"),
1273 (ADD_TO_BOARD, "adding an issue to the board"),
1274 (UPDATE_ISSUE, "updating an issue"),
1275 (UPDATE_DRAFT, "updating a draft item"),
1276 (UPDATE_FIELD, "writing a board field"),
1277 (UPDATE_FIELDS, "writing board fields together"),
1278 (CLEAR_FIELD, "clearing a board field"),
1279 (
1280 CREATE_FIELD,
1281 "creating a board single-select field with its options",
1282 ),
1283 (
1284 STATUS_OPTIONS_SNAPSHOT,
1285 "snapshotting board Status options and assignments",
1286 ),
1287 (
1288 STATUS_OPTIONS_UPDATE,
1289 "safely replacing the board Status option list",
1290 ),
1291 (ADD_SUB_ISSUE, "filing an issue under its project"),
1292 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1293 (ADD_BLOCKED_BY, "recording a dependency"),
1294 (REMOVE_BLOCKED_BY, "removing a dependency"),
1295 (DELETE_ISSUE, "deleting an issue"),
1296 (ISSUE_COMMENTS, "reading a task's comments"),
1297 (ISSUE_DETAIL, "reading one issue with its comments"),
1298 (
1299 ISSUE_DETAILS,
1300 "reading a batch of issues with their comments",
1301 ),
1302 (COMMENT_ISSUE, "reading which issue a comment is on"),
1303 (ADD_COMMENT, "adding a comment"),
1304 (UPDATE_COMMENT, "editing a comment"),
1305 (DELETE_COMMENT, "deleting a comment"),
1306 ];
1307}
1308
1309/// Which of GitHub's two rate limiters refused a request.
1310///
1311/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1312/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1313/// the whole reason this is carried rather than collapsed into "rate limited".
1314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315enum Limiter {
1316 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1317 Primary,
1318 /// The burst limiter over content-generating requests, which nothing reports.
1319 Secondary,
1320}
1321
1322/// The wordings GitHub answers a secondary rate limit with.
1323///
1324/// It sends them under a forbidden status, under a too-many-requests status, and inside
1325/// the `errors` of a *successful* response, which is why the text is what this matches on
1326/// rather than the status. `abuse detection` is the wording GitHub used before the
1327/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1328/// what a burst of content creation is refused with.
1329///
1330/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1331/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1332/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1333/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1334pub const SECONDARY_WORDINGS: [&str; 5] = [
1335 "secondary rate limit",
1336 "temporarily blocked from content creation",
1337 "abuse detection",
1338 "submitted too quickly",
1339 "exceeded a secondary",
1340];
1341
1342/// The wordings GitHub answers an exhausted primary budget with.
1343///
1344/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1345/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1346/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1347/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1348/// two phrases is a substring of it, so without it that answer read as a refusal that will
1349/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1350/// one reason.
1351pub const PRIMARY_WORDINGS: [&str; 4] = [
1352 "api rate limit exceeded",
1353 "api rate limit already exceeded",
1354 "rate limit exceeded",
1355 "rate_limited",
1356];
1357
1358/// What a response *says about itself*, which is the only place a refusal can be read.
1359///
1360/// Deliberately not the whole response body. A board is a place people write about their
1361/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1362/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1363/// reported. So the item data is never read: what is read is GitHub's own REST-style
1364/// `message` envelope, which is what a forbidden status carries, and the `message` and
1365/// `type` of each GraphQL error, which is where a *successful* response says it.
1366///
1367/// A body that is not JSON at all has nothing structured to read, so only a failing
1368/// response's own text is taken — a successful response that is not JSON is malformed
1369/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1370fn refusal_wording(status: StatusCode, body: &str) -> String {
1371 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1372 return if status.is_success() {
1373 String::new()
1374 } else {
1375 body.to_owned()
1376 };
1377 };
1378 let mut said: Vec<&str> = parsed
1379 .get("message")
1380 .and_then(Value::as_str)
1381 .into_iter()
1382 .collect();
1383 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1384 for error in errors {
1385 said.extend(
1386 ["message", "type"]
1387 .into_iter()
1388 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1389 );
1390 }
1391 }
1392 said.join("; ")
1393}
1394
1395impl Limiter {
1396 /// Which limiter refused this response, or `None` when none of them did.
1397 ///
1398 /// The wording is read first and the status only decides what carries none of it,
1399 /// because GitHub answers a secondary limit with a forbidden status far more often
1400 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1401 /// really is a credential this token lacks.
1402 ///
1403 /// A response is a refusal because of its status or its own wording. A spent budget
1404 /// only ever explains one; it never turns an answer into a refusal.
1405 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1406 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1407 if SECONDARY_WORDINGS
1408 .iter()
1409 .any(|wording| normalized.contains(wording))
1410 {
1411 return Some(Self::Secondary);
1412 }
1413 if status == StatusCode::TOO_MANY_REQUESTS {
1414 return Some(Self::Primary);
1415 }
1416 // An exhausted budget *explains* a response that failed; it does not make one that
1417 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1418 // request the budget allowed as well as on the ones it then refuses, so reading
1419 // the header alone threw away a good answer — and, once refusals were retried,
1420 // replayed a request that had already taken effect.
1421 if !status.is_success() && budget_exhausted {
1422 return Some(Self::Primary);
1423 }
1424 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1425 // `errors` of an HTTP 200, where nothing about the status says so at all.
1426 if status.is_success()
1427 && PRIMARY_WORDINGS
1428 .iter()
1429 .any(|wording| normalized.contains(wording))
1430 {
1431 return Some(Self::Primary);
1432 }
1433 None
1434 }
1435
1436 /// What this limiter is called where an operator can look it up.
1437 const fn name(self) -> &'static str {
1438 match self {
1439 Self::Primary => "GitHub's primary API rate limit",
1440 Self::Secondary => "GitHub's secondary rate limit",
1441 }
1442 }
1443
1444 /// What the endpoint an operator would go and check says about this limiter.
1445 const fn where_to_look(self) -> &'static str {
1446 match self {
1447 Self::Primary => {
1448 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1449 comes back."
1450 }
1451 Self::Secondary => {
1452 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1453 primary budget and does not report this one, so budget showing there says \
1454 nothing about this refusal, and every further attempt extends it."
1455 }
1456 }
1457 }
1458
1459 /// The next step this limiter actually calls for.
1460 const fn what_to_do(self) -> &'static str {
1461 match self {
1462 Self::Primary => {
1463 "wait for the reset `gh api rate_limit` reports, then run the command again."
1464 }
1465 Self::Secondary => {
1466 "leave this board alone for a few minutes, then run the command again — or \
1467 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1468 }
1469 }
1470 }
1471}
1472
1473/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1474#[derive(Debug, Clone, Copy)]
1475struct Limited {
1476 limiter: Limiter,
1477 hint: Option<u64>,
1478}
1479
1480impl Limited {
1481 /// What the caller is told once this source has waited as long as it may.
1482 ///
1483 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1484 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1485 /// about *which* limiter it was makes it a different kind of failure. What differs is
1486 /// the operator's next step, and that is what the message carries — a secondary
1487 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1488 /// budget looks fine, and then back to retry the very burst that was refused.
1489 fn exhausted(
1490 self,
1491 doing: &str,
1492 waits: u32,
1493 waited: Duration,
1494 needed: Duration,
1495 budget: Duration,
1496 ) -> SourceError {
1497 SourceError::RateLimited {
1498 retry_after_seconds: self.hint,
1499 message: Some(format!(
1500 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1501 again, and the next wait of {} would take it past the {} one call may spend \
1502 waiting. {} next: {}",
1503 self.limiter.name(),
1504 plural(waits, "refusal"),
1505 seconds(waited),
1506 seconds(needed),
1507 seconds(budget),
1508 self.limiter.where_to_look(),
1509 self.limiter.what_to_do(),
1510 )),
1511 }
1512 }
1513}
1514
1515/// One HTTP attempt's result, with what its response said about the rate limit.
1516///
1517/// The two travel together so the record and the outcome are written from the same place:
1518/// what a response said about the budget is only readable while that response is in hand,
1519/// and what the attempt *meant* is only decidable once its body has been read.
1520struct Attempted {
1521 result: Result<Value, Attempt>,
1522 limits: accounting::RateLimit,
1523 /// GitHub's own reported cost for this call, for a document that asked for it.
1524 reported_cost: Option<u64>,
1525}
1526
1527/// One attempt's outcome: an error to report, or a rate limit to wait out.
1528enum Attempt {
1529 Failed(SourceError),
1530 Limited(Limited),
1531}
1532
1533fn plural(count: u32, thing: &str) -> String {
1534 if count == 1 {
1535 format!("{count} {thing}")
1536 } else {
1537 format!("{count} {thing}s")
1538 }
1539}
1540
1541fn seconds(duration: Duration) -> String {
1542 format!("{:.1}s", duration.as_secs_f64())
1543}
1544
1545/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1546///
1547/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1548/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1549/// header, and neither is what makes a response a refusal — so the whole cost of one this
1550/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1551/// instead. Refusing the response over the header would turn a readable refusal into an
1552/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1553fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1554 value
1555 .and_then(|value| value.to_str().ok())
1556 .and_then(|value| value.trim().parse::<u64>().ok())
1557}
1558
1559/// Every mutation this source sends creates content — an issue, a board item, a field of
1560/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1561/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1562/// and what the keyword says are the same set. That is what makes the keyword a sound test
1563/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1564/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1565fn is_mutation(query: &str) -> bool {
1566 query.trim_start().starts_with("mutation")
1567}
1568
1569/// What this source was doing, for a diagnostic that has to say so.
1570///
1571/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1572/// a document added without a description is caught by that list's own gate instead of
1573/// falling through to the vague arm below.
1574fn operation_description(query: &str) -> &'static str {
1575 graphql::DOCUMENTS
1576 .iter()
1577 .find(|(document, _)| *document == query)
1578 .map_or("talking to GitHub", |(_, doing)| *doing)
1579}
1580
1581/// GitHub's published ceiling on content-generating requests, per minute.
1582///
1583/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1584/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1585/// from it, so a pacing value checked only against itself cannot go stale here.
1586pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1587/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1588/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1589/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1590pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1591/// Shortest interval between two content-creating mutations, in milliseconds.
1592///
1593/// GitHub documents two secondary limits on content-generating requests:
1594/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1595/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1596/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1597/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1598/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1599/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1600/// deliberately *not* what this paces at. An installation that wants the hourly bound
1601/// honoured for a long sequence of copies says so through
1602/// `pacing.min_mutation_interval_ms`.
1603pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1604/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1605///
1606/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1607/// own advice for a secondary limit — wait, and wait longer each time — without spending
1608/// the first minute of a transient refusal doing nothing.
1609pub const RETRY_BACKOFF_MS: u64 = 1_000;
1610/// Total time one call may spend waiting out rate limits before it reports a failure.
1611///
1612/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1613/// short enough that a command an operator is watching returns. The bound is what makes
1614/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1615/// the limiter, not in a process nobody can tell from a wedged one.
1616pub const RETRY_BUDGET_MS: u64 = 120_000;
1617
1618fn default_token_env() -> String {
1619 "GH_PROJECTS_TOKEN".to_owned()
1620}
1621fn default_endpoint() -> String {
1622 "https://api.github.com/graphql".to_owned()
1623}
1624
1625/// Where one status category lands on this board.
1626///
1627/// `null` — an absent value — disables the category for this instance, and using a
1628/// disabled status is a refusal naming the status and the instance.
1629#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1630#[serde(untagged)]
1631pub enum StatusTargetConfig {
1632 /// The name of a `Status` single-select option already on the board.
1633 Column(ColumnName),
1634}
1635
1636/// The name of a `Status` single-select option on the board.
1637///
1638/// Validated on the way in rather than checked later, so a blank option name — which
1639/// nothing on a board can be — is a state this type cannot hold.
1640#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1641#[serde(try_from = "String")]
1642#[schemars(extend("minLength" = 1))]
1643pub struct ColumnName(String);
1644
1645impl ColumnName {
1646 /// The option name, as the board spells it.
1647 fn as_str(&self) -> &str {
1648 &self.0
1649 }
1650}
1651
1652impl TryFrom<String> for ColumnName {
1653 type Error = String;
1654
1655 fn try_from(name: String) -> Result<Self, Self::Error> {
1656 if name.trim().is_empty() {
1657 return Err("a status_mapping option name cannot be blank".to_owned());
1658 }
1659 Ok(Self(name))
1660 }
1661}
1662
1663/// The two closed states this product can mean.
1664///
1665/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1666/// work nor abandoned work, so nothing here ever writes it.
1667#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1668#[serde(rename_all = "kebab-case")]
1669pub enum ClosedState {
1670 /// `COMPLETED` — precisely done.
1671 Completed,
1672 /// `NOT_PLANNED` — precisely cancelled.
1673 NotPlanned,
1674}
1675
1676impl ClosedState {
1677 const fn reason(self) -> &'static str {
1678 match self {
1679 Self::Completed => "COMPLETED",
1680 Self::NotPlanned => "NOT_PLANNED",
1681 }
1682 }
1683}
1684
1685/// Configuration for one GitHub Projects v2 board.
1686#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1687#[serde(default, deny_unknown_fields)]
1688pub struct GitHubProjectsConfig {
1689 /// Login of the user or organization which owns the board.
1690 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1691 /// The project number shown in the board's GitHub URL.
1692 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1693 // 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.
1694 /// `owner/name` of the repository this source creates an issue in when the item's own
1695 /// `repositories` field does not decide it.
1696 ///
1697 /// An item naming exactly one repository is created there; a task or a document naming
1698 /// none or several is created in its parent project's repository; and a project, or a
1699 /// task or document with no parent, naming none or several is created here. A board
1700 /// has no repository of its own and `createIssue` requires one, so a write without
1701 /// this is refused naming the field. Reads never need it.
1702 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1703 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1704 /// Environment variable containing a fine-grained token with Projects and Issues
1705 /// read/write plus Pull requests read-only access for every repository represented on
1706 /// the board.
1707 #[serde(default = "default_token_env")]
1708 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1709 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1710 #[serde(default = "default_endpoint")]
1711 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1712 /// Per-instance mapping from a status category to where it lands on this board.
1713 ///
1714 /// A category this does not mention keeps its shipped default: `backlog` to
1715 /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1716 /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1717 /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1718 /// board option; every unknown word then lands on that option and reads back as
1719 /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1720 /// word because it never creates board options.
1721 #[serde(default)]
1722 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` parses each key into a `StatusCategory` and reports an unknown one against this instance.
1723 /// Per-instance mapping from a task's priority to an option of this board's
1724 /// single-select field named `Priority`.
1725 ///
1726 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1727 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1728 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1729 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1730 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1731 /// no two levels may name one option. Reads and writes never create the field or an
1732 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1733 /// the board lacks is refused pointing there.
1734 #[serde(default)]
1735 pub priority_mapping: Option<PriorityMappingConfig>,
1736 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1737 ///
1738 /// Every field keeps its shipped default when it is absent, and the defaults are
1739 /// GitHub's own published limits rather than taste. See [`Pacing`].
1740 #[serde(default)]
1741 pub pacing: PacingConfig,
1742}
1743
1744/// Which option of the board's `Priority` field each priority lands on.
1745///
1746/// One member per level rather than a map, so a key that is not a level is refused where
1747/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1748/// value in the field, not an option of it.
1749#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1750#[serde(default, deny_unknown_fields)]
1751pub struct PriorityMappingConfig {
1752 /// The option `urgent` lands on; `Urgent` when absent.
1753 pub urgent: Option<PriorityOptionName>,
1754 /// The option `high` lands on; `High` when absent.
1755 pub high: Option<PriorityOptionName>,
1756 /// The option `medium` lands on; `Medium` when absent.
1757 pub medium: Option<PriorityOptionName>,
1758 /// The option `low` lands on; `Low` when absent.
1759 pub low: Option<PriorityOptionName>,
1760}
1761
1762/// The name of an option of the board's `Priority` single-select field.
1763///
1764/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1765/// blank name.
1766#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1767#[serde(try_from = "String")]
1768#[schemars(extend("minLength" = 1))]
1769pub struct PriorityOptionName(String);
1770
1771impl PriorityOptionName {
1772 /// The option name, as the board spells it.
1773 fn as_str(&self) -> &str {
1774 &self.0
1775 }
1776}
1777
1778impl TryFrom<String> for PriorityOptionName {
1779 type Error = String;
1780
1781 fn try_from(name: String) -> Result<Self, Self::Error> {
1782 if name.trim().is_empty() {
1783 return Err("a priority_mapping option name cannot be blank".to_owned());
1784 }
1785 Ok(Self(name))
1786 }
1787}
1788
1789/// The name of the board field a priority is held in.
1790pub const PRIORITY_FIELD: &str = "Priority";
1791
1792/// The four priorities a board option can hold, in the order a new `Priority` field lists
1793/// them. `none` is not among them: it is the field holding no value.
1794///
1795/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1796/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1797/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1798/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1799pub const PRIORITY_LEVELS: [Priority; 4] = [
1800 Priority::Urgent,
1801 Priority::High,
1802 Priority::Medium,
1803 Priority::Low,
1804];
1805
1806/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1807/// see that list for what this pins.
1808#[must_use]
1809pub const fn level_position(priority: Priority) -> Option<usize> {
1810 match priority {
1811 Priority::None => None,
1812 Priority::Urgent => Some(0),
1813 Priority::High => Some(1),
1814 Priority::Medium => Some(2),
1815 Priority::Low => Some(3),
1816 }
1817}
1818
1819/// This instance's complete priority-to-option mapping, read in both directions.
1820///
1821/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1822/// two levels name one option.
1823#[derive(Debug, Clone)]
1824struct PriorityMapping {
1825 options: [PriorityOptionName; 4],
1826}
1827
1828impl PriorityMapping {
1829 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1830 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1831 let mapping = Self {
1832 options: [
1833 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1834 config.high.unwrap_or_else(|| shipped("High")),
1835 config.medium.unwrap_or_else(|| shipped("Medium")),
1836 config.low.unwrap_or_else(|| shipped("Low")),
1837 ],
1838 };
1839 for (index, option) in mapping.options.iter().enumerate() {
1840 if let Some(earlier) = mapping.options[..index]
1841 .iter()
1842 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1843 {
1844 return Err(SourceError::Config {
1845 message: format!(
1846 "priority_mapping of source {instance} sends both {} and {} to the board \
1847 option {:?}; one option cannot read back as two priorities",
1848 PRIORITY_LEVELS[earlier],
1849 PRIORITY_LEVELS[index],
1850 option.as_str()
1851 ),
1852 });
1853 }
1854 }
1855 Ok(mapping)
1856 }
1857
1858 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1859 fn option(&self, priority: Priority) -> Option<&str> {
1860 level_position(priority).map(|index| self.options[index].as_str())
1861 }
1862
1863 /// The priority a board option name reports, or `None` when nothing maps to it.
1864 fn priority_of(&self, option: &str) -> Option<Priority> {
1865 self.options
1866 .iter()
1867 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1868 .map(|index| PRIORITY_LEVELS[index])
1869 }
1870
1871 /// Every mapped option name, in the order a new `Priority` field lists them.
1872 fn names(&self) -> impl Iterator<Item = &str> {
1873 self.options.iter().map(PriorityOptionName::as_str)
1874 }
1875}
1876
1877/// What one item's `Priority` field says, read through this instance's mapping.
1878#[derive(Debug, Clone, PartialEq, Eq)]
1879enum HeldPriority {
1880 /// A priority this source reports: an option the mapping names, or no value (`none`).
1881 Read(Priority),
1882 /// An option the mapping does not name, which is never read as a level or as `none`.
1883 Unmapped(String),
1884}
1885
1886/// How fast this source writes, and how long it waits out a rate-limit refusal.
1887///
1888/// Configurable because a GitHub Enterprise installation sets its own limits and an
1889/// operator who has already been refused may want to go slower still — not because the
1890/// defaults are guesses.
1891#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1892#[serde(default, deny_unknown_fields)]
1893pub struct PacingConfig {
1894 /// Shortest interval between two content-creating mutations, in milliseconds.
1895 ///
1896 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1897 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1898 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.
1899 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1900 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1901 /// zero while there is a budget to spend, because a schedule of zero-length waits
1902 /// consumes none of it and so never ends.
1903 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.
1904 /// Total time one call may spend waiting out rate limits, in milliseconds.
1905 ///
1906 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1907 /// the bound is what makes this a wait rather than a hang.
1908 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.
1909}
1910
1911/// The largest any pacing setting may be, in milliseconds.
1912///
1913/// One hour. GitHub's own harshest published bound on content-generating requests works
1914/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1915/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1916/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1917/// and an interval beyond it is a command that never sends its second mutation. It also
1918/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1919/// what an `Instant` can hold on every platform.
1920pub const MAX_PACING_MS: u64 = 3_600_000;
1921
1922/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1923/// source holds.
1924#[derive(Debug, Clone, Copy)]
1925struct Pacing {
1926 min_mutation_interval: Duration,
1927 retry_backoff: Duration,
1928 retry_budget: Duration,
1929}
1930
1931impl Pacing {
1932 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1933 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1934 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1935 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1936 message: format!(
1937 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1938 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1939 GitHub's own harshest published limit"
1940 ),
1941 }),
1942 Some(value) => Ok(Duration::from_millis(value)),
1943 None => Ok(Duration::from_millis(default)),
1944 };
1945 let retry_backoff = bounded(
1946 config.retry_backoff_ms,
1947 RETRY_BACKOFF_MS,
1948 "retry_backoff_ms",
1949 )?;
1950 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1951 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1952 return Err(SourceError::Config {
1953 message: format!(
1954 "pacing.retry_backoff_ms of source {instance} is 0 while \
1955 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1956 none of that budget, so it would retry a refusal forever. Set a backoff of \
1957 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1958 waiting at all",
1959 retry_budget.as_millis()
1960 ),
1961 });
1962 }
1963 Ok(Self {
1964 min_mutation_interval: bounded(
1965 config.min_mutation_interval_ms,
1966 MIN_MUTATION_INTERVAL_MS,
1967 "min_mutation_interval_ms",
1968 )?,
1969 retry_backoff,
1970 retry_budget,
1971 })
1972 }
1973}
1974
1975/// Factory for [`GitHubProjectsSource`].
1976#[derive(Debug, Clone, Copy, Default)]
1977pub struct Plugin;
1978
1979impl SourcePlugin for Plugin {
1980 fn kind(&self) -> &'static str {
1981 KIND
1982 }
1983 fn config_schema(&self) -> Schema {
1984 schema_for!(GitHubProjectsConfig)
1985 }
1986 fn build(
1987 &self,
1988 name: &SourceName,
1989 config: &Value,
1990 secrets: &dyn SecretResolver,
1991 ) -> Result<Box<dyn TaskSource>, SourceError> {
1992 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1993 }
1994}
1995
1996impl Plugin {
1997 /// Build a source recording every request it sends into an accounting the caller holds.
1998 ///
1999 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
2000 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
2001 /// session total rather than two — see [`accounting`] and
2002 /// [`GitHubProjectsSource::recording_into`].
2003 ///
2004 /// # Errors
2005 ///
2006 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
2007 /// [`SourceError::Config`] for configuration this plugin cannot use and
2008 /// [`SourceError::Auth`] for a credential it cannot find.
2009 pub fn build_recording_into(
2010 &self,
2011 name: &SourceName,
2012 config: &Value,
2013 secrets: &dyn SecretResolver,
2014 ledger: Arc<Accounting>,
2015 ) -> Result<Box<dyn TaskSource>, SourceError> {
2016 let config: GitHubProjectsConfig =
2017 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
2018 message: format!("source {name}: {e}"),
2019 })?;
2020 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
2021 |error| match error {
2022 SourceError::Config { message } => SourceError::Config {
2023 message: format!("source {name}: {message}"),
2024 },
2025 SourceError::Auth { message } => SourceError::Auth {
2026 message: format!("source {name}: {message}"),
2027 },
2028 other => other,
2029 },
2030 )?;
2031 Ok(Box::new(source))
2032 }
2033}
2034
2035/// Where a status category lands on this board, once configuration is resolved.
2036#[derive(Debug, Clone, PartialEq, Eq)]
2037enum StatusTarget {
2038 /// Not usable against this instance.
2039 Disabled,
2040 /// The board's `Status` option of this name.
2041 Column(ColumnName),
2042 /// A closed issue, with both its board option and the reason that says which closed it means.
2043 // 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, `StatusMapping::new`, 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.
2044 Terminal(ColumnName, ClosedState),
2045}
2046
2047/// Every status category, in the order the vocabulary declares them.
2048///
2049/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
2050/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
2051/// added to the shared vocabulary fails to compile until it is named there, and this
2052/// crate's suite reconciles this list against that enum's own derived schema, which is
2053/// generated from the variants rather than written beside them. The schema is what
2054/// catches a list left one short — a list checking only the positions it already holds
2055/// would pass while every mapping indexed by the new position panicked.
2056pub const CATEGORIES: [StatusCategory; 8] = [
2057 StatusCategory::Draft,
2058 StatusCategory::Backlog,
2059 StatusCategory::Todo,
2060 StatusCategory::Queued,
2061 StatusCategory::InProgress,
2062 StatusCategory::Done,
2063 StatusCategory::Cancelled,
2064 StatusCategory::Unknown,
2065];
2066
2067/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
2068#[must_use]
2069pub const fn category_position(category: StatusCategory) -> usize {
2070 match category {
2071 StatusCategory::Draft => 0,
2072 StatusCategory::Backlog => 1,
2073 StatusCategory::Todo => 2,
2074 StatusCategory::Queued => 3,
2075 StatusCategory::InProgress => 4,
2076 StatusCategory::Done => 5,
2077 StatusCategory::Cancelled => 6,
2078 StatusCategory::Unknown => 7,
2079 }
2080}
2081
2082/// The spelling a status category is configured and reported under.
2083fn category_name(category: StatusCategory) -> &'static str {
2084 match category {
2085 StatusCategory::Draft => "draft",
2086 StatusCategory::Backlog => "backlog",
2087 StatusCategory::Todo => "todo",
2088 StatusCategory::Queued => "queued",
2089 StatusCategory::InProgress => "in-progress",
2090 StatusCategory::Done => "done",
2091 StatusCategory::Cancelled => "cancelled",
2092 StatusCategory::Unknown => "unknown",
2093 }
2094}
2095
2096/// A shipped default's option name.
2097///
2098/// The literals below are this file's own and non-blank, and they are validated by the
2099/// one constructor a configured name goes through rather than beside it.
2100fn shipped_column(name: &'static str) -> ColumnName {
2101 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
2102}
2103
2104/// The shipped default for one category, before this instance's configuration.
2105fn shipped_default(category: StatusCategory) -> StatusTarget {
2106 match category {
2107 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
2108 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
2109 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
2110 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
2111 StatusCategory::Done => {
2112 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
2113 }
2114 StatusCategory::Cancelled => {
2115 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
2116 }
2117 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
2118 }
2119}
2120
2121/// This instance's complete category-to-target mapping, read in both directions.
2122///
2123/// One target per category, held at that category's own [`category_position`], so a
2124/// category missing from the mapping, named twice in it, or filed out of order is a
2125/// state this type cannot hold rather than one [`Self::target`] has to defend against.
2126#[derive(Debug, Clone)]
2127struct StatusMapping {
2128 targets: [StatusTarget; CATEGORIES.len()],
2129}
2130
2131impl StatusMapping {
2132 fn resolve(
2133 configured: BTreeMap<String, Option<StatusTargetConfig>>,
2134 instance: &SourceName,
2135 ) -> Result<Self, SourceError> {
2136 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
2137 for (key, value) in configured {
2138 let category = CATEGORIES
2139 .iter()
2140 .find(|category| category_name(**category) == key)
2141 .ok_or_else(|| SourceError::Config {
2142 message: format!(
2143 "status_mapping names {key:?}, which is not a status category of source \
2144 {instance}; the categories are {}",
2145 CATEGORIES
2146 .iter()
2147 .map(|category| category_name(*category))
2148 .collect::<Vec<_>>()
2149 .join(", ")
2150 ),
2151 })?;
2152 overrides.insert(category_name(*category), value);
2153 }
2154 // `CATEGORIES[position] == category` for every category — the crate's suite
2155 // asserts it — so mapping the list in order fills each category's own slot.
2156 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
2157 None => shipped_default(category),
2158 Some(None) => StatusTarget::Disabled,
2159 Some(Some(StatusTargetConfig::Column(option))) => match category {
2160 StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
2161 StatusCategory::Cancelled => {
2162 StatusTarget::Terminal(option, ClosedState::NotPlanned)
2163 }
2164 _ => StatusTarget::Column(option),
2165 },
2166 });
2167 let mapping = Self { targets };
2168 for (index, category) in CATEGORIES.into_iter().enumerate() {
2169 let option = match mapping.target(category) {
2170 StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
2171 StatusTarget::Disabled => continue,
2172 };
2173 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
2174 matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2175 if name.as_str().eq_ignore_ascii_case(option.as_str()))
2176 }) {
2177 return Err(SourceError::Config {
2178 message: format!(
2179 "status_mapping of source {instance} sends both {} and {} to the board \
2180 option {:?}; one option cannot read back as two categories",
2181 category_name(*other),
2182 category_name(category),
2183 option.as_str()
2184 ),
2185 });
2186 }
2187 }
2188 Ok(mapping)
2189 }
2190
2191 fn target(&self, category: StatusCategory) -> &StatusTarget {
2192 &self.targets[category_position(category)]
2193 }
2194
2195 /// The category a board option name reports, or `None` when nothing maps to it.
2196 fn category_of(&self, option: &str) -> Option<StatusCategory> {
2197 CATEGORIES.into_iter().find(|category| {
2198 matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
2199 if name.as_str().eq_ignore_ascii_case(option))
2200 })
2201 }
2202
2203 /// The status an item reports, from the three things a read of it says: its board
2204 /// `Status` option, whether its issue is closed, and the reason it was closed with.
2205 ///
2206 /// The closed state decides the category and the `Status` option decides the name, so
2207 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2208 /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2209 /// duplicate is not finished work, and calling it done is a lie the next copy would
2210 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2211 /// it is read permissively rather than refused — reads are faithful, and refusals
2212 /// belong on writes.
2213 ///
2214 /// One function of those three rather than of a response, so a narrow status write can
2215 /// answer what a re-read would report by applying it to the state it has just written.
2216 fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2217 if closed {
2218 let category = match reason {
2219 None | Some("COMPLETED") => StatusCategory::Done,
2220 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2221 Some(_) => StatusCategory::Unknown,
2222 };
2223 let fallback = match category {
2224 StatusCategory::Done => "Done",
2225 StatusCategory::Cancelled => "Cancelled",
2226 _ => "Closed",
2227 };
2228 return Status {
2229 category,
2230 name: option.unwrap_or(fallback).to_owned(),
2231 };
2232 }
2233 let name = option.unwrap_or("Open").to_owned();
2234 Status {
2235 category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2236 name,
2237 }
2238 }
2239}
2240
2241// 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.
2242/// One repository this source can create an issue in, as `owner/name`.
2243///
2244/// Every `createIssue` this source sends names one of these: the item's own single
2245/// `repositories` entry, else its parent project issue's repository, else the configured
2246/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2247/// that choice and says what it refuses before `createIssue`.
2248// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2249#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2250struct RepositoryTarget {
2251 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2252 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2253}
2254
2255impl RepositoryTarget {
2256 fn parse(value: &str) -> Result<Self, SourceError> {
2257 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2258 message: format!(
2259 "repository must be spelled owner/name; {value:?} names no repository"
2260 ),
2261 })?;
2262 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2263 return Err(SourceError::Config {
2264 message: format!(
2265 "repository must be spelled owner/name with a GitHub login and one \
2266 repository name; {value:?} is not"
2267 ),
2268 });
2269 }
2270 Ok(Self {
2271 owner: owner.to_owned(),
2272 name: name.to_owned(),
2273 })
2274 }
2275
2276 /// The one host whose repositories this source creates issues in, spelled once: it is
2277 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2278 const HOST: &str = "github.com";
2279
2280 fn origin(&self) -> String {
2281 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2282 }
2283
2284 /// The repository a normalized origin names, or why it is none this source can create
2285 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2286 fn from_origin(origin: &Repository) -> Result<Self, String> {
2287 let not_here = || {
2288 format!(
2289 "{} is not a {}/owner/name repository",
2290 origin.as_str(),
2291 Self::HOST
2292 )
2293 };
2294 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2295 if host != Self::HOST {
2296 return Err(not_here());
2297 }
2298 Self::parse(rest).map_err(|_| not_here())
2299 }
2300
2301 fn slug(&self) -> String {
2302 format!("{}/{}", self.owner, self.name)
2303 }
2304}
2305
2306/// A source which reads GitHub afresh for every operation.
2307pub struct GitHubProjectsSource {
2308 /// This source's configured name, used both to tell a far end naming this source
2309 /// from one naming a system it knows nothing about, and to name the instance a
2310 /// status refusal is about.
2311 name: SourceName,
2312 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2313 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2314 repository: Option<RepositoryTarget>,
2315 endpoint: Url,
2316 token: SecretString,
2317 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2318 statuses: StatusMapping,
2319 /// Where each priority lands on this board, or `None` when this instance holds none.
2320 priorities: Option<PriorityMapping>,
2321 client: Client,
2322 /// Every item this source has created since it was built, in the order it created
2323 /// them.
2324 ///
2325 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2326 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2327 /// a copy resolving a dependency on an item it had just created refused it as not
2328 /// found. A board read is completed from this — an item remembered here and absent from
2329 /// the read is added back, because the board really does hold it and only the read is
2330 /// behind.
2331 ///
2332 /// It is not a cache of a user's work: nothing is remembered that this process did not
2333 /// itself just write, it lives and dies with the process, and it is never consulted for
2334 /// an item this source did not create.
2335 created: Mutex<Vec<Resolved>>,
2336 /// Every item that already existed and that this source has written since it was built,
2337 /// as it wrote it.
2338 ///
2339 /// The other half of [`Self::created`], held on the same terms and for the reason a
2340 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2341 /// filter is an index behind a write this process made moments ago, so a query matching
2342 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2343 /// is remembered that this process did not itself just write.
2344 updated: Mutex<Vec<Resolved>>,
2345 /// How fast this source writes, and how long it waits out a refusal.
2346 pacing: Pacing,
2347 /// When the last content-creating mutation finished, or the moment the furthest-out
2348 /// reserved slot releases the next one, whichever is later — so the one after it can be
2349 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2350 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2351 /// what it is measured from.
2352 last_mutation: Mutex<Option<Instant>>,
2353 /// The board as this process last read it, for the length of one command.
2354 ///
2355 /// A copy of a project used to re-read the whole board, paged, before writing each of
2356 /// its items, which is by far the largest part of a copy's request count and none of
2357 /// its work. Nothing else changes this board while a command runs — this source's own
2358 /// writes are the only writer — so one read answers them all.
2359 ///
2360 /// It is not a store of a user's work and it is not the cache the no-persistence
2361 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2362 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2363 /// an item this command created and then depends on resolves whether or not GitHub's
2364 /// own eventually-consistent read has caught up. A write to an item already on the
2365 /// board updates the entry here too, so what this holds is the last read plus this
2366 /// process's own writes rather than a snapshot taken before them.
2367 board_cache: Mutex<Option<Board>>,
2368 /// Every issue this board's own search reported, for the length of one command.
2369 ///
2370 /// The second half of a board read, and cached for the same reason and on the same
2371 /// terms as the first: it lives and dies with the process, nothing is written down, and
2372 /// a write this process makes updates the entry here exactly as it updates the one in
2373 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2374 /// that lists this board's projects and its tasks pays for one search rather than two.
2375 search_cache: Mutex<Option<Vec<Resolved>>>,
2376 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2377 /// the length of one command.
2378 ///
2379 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2380 /// and dies with the process, nothing is written down, a write this process makes
2381 /// updates the entry here as it updates the other two, and every answer is completed
2382 /// with this process's own writes each time it is given. A command that asks the same
2383 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2384 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2385 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2386 search_next: Mutex<BTreeMap<String, Option<String>>>,
2387 /// Records already resolved in this source instance, reused by writes and
2388 /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2389 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2390 /// The board's own id and field definitions as this process last read them on their
2391 /// own, for the length of one command.
2392 ///
2393 /// What a write needs of the board and its item does not say, read once per command
2394 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2395 /// lives and dies with the process and nothing is written down. It holds no item and so
2396 /// can answer no question about one — see [`Self::board_fields`].
2397 fields_cache: Mutex<Option<BoardFields>>,
2398 /// Each destination repository's node id, resolved once per repository
2399 /// rather than per issue created.
2400 ///
2401 /// A repository's node id does not change, and re-reading it for every issue of a copy
2402 /// spent one request per item on an answer this source already had. It is a map rather
2403 /// than one entry because a copy files each item in the repository its own
2404 /// `repositories` field names, so a plan across five repositories asks GitHub five
2405 /// times and not once per item.
2406 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2407 /// What every request this source sends is recorded into.
2408 ///
2409 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2410 /// a request leaves this crate, so nothing has to be switched on for a session to be
2411 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2412 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2413 /// source's reads and writes — adds up one accounting instead of two. See
2414 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2415 ledger: Arc<Accounting>,
2416}
2417
2418/// GitHub's closed single-select color vocabulary.
2419#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2420#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2421pub enum StatusOptionColor {
2422 /// Gray.
2423 Gray,
2424 /// Blue.
2425 Blue,
2426 /// Green.
2427 Green,
2428 /// Yellow.
2429 Yellow,
2430 /// Purple.
2431 Purple,
2432 /// Red.
2433 Red,
2434 /// Orange.
2435 Orange,
2436 /// Pink.
2437 Pink,
2438}
2439
2440/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2441/// applies its additions.
2442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2443pub enum SetupMode {
2444 /// Read without mutation.
2445 Plan,
2446 /// Apply and verify.
2447 Apply,
2448}
2449
2450/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2451/// against it goes on compiling.
2452pub type StatusOptionsMode = SetupMode;
2453
2454/// The explicit result of the requested operation.
2455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2456#[serde(rename_all = "kebab-case")]
2457pub enum StatusOptionsOutcome {
2458 /// A read-only plan.
2459 Planned,
2460 /// Apply found nothing missing.
2461 Unchanged,
2462 /// Additions were applied and verified.
2463 Applied,
2464}
2465
2466/// A GitHub single-select option's opaque GraphQL node identifier.
2467#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2468#[serde(transparent)]
2469pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2470
2471impl TryFrom<String> for StatusOptionId {
2472 type Error = String;
2473
2474 fn try_from(id: String) -> Result<Self, Self::Error> {
2475 if id.trim().is_empty() {
2476 return Err("a GitHub Status option id cannot be blank".to_owned());
2477 }
2478 Ok(Self(id))
2479 }
2480}
2481
2482/// One existing or proposed option in a guarded Status-field update.
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2484pub struct StatusOption {
2485 /// GitHub's stable id.
2486 pub id: StatusOptionId,
2487 /// The visible option name.
2488 pub name: ColumnName,
2489 /// GitHub's single-select color token.
2490 pub color: StatusOptionColor,
2491 /// The option description, including an empty one.
2492 pub description: String,
2493}
2494
2495/// One board item's Status assignment, retained as recovery data.
2496#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2497pub struct StatusAssignment {
2498 /// The project item id whose assignment this is.
2499 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2500 // carried verbatim as operator recovery data; introducing a semantic type would claim
2501 // validation rules GitHub does not publish and no operation here interprets.
2502 pub item_id: String,
2503 /// The selected option, absent when the item has no status.
2504 #[serde(skip_serializing_if = "Option::is_none")]
2505 pub option: Option<AssignedStatusOption>,
2506}
2507
2508/// The inseparable id and name of an assigned option.
2509#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2510pub struct AssignedStatusOption {
2511 /// GitHub's stable id.
2512 pub id: StatusOptionId,
2513 /// The visible name.
2514 pub name: ColumnName,
2515}
2516
2517/// The plan and verified outcome of reconciling configured Status options.
2518#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2519pub struct StatusOptionsReport {
2520 /// The configured source name.
2521 pub source: SourceName,
2522 /// Configured option names absent before the operation.
2523 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2524 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2525 // serialized string here preserves the report's intentionally simple public contract.
2526 pub missing: Vec<String>,
2527 /// What the requested operation did.
2528 pub outcome: StatusOptionsOutcome,
2529 /// The complete option list observed before any mutation.
2530 pub existing: Vec<StatusOption>,
2531}
2532
2533#[derive(Debug, Clone, PartialEq, Eq)]
2534struct StatusSnapshot {
2535 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2536 // passed back as the mutation's project identity; a newtype could enforce no stronger
2537 // invariant because GitHub publishes no grammar for it.
2538 board_id: String,
2539 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2540 // passed back as the mutation's field identity; a newtype could enforce no stronger
2541 // invariant because GitHub publishes no grammar for it.
2542 field_id: String,
2543 options: Vec<StatusOption>,
2544 assignments: Vec<StatusAssignment>,
2545}
2546
2547/// The name of the board field a status is held in.
2548const STATUS_FIELD: &str = "Status";
2549
2550/// Every item's value of each field `report` names, as it stood before the setup wrote
2551/// anything — what a person puts back when the setup is refused part way.
2552fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2553 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2554 .fields
2555 .iter()
2556 .map(|field| (field.field.name(), before.assignments(field.field)))
2557 .collect();
2558 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2559 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2560 })
2561}
2562
2563/// One board field the guarded setup reads and writes — every one it reads, and the only
2564/// ones it writes.
2565#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2566pub enum BoardField {
2567 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2568 Status,
2569 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2570 Priority,
2571}
2572
2573impl BoardField {
2574 /// The field's name on the board.
2575 #[must_use]
2576 pub const fn name(self) -> &'static str {
2577 match self {
2578 Self::Status => STATUS_FIELD,
2579 Self::Priority => PRIORITY_FIELD,
2580 }
2581 }
2582
2583 /// The field a board calls `name`, or `None` for one this setup does not own.
2584 fn named(name: &str) -> Option<Self> {
2585 [Self::Status, Self::Priority]
2586 .into_iter()
2587 .find(|field| field.name() == name)
2588 }
2589}
2590
2591/// What the guarded setup did to one field.
2592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2593#[serde(rename_all = "kebab-case")]
2594pub enum FieldOutcome {
2595 /// A read-only plan.
2596 Planned,
2597 /// Apply found the field there with every configured option.
2598 Unchanged,
2599 /// Missing options were added to the field that was there, and verified.
2600 Applied,
2601 /// The field was not there; it was created holding the configured options, and verified.
2602 Created,
2603}
2604
2605/// One field's plan, or its verified outcome.
2606#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2607pub struct FieldReport {
2608 /// Which field.
2609 pub field: BoardField,
2610 /// Whether the board had the field before the operation.
2611 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2612 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2613 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2614 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2615 // one constructor, and it derives `outcome` from `exists` in one match.
2616 pub exists: bool,
2617 /// Configured option names the field lacked before the operation — every one of them,
2618 /// in the order a new field lists them, when the field was not there at all.
2619 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2620 // mapping name and has therefore already passed its nonblank validation; the serialized
2621 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2622 pub missing: Vec<String>,
2623 /// What the requested operation did.
2624 pub outcome: FieldOutcome,
2625 /// The field's complete option list observed before any mutation; empty when the field
2626 /// was not there.
2627 pub existing: Vec<StatusOption>,
2628}
2629
2630/// The plan and verified outcome of setting up every field a source's configuration names.
2631#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2632pub struct FieldsReport {
2633 /// The configured source name.
2634 pub source: SourceName,
2635 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2636 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2637 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2638 // per field would change a published JSON shape. The states the list could hold and the
2639 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2640 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2641 pub fields: Vec<FieldReport>,
2642}
2643
2644/// Which options one field is configured with, in the order a new field would list them.
2645struct FieldPlan {
2646 field: BoardField,
2647 wanted: Vec<String>,
2648}
2649
2650/// One single-select field as the guarded setup snapshots it.
2651#[derive(Debug, Clone, PartialEq, Eq)]
2652struct SnapshotField {
2653 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2654 // passed back as the mutation's field identity; a newtype could enforce no stronger
2655 // invariant because GitHub publishes no grammar for it.
2656 field_id: String,
2657 options: Vec<StatusOption>,
2658}
2659
2660/// Every single-select field of a board and every item's value of each.
2661#[derive(Debug, Clone, PartialEq, Eq)]
2662struct BoardSnapshot {
2663 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2664 // passed back as the mutation's project identity; a newtype could enforce no stronger
2665 // invariant because GitHub publishes no grammar for it.
2666 board_id: String,
2667 fields: BTreeMap<BoardField, SnapshotField>,
2668 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2669 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2670}
2671
2672impl BoardSnapshot {
2673 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2674 /// carries.
2675 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2676 self.items
2677 .iter()
2678 .map(|(item_id, values)| StatusAssignment {
2679 item_id: item_id.clone(),
2680 option: values.get(&field).cloned(),
2681 })
2682 .collect()
2683 }
2684}
2685
2686impl GitHubProjectsSource {
2687 /// Report missing configured Status options and, when `apply` is true, add them with
2688 /// a whole-list mutation that preserves every existing id and verifies the result.
2689 ///
2690 /// # Errors
2691 ///
2692 /// Refuses a board without a single-select `Status` field. A post-write difference in
2693 /// any pre-existing option id or item assignment is refused with the complete pre-write
2694 /// assignment snapshot in the diagnostic for recovery.
2695 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2696 // successful mutation, both drift refusals, source selection, missing Status, casing,
2697 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2698 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2699 // responses from entering the defensive malformed-response branches below.
2700 pub async fn status_options(
2701 &self,
2702 mode: StatusOptionsMode,
2703 ) -> Result<StatusOptionsReport, SourceError> {
2704 let before = self.status_snapshot().await?;
2705 let configured = self
2706 .statuses
2707 .targets
2708 .iter()
2709 // A terminal category's option is as configured as an open one's: a terminal
2710 // write validates it before closing and refuses when the board lacks it.
2711 .filter_map(|target| match target {
2712 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2713 Some(name.as_str().to_owned())
2714 }
2715 StatusTarget::Disabled => None,
2716 });
2717 let missing = configured
2718 .filter(|wanted| {
2719 !before
2720 .options
2721 .iter()
2722 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2723 })
2724 .collect::<Vec<_>>();
2725 let report = StatusOptionsReport {
2726 source: self.name.clone(),
2727 missing: missing.clone(),
2728 outcome: match (mode, missing.is_empty()) {
2729 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2730 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2731 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2732 },
2733 existing: before.options.clone(),
2734 };
2735 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2736 return Ok(report);
2737 }
2738 let mut options = before
2739 .options
2740 .iter()
2741 .map(|option| {
2742 json!({
2743 "id": option.id, "name": option.name, "color": option.color,
2744 "description": option.description,
2745 })
2746 })
2747 .collect::<Vec<_>>();
2748 options.extend(missing.iter().map(|name| {
2749 json!({
2750 "name": name, "color": "GRAY", "description": ""
2751 })
2752 }));
2753 self.graphql(
2754 graphql::STATUS_OPTIONS_UPDATE,
2755 json!({"input": {
2756 "projectId": before.board_id, "fieldId": before.field_id,
2757 "singleSelectOptions": options,
2758 }}),
2759 )
2760 .await?;
2761 let after = self.status_snapshot().await?;
2762 let options_preserved = before
2763 .options
2764 .iter()
2765 .all(|old| after.options.iter().any(|new| new == old));
2766 let additions_present = missing.iter().all(|wanted| {
2767 after
2768 .options
2769 .iter()
2770 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2771 });
2772 if !options_preserved || !additions_present || after.assignments != before.assignments {
2773 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2774 SourceError::Malformed {
2775 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2776 }
2777 })?;
2778 return Err(SourceError::Refused {
2779 message: format!(
2780 "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}"
2781 ),
2782 });
2783 }
2784 Ok(report)
2785 }
2786
2787 /// A fresh snapshot of the Status field and every board item's assignment of it.
2788 ///
2789 /// # Errors
2790 ///
2791 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2792 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2793 // Status alone, as this operation has always read it: a `Priority` field is another
2794 // operation's, so nothing about it can refuse this one.
2795 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2796 let field = board
2797 .fields
2798 .remove(&BoardField::Status)
2799 .ok_or_else(|| self.no_status_field())?;
2800 Ok(StatusSnapshot {
2801 assignments: board.assignments(BoardField::Status),
2802 board_id: board.board_id,
2803 field_id: field.field_id,
2804 options: field.options,
2805 })
2806 }
2807
2808 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2809 fn no_status_field(&self) -> SourceError {
2810 SourceError::Refused {
2811 message: format!("source {} board has no Status field", self.name),
2812 }
2813 }
2814
2815 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2816 // the real CLI loopback journey, including pagination. The individual malformed guards
2817 // are defensive validation of a schema-pinned third-party response, not separate user
2818 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2819 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2820 /// every board item's value of each, walked to the end of the board's items. A field not
2821 /// in `owned` is read past whatever it holds.
2822 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2823 let mut after: Option<String> = None;
2824 let mut snapshot: Option<BoardSnapshot> = None;
2825 loop {
2826 let data = self
2827 .graphql(
2828 graphql::STATUS_OPTIONS_SNAPSHOT,
2829 json!({
2830 "owner": self.owner, "number": self.project_number,
2831 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2832 }),
2833 )
2834 .await?;
2835 let board = data
2836 .pointer("/owner/projectV2")
2837 .filter(|board| board.is_object())
2838 .ok_or_else(|| SourceError::Refused {
2839 message: format!(
2840 "source {} has no accessible GitHub Projects board",
2841 self.name
2842 ),
2843 })?;
2844 if board
2845 .pointer("/fields/pageInfo/hasNextPage")
2846 .and_then(Value::as_bool)
2847 != Some(false)
2848 {
2849 return Err(SourceError::Malformed {
2850 message:
2851 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2852 .into(),
2853 });
2854 }
2855 let mut fields = BTreeMap::new();
2856 // Only the fields this setup owns, by name: a node the single-select fragment did not
2857 // match carries no name, and a person's own single-select field — a `Size`, a
2858 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2859 // `Status` or `Priority` field without its options is malformed, not absent.
2860 // 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.
2861 for (owned, field) in board
2862 .pointer("/fields/nodes")
2863 .and_then(Value::as_array)
2864 .ok_or_else(|| SourceError::Malformed {
2865 message: "GitHub project fields.nodes is not an array".into(),
2866 })?
2867 .iter()
2868 .filter_map(|field| {
2869 let named = BoardField::named(field.get("name")?.as_str()?)?;
2870 owned.contains(&named).then_some((named, field))
2871 })
2872 {
2873 let options = field
2874 .get("options")
2875 .and_then(Value::as_array)
2876 .ok_or_else(|| SourceError::Malformed {
2877 message: "GitHub single-select field options is not an array".into(),
2878 })?
2879 .iter()
2880 .map(|option| {
2881 Ok(StatusOption {
2882 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2883 .map_err(|message| SourceError::Malformed { message })?,
2884 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2885 .map_err(|message| SourceError::Malformed {
2886 message: format!(
2887 "GitHub single-select option name is invalid: {message}"
2888 ),
2889 })?,
2890 color: serde_json::from_value(
2891 option.get("color").cloned().unwrap_or(Value::Null),
2892 )
2893 .map_err(|error| {
2894 SourceError::Malformed {
2895 message: format!(
2896 "GitHub single-select option color is invalid: {error}"
2897 ),
2898 }
2899 })?,
2900 description: optional_str(option, "description")?
2901 .unwrap_or_default()
2902 .to_owned(),
2903 })
2904 })
2905 .collect::<Result<Vec<_>, SourceError>>()?;
2906 let snapshot = SnapshotField {
2907 field_id: required_nonblank_str(field, "id")?.to_owned(),
2908 options,
2909 };
2910 // A board's field names are unique, so a second one is an answer that cannot
2911 // say which field the setup would act on — refused rather than one chosen.
2912 if fields.insert(owned, snapshot).is_some() {
2913 return Err(SourceError::Malformed {
2914 message: format!(
2915 "GitHub answered two {} fields for this board",
2916 owned.name()
2917 ),
2918 });
2919 }
2920 }
2921 let board_id = required_nonblank_str(board, "id")?.to_owned();
2922 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2923 board_id,
2924 fields,
2925 items: Vec::new(),
2926 });
2927 let items = board
2928 .pointer("/items/nodes")
2929 .and_then(Value::as_array)
2930 .ok_or_else(|| SourceError::Malformed {
2931 message: "GitHub project items.nodes is not an array".into(),
2932 })?;
2933 for item in items {
2934 let field_values =
2935 item.get("fieldValues")
2936 .ok_or_else(|| SourceError::Malformed {
2937 message: "GitHub project item is missing fieldValues".into(),
2938 })?;
2939 if field_values
2940 .pointer("/pageInfo/hasNextPage")
2941 .and_then(Value::as_bool)
2942 != Some(false)
2943 {
2944 return Err(SourceError::Malformed {
2945 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2946 });
2947 }
2948 let values = item
2949 .pointer("/fieldValues/nodes")
2950 .and_then(Value::as_array)
2951 .ok_or_else(|| SourceError::Malformed {
2952 message: "GitHub project item fieldValues.nodes is not an array".into(),
2953 })?;
2954 let item_id = required_nonblank_str(item, "id")?;
2955 let mut assigned = BTreeMap::new();
2956 for value in values {
2957 let Some(field) = value
2958 .pointer("/field/name")
2959 .and_then(Value::as_str)
2960 .and_then(BoardField::named)
2961 .filter(|field| owned.contains(field))
2962 else {
2963 continue;
2964 };
2965 let held = assigned.insert(
2966 field,
2967 AssignedStatusOption {
2968 id: StatusOptionId::try_from(
2969 required_str(value, "optionId")?.to_owned(),
2970 )
2971 .map_err(|message| SourceError::Malformed { message })?,
2972 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2973 .map_err(|message| SourceError::Malformed {
2974 message: format!(
2975 "GitHub assigned {} name is invalid: {message}",
2976 field.name()
2977 ),
2978 })?,
2979 },
2980 );
2981 // An item holds one value of a field, so a second one leaves no way to
2982 // tell which it holds — and a verification or recovery built on either
2983 // could restore the wrong one.
2984 if held.is_some() {
2985 return Err(SourceError::Malformed {
2986 message: format!(
2987 "GitHub answered two {} values for board item {item_id}",
2988 field.name()
2989 ),
2990 });
2991 }
2992 }
2993 current.items.push((item_id.to_owned(), assigned));
2994 }
2995 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2996 message: "GitHub project is missing items".into(),
2997 })?;
2998 let has_next = page
2999 .pointer("/pageInfo/hasNextPage")
3000 .and_then(Value::as_bool)
3001 .ok_or_else(|| SourceError::Malformed {
3002 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
3003 })?;
3004 if !has_next {
3005 break;
3006 }
3007 let next =
3008 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
3009 validate_cursor_progress(after.as_deref(), next)?;
3010 after = Some(next.to_owned());
3011 }
3012 snapshot.ok_or_else(|| SourceError::Malformed {
3013 message: "GitHub returned no board field snapshot".into(),
3014 })
3015 }
3016 // llmlint: ignore-end[changed_behavior_has_e2e]
3017
3018 /// Report every board field this source's configuration names and, with
3019 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
3020 /// the `Priority` field when the board has none.
3021 ///
3022 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
3023 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
3024 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
3025 /// color and description: the whole option list goes back with every existing id, because
3026 /// a re-minted id clears every item's value.
3027 ///
3028 /// # Errors
3029 ///
3030 /// Refuses a board without a single-select `Status` field. After an apply the board is
3031 /// read again, and a pre-existing option or any item's value of either field that moved is
3032 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
3033 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
3034 // unchanged apply, a created field, an added option to each field, drift refusal, a board
3035 // with no Status field and a non-github-projects source through the compiled CLI against
3036 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
3037 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
3038 let owned: Vec<BoardField> = if self.priorities.is_some() {
3039 vec![BoardField::Status, BoardField::Priority]
3040 } else {
3041 vec![BoardField::Status]
3042 };
3043 let before = self.board_snapshot(&owned).await?;
3044 let mut plans = vec![FieldPlan {
3045 field: BoardField::Status,
3046 wanted: self
3047 .statuses
3048 .targets
3049 .iter()
3050 .filter_map(|target| match target {
3051 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
3052 Some(name.as_str().to_owned())
3053 }
3054 StatusTarget::Disabled => None,
3055 })
3056 .collect(),
3057 }];
3058 if !before.fields.contains_key(&BoardField::Status) {
3059 return Err(self.no_status_field());
3060 }
3061 if let Some(mapping) = &self.priorities {
3062 plans.push(FieldPlan {
3063 field: BoardField::Priority,
3064 wanted: mapping.names().map(str::to_owned).collect(),
3065 });
3066 }
3067 // The snapshot reads single-select fields alone, so a field it did not find may still
3068 // be on the board under the name, of another type: creating one beside it would fail
3069 // part way, or leave two fields of one name. Asked of the board's own field list, and
3070 // only when a field is missing.
3071 if plans
3072 .iter()
3073 .any(|plan| !before.fields.contains_key(&plan.field))
3074 {
3075 let board = self.board_fields().await?;
3076 for plan in plans
3077 .iter()
3078 .filter(|plan| !before.fields.contains_key(&plan.field))
3079 {
3080 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
3081 return Err(SourceError::Refused {
3082 message: format!(
3083 "source {}'s board has a {} field that is not a single-select field \
3084 (it is a {}), so it cannot hold this source's options; next: rename \
3085 or remove that field, then run this again",
3086 self.name,
3087 plan.field.name(),
3088 optional_str(field, "__typename")?.unwrap_or("field of another type")
3089 ),
3090 });
3091 }
3092 }
3093 }
3094 let mut reports = Vec::new();
3095 for plan in &plans {
3096 let held = before.fields.get(&plan.field);
3097 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
3098 let mut missing: Vec<String> = Vec::new();
3099 for wanted in &plan.wanted {
3100 let present = existing
3101 .iter()
3102 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3103 || missing
3104 .iter()
3105 .any(|named| named.eq_ignore_ascii_case(wanted));
3106 if !present {
3107 missing.push(wanted.clone());
3108 }
3109 }
3110 reports.push(FieldReport {
3111 field: plan.field,
3112 exists: held.is_some(),
3113 outcome: match (mode, held.is_some(), missing.is_empty()) {
3114 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
3115 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
3116 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
3117 (SetupMode::Apply, false, _) => FieldOutcome::Created,
3118 },
3119 missing,
3120 existing,
3121 });
3122 }
3123 let report = FieldsReport {
3124 source: self.name.clone(),
3125 fields: reports,
3126 };
3127 let writes: Vec<&FieldReport> = report
3128 .fields
3129 .iter()
3130 .filter(|field| !field.missing.is_empty() || !field.exists)
3131 .collect();
3132 if mode == SetupMode::Plan || writes.is_empty() {
3133 return Ok(report);
3134 }
3135 let mut landed: Vec<&str> = Vec::new();
3136 for field in &writes {
3137 let added = field
3138 .missing
3139 .iter()
3140 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
3141 let sent = match before.fields.get(&field.field) {
3142 Some(held) => {
3143 let mut options = held
3144 .options
3145 .iter()
3146 .map(|option| {
3147 json!({
3148 "id": option.id, "name": option.name, "color": option.color,
3149 "description": option.description,
3150 })
3151 })
3152 .collect::<Vec<_>>();
3153 options.extend(added);
3154 self.graphql(
3155 graphql::STATUS_OPTIONS_UPDATE,
3156 json!({"input": {
3157 "projectId": before.board_id, "fieldId": held.field_id,
3158 "singleSelectOptions": options,
3159 }}),
3160 )
3161 .await
3162 }
3163 None => {
3164 self.graphql(
3165 graphql::CREATE_FIELD,
3166 json!({"input": {
3167 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
3168 "name": field.field.name(),
3169 "singleSelectOptions": added.collect::<Vec<_>>(),
3170 }}),
3171 )
3172 .await
3173 }
3174 };
3175 // A mutation that failed does not establish that GitHub left its field as it was,
3176 // so every failure from here on carries the recovery data a drift refusal does.
3177 match sent {
3178 Ok(_) => landed.push(field.field.name()),
3179 Err(error) => {
3180 let changed = if landed.is_empty() {
3181 String::new()
3182 } else {
3183 format!("changed the {} field and then ", landed.join(" and "))
3184 };
3185 return Err(SourceError::Refused {
3186 message: format!(
3187 "the guarded field setup {changed}failed on the {} field, which it may \
3188 have changed part way: {error}; the pre-write item assignments \
3189 are:\n{}",
3190 field.field.name(),
3191 recovery(&report, &before)?
3192 ),
3193 });
3194 }
3195 }
3196 }
3197 // The board has been written, so a verification read that fails leaves it unverified
3198 // rather than unchanged, and says what to put back.
3199 let after = match self.board_snapshot(&owned).await {
3200 Ok(after) => after,
3201 Err(error) => {
3202 return Err(SourceError::Refused {
3203 message: format!(
3204 "the guarded field setup changed the {} field and then could not read the \
3205 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3206 landed.join(" and "),
3207 recovery(&report, &before)?
3208 ),
3209 });
3210 }
3211 };
3212 let mut moved = Vec::new();
3213 for field in &report.fields {
3214 let name = field.field.name();
3215 let now = after
3216 .fields
3217 .get(&field.field)
3218 .map(|held| held.options.as_slice())
3219 .unwrap_or_default();
3220 if !field.existing.iter().all(|old| now.contains(old)) {
3221 moved.push(format!(
3222 "a pre-existing {name} option id, name, color or description"
3223 ));
3224 }
3225 if !field.missing.iter().all(|wanted| {
3226 now.iter()
3227 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3228 }) {
3229 moved.push(format!("an added {name} option"));
3230 }
3231 if after.assignments(field.field) != before.assignments(field.field) {
3232 moved.push(format!("an item's {name} value"));
3233 }
3234 }
3235 if !moved.is_empty() {
3236 return Err(SourceError::Refused {
3237 message: format!(
3238 "GitHub changed {} after the guarded field setup; the pre-write item \
3239 assignments are:\n{}",
3240 moved.join(", "),
3241 recovery(&report, &before)?
3242 ),
3243 });
3244 }
3245 Ok(report)
3246 }
3247
3248 /// Validate configuration and capture the named credential without exposing it.
3249 ///
3250 /// # Errors
3251 ///
3252 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3253 /// [`SourceError::Auth`] when the named credential is missing or empty.
3254 pub fn new(
3255 name: &SourceName,
3256 config: GitHubProjectsConfig,
3257 secrets: &dyn SecretResolver,
3258 ) -> Result<Self, SourceError> {
3259 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3260 }
3261
3262 /// The same, recording every request it sends into an accounting the caller holds too.
3263 ///
3264 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3265 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3266 /// up — passes the one it records those into, so the session total accounts for the
3267 /// whole session rather than for this source's share of it.
3268 ///
3269 /// # Errors
3270 ///
3271 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3272 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3273 pub fn recording_into(
3274 name: &SourceName,
3275 config: GitHubProjectsConfig,
3276 secrets: &dyn SecretResolver,
3277 ledger: Arc<Accounting>,
3278 ) -> Result<Self, SourceError> {
3279 if !valid_github_owner(&config.owner) {
3280 return Err(SourceError::Config {
3281 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3282 });
3283 }
3284 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3285 return Err(SourceError::Config {
3286 message: format!("project_number must be between 1 and {}", i32::MAX),
3287 });
3288 }
3289 if !valid_environment_name(&config.token_env) {
3290 return Err(SourceError::Config {
3291 message: "token_env must be a valid environment-variable name".into(),
3292 });
3293 }
3294 let repository = config
3295 .repository
3296 .as_deref()
3297 .map(RepositoryTarget::parse)
3298 .transpose()?;
3299 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3300 message: format!("endpoint is not a valid URL: {e}"),
3301 })?;
3302 if endpoint.scheme() != "https"
3303 && !(endpoint.scheme() == "http"
3304 && endpoint
3305 .host_str()
3306 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3307 {
3308 return Err(SourceError::Config {
3309 message:
3310 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3311 .into(),
3312 });
3313 }
3314 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3315 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),
3316 })?;
3317 Ok(Self {
3318 name: name.clone(),
3319 owner: config.owner,
3320 project_number: config.project_number,
3321 repository,
3322 endpoint,
3323 token,
3324 credential_name: config.token_env,
3325 statuses: StatusMapping::resolve(config.status_mapping, name)?,
3326 priorities: config
3327 .priority_mapping
3328 .map(|mapping| PriorityMapping::resolve(mapping, name))
3329 .transpose()?,
3330 client: Client::builder()
3331 .user_agent("onetaskgraph")
3332 .build()
3333 .map_err(|e| SourceError::Config {
3334 message: format!("cannot build HTTP client: {e}"),
3335 })?,
3336 created: Mutex::new(Vec::new()),
3337 updated: Mutex::new(Vec::new()),
3338 pacing: Pacing::resolve(config.pacing, name)?,
3339 last_mutation: Mutex::new(None),
3340 board_cache: Mutex::new(None),
3341 search_cache: Mutex::new(None),
3342 narrowed_cache: Mutex::new(BTreeMap::new()),
3343 resolved_cache: Mutex::new(BTreeMap::new()),
3344 search_next: Mutex::new(BTreeMap::new()),
3345 fields_cache: Mutex::new(None),
3346 repository_cache: Mutex::new(BTreeMap::new()),
3347 ledger,
3348 })
3349 }
3350
3351 /// A snapshot of every request this source has sent, and what each cost.
3352 ///
3353 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3354 /// of them can sit side by side. When this source was built with
3355 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3356 /// point of building it that way.
3357 #[must_use]
3358 pub fn accounting(&self) -> accounting::Session {
3359 self.ledger.snapshot()
3360 }
3361
3362 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3363 /// rate limit rather than handing it straight back as an error.
3364 ///
3365 /// Retrying is safe for every document here, including the mutations, and the reason
3366 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3367 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3368 /// this replays has already taken effect. An outcome this source cannot know — the
3369 /// send failed, or the body could not be read, so the mutation may well have landed —
3370 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3371 /// attempt. A duplicate write would come from replaying one of those, and none is
3372 /// replayed.
3373 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3374 if is_mutation(query)
3375 && ![
3376 graphql::ADD_COMMENT,
3377 graphql::UPDATE_COMMENT,
3378 graphql::DELETE_COMMENT,
3379 ]
3380 .contains(&query)
3381 {
3382 let mut cache = self.resolved_cache()?;
3383 for argument in ["input", "second", "third", "clear"] {
3384 if let Some(input) = variables.get(argument) {
3385 cache.retain(|id, item| {
3386 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3387 input
3388 .get(key)
3389 .and_then(Value::as_str)
3390 .is_some_and(|value| value == id.0 || value == item.item_id)
3391 })
3392 });
3393 }
3394 }
3395 }
3396 let doing = operation_description(query);
3397 let mut waited = Duration::ZERO;
3398 let mut waits = 0_u32;
3399 let mut backoff = self.pacing.retry_backoff;
3400 loop {
3401 if is_mutation(query) {
3402 let spacing = self.reserve_mutation_slot();
3403 if !spacing.is_zero() {
3404 tokio::time::sleep(spacing).await;
3405 }
3406 }
3407 let attempt = self.send_once(query, &variables).await;
3408 if is_mutation(query) {
3409 self.finish_mutation();
3410 }
3411 let limited = match attempt {
3412 Ok(data) => return Ok(data),
3413 Err(Attempt::Failed(error)) => return Err(error),
3414 Err(Attempt::Limited(limited)) => limited,
3415 };
3416 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3417 // move that extends a secondary limit, so a hint below the schedule's own next
3418 // wait is raised to it.
3419 let wait = match limited.hint {
3420 Some(hint) => Duration::from_secs(hint).max(backoff),
3421 None => backoff,
3422 };
3423 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3424 // A wait of nothing spends none of the budget, so it is exhaustion rather
3425 // than a retry. `Pacing::resolve` rules out every way of configuring one
3426 // except a budget of zero, where reporting the first refusal is the ask.
3427 if wait.is_zero() || wait > remaining {
3428 return Err(limited.exhausted(
3429 doing,
3430 waits,
3431 waited,
3432 wait,
3433 self.pacing.retry_budget,
3434 ));
3435 }
3436 tokio::time::sleep(wait).await;
3437 waited += wait;
3438 waits += 1;
3439 backoff = backoff.saturating_mul(2);
3440 }
3441 }
3442
3443 /// The next moment a content-creating mutation may leave this source, as a wait from
3444 /// now.
3445 ///
3446 /// The slot is reserved under the lock and the waiting happens outside it, so two
3447 /// callers take two slots rather than the same one — and no lock is held across an
3448 /// await.
3449 ///
3450 /// The moment it is spaced from is the previous mutation's *completion*, which
3451 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3452 /// own is the wrong thing to measure from.
3453 fn reserve_mutation_slot(&self) -> Duration {
3454 if self.pacing.min_mutation_interval.is_zero() {
3455 return Duration::ZERO;
3456 }
3457 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3458 // it would turn an earlier failure into a second one for no gain.
3459 let mut last = self
3460 .last_mutation
3461 .lock()
3462 .unwrap_or_else(std::sync::PoisonError::into_inner);
3463 let now = Instant::now();
3464 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3465 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3466 let at = last.map_or(now, |previous| {
3467 previous
3468 .checked_add(self.pacing.min_mutation_interval)
3469 .map_or(now, |earliest| earliest.max(now))
3470 });
3471 *last = Some(at);
3472 at.saturating_duration_since(now)
3473 }
3474
3475 /// Record that a content-creating mutation has finished, so the next one is spaced
3476 /// from here rather than from the moment this one was released.
3477 ///
3478 /// This source can only choose when a request *departs*; the limiter counts when it
3479 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3480 /// departure from the last therefore hands the limiter a gap of the interval less that
3481 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3482 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3483 /// slower machine while passing on a quick one.
3484 ///
3485 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3486 /// previous request had already arrived before its response came back, so its arrival
3487 /// is no later than this moment, and the next mutation is released at least the
3488 /// interval after this moment and arrives no earlier than it is released: the gap the
3489 /// limiter measures is therefore at least the interval, whatever transit costs and on
3490 /// whatever platform. The price is that a mutation's own round trip no longer counts
3491 /// towards its spacing, which makes this source slightly slower than the configured
3492 /// rate rather than slightly faster — the safe side of a limit that punishes being
3493 /// wrong by refusing reads for the next fifty minutes.
3494 ///
3495 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3496 /// and one that never left costs only a wait nobody needed.
3497 fn finish_mutation(&self) {
3498 if self.pacing.min_mutation_interval.is_zero() {
3499 return;
3500 }
3501 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3502 let mut last = self
3503 .last_mutation
3504 .lock()
3505 .unwrap_or_else(std::sync::PoisonError::into_inner);
3506 let now = Instant::now();
3507 // `max` rather than an assignment: a concurrent caller may already have reserved a
3508 // slot further out, and completing this request must never pull that slot back in.
3509 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3510 }
3511
3512 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3513 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3514 ///
3515 /// This is the one place a request leaves this crate, which is why the accounting is
3516 /// here rather than at each of the callers: a read path added later is counted without
3517 /// anybody remembering to count it, and
3518 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3519 /// when one is not.
3520 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3521 let Attempted {
3522 result,
3523 limits,
3524 reported_cost,
3525 } = self.attempt(query, variables).await;
3526 // No `otherwise` name: every document this source sends is one of its own, and the
3527 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3528 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3529 let outcome = match &result {
3530 Ok(_) => accounting::Outcome::Answered,
3531 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3532 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3533 };
3534 self.ledger.record(sending.finished(outcome, limits));
3535 result
3536 }
3537
3538 /// The attempt itself, with what its response said about the rate limit alongside.
3539 ///
3540 /// The two are returned together rather than recorded here because every one of the
3541 /// early exits below is a different outcome, and a record written at each of them is a
3542 /// record one of them can be added without.
3543 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3544 let mut limits = accounting::RateLimit::default();
3545 let mut reported_cost = None;
3546 let result = self
3547 .attempted(query, variables, &mut limits, &mut reported_cost)
3548 .await;
3549 Attempted {
3550 result,
3551 limits,
3552 reported_cost,
3553 }
3554 }
3555
3556 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3557 async fn attempted(
3558 &self,
3559 query: &str,
3560 variables: &Value,
3561 limits: &mut accounting::RateLimit,
3562 reported_cost: &mut Option<u64>,
3563 ) -> Result<Value, Attempt> {
3564 let response = self
3565 .client
3566 .post(self.endpoint.clone())
3567 .bearer_auth(self.token.expose_secret())
3568 .json(&json!({"query": query, "variables": variables}))
3569 .send()
3570 .await
3571 .map_err(|e| {
3572 Attempt::Failed(SourceError::Unavailable {
3573 message: format!("GitHub GraphQL request failed: {e}"),
3574 })
3575 })?;
3576 let status = response.status();
3577 let header = |name: &str| whole_seconds(response.headers().get(name));
3578 *limits = accounting::RateLimit::read(|name| {
3579 response
3580 .headers()
3581 .get(name)
3582 .and_then(|value| value.to_str().ok())
3583 .map(str::to_owned)
3584 });
3585 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3586 // that are not text at all — is "not known to be exhausted". This never makes a
3587 // response a refusal on its own: it says which limiter a refusal is attributed to
3588 // and where its hint comes from, so a value this cannot read costs a hint rather
3589 // than an answer.
3590 let exhausted = response
3591 .headers()
3592 .get("x-ratelimit-remaining")
3593 .and_then(|value| value.to_str().ok())
3594 == Some("0");
3595 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3596 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3597 // which is the same question answered as an absolute time. Nothing else here is a
3598 // hint, and a schedule is what answers a refusal that carries none.
3599 let hint = header("retry-after").or_else(|| {
3600 exhausted
3601 .then(|| header("x-ratelimit-reset"))
3602 .flatten()
3603 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3604 });
3605 // Read before it is parsed, because the evidence which tells a secondary rate
3606 // limit from a rejected credential is in the body of a response whose status says
3607 // only "forbidden" — and a non-success response was never parsed at all.
3608 let body = response.text().await.map_err(|e| {
3609 Attempt::Failed(SourceError::Unavailable {
3610 message: format!("GitHub GraphQL response could not be read: {e}"),
3611 })
3612 })?;
3613 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3614 return Err(Attempt::Limited(Limited { limiter, hint }));
3615 }
3616 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3617 return Err(Attempt::Failed(SourceError::Auth {
3618 message: format!(
3619 "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"
3620 ),
3621 }));
3622 }
3623 if !status.is_success() {
3624 return Err(Attempt::Failed(SourceError::Unavailable {
3625 message: format!("GitHub GraphQL returned HTTP {status}"),
3626 }));
3627 }
3628 // GitHub reports what a call cost only when the document asked it to, and no
3629 // document this source sends does — so this is `None` here and carries the figure
3630 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3631 // pick up is a `dryRun` probe's cost, which is some other document's.
3632 *reported_cost = serde_json::from_str::<Value>(&body)
3633 .ok()
3634 .as_ref()
3635 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3636 .and_then(Value::as_u64);
3637 self.answer(&body).map_err(Attempt::Failed)
3638 }
3639
3640 /// What one successful HTTP response says, once its GraphQL errors are read.
3641 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3642 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3643 message: format!("GitHub returned invalid JSON: {e}"),
3644 })?;
3645 let errors = body
3646 .get("errors")
3647 .map(|value| {
3648 value.as_array().ok_or_else(|| SourceError::Malformed {
3649 message: "GitHub response errors is not an array".into(),
3650 })
3651 })
3652 .transpose()?;
3653 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3654 let messages = errors
3655 .iter()
3656 .filter_map(|e| e.get("message").and_then(Value::as_str))
3657 .collect::<Vec<_>>()
3658 .join("; ");
3659 let message = if messages.is_empty() {
3660 "GitHub returned GraphQL errors".into()
3661 } else {
3662 messages
3663 };
3664 let normalized = message.to_ascii_lowercase();
3665 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3666 return Err(SourceError::Auth {
3667 message: format!(
3668 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3669 self.credential_name
3670 ),
3671 });
3672 }
3673 return Err(SourceError::Refused { message });
3674 }
3675 body.get("data")
3676 .filter(|data| data.is_object())
3677 .cloned()
3678 .ok_or_else(|| SourceError::Malformed {
3679 message: "GitHub response has no data object".into(),
3680 })
3681 }
3682
3683 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3684 // GraphQL cannot independently page them inside the outer item page. This source page is
3685 // deliberately bounded at that published maximum; the live drift journey exercises it.
3686 async fn board_page(
3687 &self,
3688 items_after: Option<&str>,
3689 items_first: u32,
3690 ) -> Result<Value, SourceError> {
3691 let data = self
3692 .graphql(
3693 graphql::BOARD,
3694 json!({"owner":self.owner,"number":self.project_number,
3695 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3696 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3697 )
3698 .await?;
3699 data.pointer("/owner/projectV2")
3700 .filter(|v| !v.is_null())
3701 .cloned()
3702 .ok_or_else(|| SourceError::Refused {
3703 message: format!(
3704 "GitHub project {}/{} was not found or is not visible to the token",
3705 self.owner, self.project_number
3706 ),
3707 })
3708 }
3709
3710 /// The search that finds the issues of this board, narrowed by `also` when it is
3711 /// given.
3712 ///
3713 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3714 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3715 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3716 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3717 /// from a task by the `parent` field each issue carries rather than by the search.
3718 fn board_search(&self, also: Option<&str>) -> String {
3719 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3720 match also {
3721 Some(also) => format!("{scope} {also}"),
3722 None => scope,
3723 }
3724 }
3725
3726 /// One issue this source reached directly, as the board item a read of the board would
3727 /// have produced — or `None` when this board does not hold it.
3728 ///
3729 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3730 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3731 /// item's own id, that item's field values, and the issue as its content. One resolver
3732 /// for both routes is what makes an issue read through a search, through its own node
3733 /// id, or through its project's sub-issues report the same title, the same status, the
3734 /// same labels and the same qualified id.
3735 ///
3736 /// An issue with no entry for *this* board is not this source's to report, which is
3737 /// what keeps an id naming some other repository's issue from being answered as an item
3738 /// of this board. That answer is given about an **exhausted** connection and never
3739 /// about an unread page: the entry is looked for on the page in hand, and only if that
3740 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3741 /// rest of it.
3742 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3743 if optional_str(issue, "__typename")? != Some("Issue") {
3744 return Ok(None);
3745 }
3746 let memberships = issue
3747 .get("projectItems")
3748 .ok_or_else(|| SourceError::Malformed {
3749 message: "GitHub issue is missing projectItems".into(),
3750 })?;
3751 let nodes = memberships
3752 .get("nodes")
3753 .and_then(Value::as_array)
3754 .ok_or_else(|| SourceError::Malformed {
3755 message: "GitHub issue projectItems.nodes is not an array".into(),
3756 })?;
3757 let held = match self.board_entry(nodes) {
3758 Some(held) => held.clone(),
3759 None => {
3760 let info = memberships
3761 .get("pageInfo")
3762 .ok_or_else(|| SourceError::Malformed {
3763 message: "GitHub issue projectItems has no pageInfo".into(),
3764 })?;
3765 // The page held no entry for this board. Whether that means the issue is
3766 // not on it is a question about the rest of the connection, and only a
3767 // connection with no rest answers it here.
3768 if !required_bool(info, "hasNextPage")? {
3769 return Ok(None);
3770 }
3771 let cursor = required_str(info, "endCursor")?;
3772 validate_cursor_progress(None, cursor)?;
3773 let issue_id = required_str(issue, "id")?;
3774 match self.board_membership(issue_id, cursor).await? {
3775 Some(held) => held,
3776 None => return Ok(None),
3777 }
3778 }
3779 };
3780 let item = json!({
3781 "id": required_str(&held, "id")?,
3782 "project": held.get("project"),
3783 "fieldValues": held.get("fieldValues"),
3784 "content": issue,
3785 });
3786 self.resolve(&item)
3787 }
3788
3789 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3790 ///
3791 /// One spelling of *which membership is this board's*, so the page a read carries and
3792 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3793 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3794 nodes.iter().find(|node| {
3795 node.pointer("/project/number").and_then(Value::as_u64)
3796 == Some(u64::from(self.project_number))
3797 })
3798 }
3799
3800 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3801 ///
3802 /// The recovery read: a page of memberships that holds no entry for this board says
3803 /// nothing about the memberships past it, so the connection is walked to exhaustion
3804 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3805 /// positive answer — the whole connection was read and no entry named this board —
3806 /// rather than a failure, and the walk is held to
3807 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3808 /// with a cursor that does not advance is refused instead of spun on.
3809 async fn board_membership(
3810 &self,
3811 issue: &str,
3812 after: &str,
3813 ) -> Result<Option<Value>, SourceError> {
3814 let mut after = after.to_owned();
3815 loop {
3816 let data = self
3817 .graphql(
3818 graphql::ISSUE_BOARD_ITEMS,
3819 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3820 "nestedFirst":NESTED_PAGE_SIZE}),
3821 )
3822 .await?;
3823 let Some(connection) = data
3824 .pointer("/node/projectItems")
3825 .filter(|value| !value.is_null())
3826 else {
3827 // The id resolved to nothing, or to something with no memberships to walk —
3828 // which is the same answer as a connection holding no entry for this board.
3829 return Ok(None);
3830 };
3831 let nodes = connection
3832 .get("nodes")
3833 .and_then(Value::as_array)
3834 .ok_or_else(|| SourceError::Malformed {
3835 message: "GitHub issue projectItems.nodes is not an array".into(),
3836 })?;
3837 if let Some(held) = self.board_entry(nodes) {
3838 return Ok(Some(held.clone()));
3839 }
3840 let info = connection
3841 .get("pageInfo")
3842 .ok_or_else(|| SourceError::Malformed {
3843 message: "GitHub issue projectItems has no pageInfo".into(),
3844 })?;
3845 let next = required_bool(info, "hasNextPage")?
3846 .then(|| required_str(info, "endCursor"))
3847 .transpose()?;
3848 match next {
3849 Some(next) => {
3850 validate_cursor_progress(Some(&after), next)?;
3851 after = next.to_owned();
3852 }
3853 None => return Ok(None),
3854 }
3855 }
3856 }
3857
3858 /// One page of a board-scoped issue search, and where the next page resumes.
3859 async fn search_page(
3860 &self,
3861 search: &str,
3862 first: u32,
3863 after: Option<&str>,
3864 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3865 let data = self
3866 .graphql(
3867 graphql::SEARCH_ISSUES,
3868 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3869 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3870 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3871 )
3872 .await?;
3873 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3874 message: "GitHub search response has no search connection".into(),
3875 })?;
3876 let mut found = Vec::new();
3877 for node in connection
3878 .get("nodes")
3879 .and_then(Value::as_array)
3880 .ok_or_else(|| SourceError::Malformed {
3881 message: "GitHub search nodes is not an array".into(),
3882 })?
3883 {
3884 if let Some(resolved) = self.resolve_issue(node).await? {
3885 found.push(resolved);
3886 }
3887 }
3888 let info = connection
3889 .get("pageInfo")
3890 .ok_or_else(|| SourceError::Malformed {
3891 message: "GitHub search connection has no pageInfo".into(),
3892 })?;
3893 let next = required_bool(info, "hasNextPage")?
3894 .then(|| required_str(info, "endCursor"))
3895 .transpose()?
3896 .map(str::to_owned);
3897 if let Some(next) = &next {
3898 validate_cursor_progress(after, next)?;
3899 }
3900 Ok((found, next))
3901 }
3902
3903 /// Every issue this board holds, completed with what this run wrote.
3904 ///
3905 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3906 /// is an index and is eventually consistent, so an issue this run created seconds ago
3907 /// can be absent from it, and a project listed straight after being written would
3908 /// otherwise be missing from its own board. What is added back is only what this
3909 /// process itself wrote, out of [`Self::created`], which lives and dies with the
3910 /// process.
3911 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3912 let found = self.searched_issues().await?;
3913 self.completed_with_written(found, |_| true)
3914 }
3915
3916 /// Every issue this board's own search reports, walked to exhaustion, read once per
3917 /// source.
3918 ///
3919 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3920 /// needs it too and the two would otherwise walk the same search twice in one command.
3921 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3922 /// is.
3923 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3924 let cached = self.search_cache()?.clone();
3925 if let Some(held) = cached {
3926 return Ok(held);
3927 }
3928 let mut after: Option<String> = None;
3929 let mut found = Vec::new();
3930 let search = self.board_search(None);
3931 loop {
3932 let (page, next) = self
3933 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3934 .await?;
3935 found.extend(page);
3936 match next {
3937 Some(next) => after = Some(next),
3938 None => break,
3939 }
3940 }
3941 *self.search_cache()? = Some(found.clone());
3942 Ok(found)
3943 }
3944
3945 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3946 fn search_cache(
3947 &self,
3948 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3949 self.search_cache
3950 .lock()
3951 .map_err(|_| SourceError::Unavailable {
3952 message: "this source's view of the board's issues was left inconsistent by an \
3953 earlier failure; next: run the command again"
3954 .into(),
3955 })
3956 }
3957
3958 /// `found`, with everything this run wrote that `keep` accepts and the read did not
3959 /// report.
3960 ///
3961 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3962 /// at all: the search index is behind, and a node read of an item filed moments ago can
3963 /// be too.
3964 fn completed_with_written(
3965 &self,
3966 mut found: Vec<Resolved>,
3967 keep: impl Fn(&Resolved) -> bool,
3968 ) -> Result<Vec<Resolved>, SourceError> {
3969 for own in self.created()?.iter().filter(|own| keep(own)) {
3970 if !found.iter().any(|item| item.id == own.id) {
3971 found.push(own.clone());
3972 }
3973 }
3974 Ok(found)
3975 }
3976
3977 /// What resolving one node id reached.
3978 ///
3979 /// Three answers rather than an `Option`, because a board *draft* is none of the other
3980 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3981 /// is completed by a read of the draft itself rather than reported as nothing.
3982 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3983 let asked = self
3984 .graphql(
3985 graphql::ISSUE,
3986 json!({"id":id.0,"first":MAX_PAGE_SIZE,"nestedFirst":NESTED_PAGE_SIZE,
3987 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3988 )
3989 .await;
3990 let data = match asked {
3991 Ok(data) => data,
3992 // A string that is not a node id at all is not a failure to report: it is an id
3993 // this board does not hold, which is what every read of one already answers.
3994 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3995 Err(error) => return Err(error),
3996 };
3997 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3998 return Ok(Reached::Nothing);
3999 };
4000 if optional_str(node, "__typename")? == Some("DraftIssue") {
4001 return Ok(Reached::Draft);
4002 }
4003 Ok(match self.resolve_issue(node).await? {
4004 Some(item) => Reached::Held(Box::new(item)),
4005 None => Reached::Nothing,
4006 })
4007 }
4008
4009 /// One item of this board by its own id, whatever kind it is.
4010 ///
4011 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
4012 /// run wrote is read first, because a node read of an item created moments ago can
4013 /// still be behind the board field values written onto it — see [`Self::created`].
4014 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4015 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
4016 return Ok(Some(own.clone()));
4017 }
4018 match self.reach(id).await? {
4019 Reached::Held(item) => Ok(Some(*item)),
4020 Reached::Nothing => Ok(None),
4021 Reached::Draft => self.draft_by_id(id).await,
4022 }
4023 }
4024
4025 /// Several items of this board, each by its own id, in order — what [`Self::item_by_id`]
4026 /// answers for each, read [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] rather
4027 /// than one request per id.
4028 ///
4029 /// What this run wrote answers first, as it does there, and only the rest is read. One id
4030 /// left to read is read by [`Self::item_by_id`] itself, which costs what a batch does. A
4031 /// batch GitHub refuses because one of its ids resolves to no node at all is read again one
4032 /// id at a time, so that id is answered as not held and the others as themselves; a draft
4033 /// is completed by a read of the draft, exactly as there.
4034 async fn items_by_ids(&self, ids: &[NativeId]) -> Result<Vec<Option<Resolved>>, SourceError> {
4035 let mut found: Vec<Option<Option<Resolved>>> = {
4036 let created = self.created()?;
4037 ids.iter()
4038 .map(|id| {
4039 created
4040 .iter()
4041 .find(|own| own.id == *id)
4042 .map(|own| Some(own.clone()))
4043 })
4044 .collect()
4045 };
4046 let unread: Vec<NativeId> = ids
4047 .iter()
4048 .zip(&found)
4049 .filter(|(_, found)| found.is_none())
4050 .map(|(id, _)| id.clone())
4051 .collect();
4052 let mut read = Vec::with_capacity(unread.len());
4053 if let [one] = unread.as_slice() {
4054 read.push(self.item_by_id(one).await?);
4055 } else {
4056 for batch in unread.chunks(DETAIL_BATCH) {
4057 let data = match self
4058 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, None))
4059 .await
4060 {
4061 Ok(data) => data,
4062 Err(error) if unresolvable_node(&error) => {
4063 for id in batch {
4064 read.push(self.item_by_id(id).await?);
4065 }
4066 continue;
4067 }
4068 Err(error) => return Err(error),
4069 };
4070 for (slot, id) in batch.iter().enumerate() {
4071 let node =
4072 data.get(format!("i{slot}"))
4073 .ok_or_else(|| SourceError::Malformed {
4074 message: format!(
4075 "GitHub answered a batch read with no item for {}",
4076 id.0
4077 ),
4078 })?;
4079 read.push(if node.is_null() {
4080 None
4081 } else if optional_str(node, "__typename")? == Some("DraftIssue") {
4082 self.draft_by_id(id).await?
4083 } else {
4084 if optional_str(node, "__typename")? == Some("Issue")
4085 && required_str(node, "id")? != id.0
4086 {
4087 return Err(SourceError::Malformed {
4088 message: format!(
4089 "GitHub answered the read of {} with issue {}",
4090 id.0,
4091 required_str(node, "id")?
4092 ),
4093 });
4094 }
4095 self.resolve_issue(node).await?
4096 });
4097 }
4098 }
4099 }
4100 let mut read = read.into_iter();
4101 Ok(found
4102 .iter_mut()
4103 .map(|slot| slot.take().unwrap_or_else(|| read.next().flatten()))
4104 .collect())
4105 }
4106
4107 fn resolved_cache(
4108 &self,
4109 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
4110 self.resolved_cache
4111 .lock()
4112 .map_err(|_| SourceError::Unavailable {
4113 message: "resolved item records were left inconsistent; run the command again"
4114 .into(),
4115 })
4116 }
4117
4118 /// Reuse a record this invocation already resolved. The mutation sender invalidates
4119 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
4120 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4121 let cached = self.resolved_cache()?.get(id).cloned();
4122 match cached {
4123 Some(item) => Ok(Some(item)),
4124 None => self.item_by_id(id).await,
4125 }
4126 }
4127
4128 /// One board draft by its own id, with the board item it sits in — or `None` when no
4129 /// item of this board is that draft's.
4130 ///
4131 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
4132 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
4133 /// links a draft to one board item, so the page this read carries is the whole of that
4134 /// connection, and a page that reports more than it holds is refused rather than read
4135 /// as an answer about memberships nobody read.
4136 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
4137 let data = self
4138 .graphql(
4139 graphql::DRAFT,
4140 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
4141 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
4142 )
4143 .await?;
4144 // Gone between the two reads is an answer — the draft is no longer there. Anything
4145 // else than the draft [`Self::reach`] was just told this id is, is not one.
4146 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
4147 return Ok(None);
4148 };
4149 if optional_str(draft, "__typename")? != Some("DraftIssue") {
4150 return Err(SourceError::Malformed {
4151 message: format!(
4152 "GitHub answered {} as a draft and then as something else",
4153 id.0
4154 ),
4155 });
4156 }
4157 if required_str(draft, "id")? != id.0 {
4158 return Err(SourceError::Malformed {
4159 message: format!("GitHub answered a different draft for {}", id.0),
4160 });
4161 }
4162 let memberships = draft
4163 .get("projectV2Items")
4164 .ok_or_else(|| SourceError::Malformed {
4165 message: format!("GitHub draft {} is missing projectV2Items", id.0),
4166 })?;
4167 let nodes = memberships
4168 .get("nodes")
4169 .and_then(Value::as_array)
4170 .ok_or_else(|| SourceError::Malformed {
4171 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
4172 })?;
4173 let info = memberships
4174 .get("pageInfo")
4175 .ok_or_else(|| SourceError::Malformed {
4176 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
4177 })?;
4178 // Read whether or not this board's entry is on the page: a page claiming more than
4179 // the one item GitHub links a draft to is a malformed answer either way.
4180 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
4181 return Err(SourceError::Malformed {
4182 message: format!(
4183 "GitHub draft {} reports more board items than the one GitHub links a draft \
4184 to",
4185 id.0
4186 ),
4187 });
4188 }
4189 if let Some(node) = nodes.first()
4190 && node
4191 .pointer("/project/number")
4192 .and_then(Value::as_u64)
4193 .is_none()
4194 {
4195 return Err(SourceError::Malformed {
4196 message: format!(
4197 "GitHub draft {} board item has no numeric project number",
4198 id.0
4199 ),
4200 });
4201 }
4202 let Some(held) = self.board_entry(nodes) else {
4203 return Ok(None);
4204 };
4205 if required_str(
4206 held.get("project").ok_or_else(|| SourceError::Malformed {
4207 message: format!("GitHub draft {} board item has no project", id.0),
4208 })?,
4209 "id",
4210 )? != self.board_fields().await?.id.as_str()
4211 {
4212 return Ok(None);
4213 }
4214 let item = json!({
4215 "id": required_str(held, "id")?,
4216 "project": held.get("project"),
4217 "fieldValues": held.get("fieldValues"),
4218 "content": draft,
4219 });
4220 self.resolve(&item)
4221 }
4222
4223 /// The board's own id and field definitions, for a write whose item does not carry
4224 /// them — never its items.
4225 ///
4226 /// A board this command has already listed supplies them, since it read them beside its
4227 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
4228 /// is consulted about which items the board holds: see the module documentation for
4229 /// why a question about one known item is answered by reading that item.
4230 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
4231 if let Some(board) = self.board_cache()?.as_ref() {
4232 return Ok(BoardFields {
4233 id: BoardId::parse(&board.id)?,
4234 fields: board.fields.clone(),
4235 });
4236 }
4237 if let Some(held) = self.fields_cache()?.clone() {
4238 return Ok(held);
4239 }
4240 let data = self
4241 .graphql(
4242 graphql::BOARD_FIELDS,
4243 json!({"owner":self.owner,"number":self.project_number,
4244 "nestedFirst":NESTED_PAGE_SIZE}),
4245 )
4246 .await?;
4247 self.fields_read(&data)
4248 }
4249
4250 /// The board's id and fields out of an answer carrying the `boardFields` root, held for
4251 /// the rest of this command.
4252 fn fields_read(&self, data: &Value) -> Result<BoardFields, SourceError> {
4253 let board = data
4254 .pointer("/boardFields/projectV2")
4255 .filter(|value| !value.is_null())
4256 .ok_or_else(|| SourceError::Refused {
4257 message: format!(
4258 "GitHub project {}/{} was not found or is not visible to the token",
4259 self.owner, self.project_number
4260 ),
4261 })?;
4262 let read = BoardFields {
4263 id: BoardId::parse(required_str(board, "id")?)?,
4264 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4265 };
4266 *self.fields_cache()? = Some(read.clone());
4267 Ok(read)
4268 }
4269
4270 /// Read what creating an issue in `repository` needs and this command has not read yet —
4271 /// the board's fields and the repository's node id — in one request when it needs both.
4272 ///
4273 /// When either is already known this sends nothing, and the other is read by its own
4274 /// document where it is asked for, so no create reads anything twice.
4275 async fn creation_context(
4276 &self,
4277 repository: &RepositoryTarget,
4278 incoming: &Incoming<'_>,
4279 ) -> Result<(), SourceError> {
4280 let fields_known = self.board_cache()?.is_some() || self.fields_cache()?.is_some();
4281 if fields_known || self.repository_cache()?.contains_key(repository) {
4282 return Ok(());
4283 }
4284 let data = self
4285 .graphql(
4286 graphql::CREATION_CONTEXT,
4287 json!({"owner":self.owner,"number":self.project_number,
4288 "nestedFirst":NESTED_PAGE_SIZE,"repositoryOwner":repository.owner,
4289 "repositoryName":repository.name}),
4290 )
4291 .await?;
4292 self.fields_read(&data)?;
4293 self.repository_read(&data, repository, incoming)?;
4294 Ok(())
4295 }
4296
4297 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
4298 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
4299 self.fields_cache
4300 .lock()
4301 .map_err(|_| SourceError::Unavailable {
4302 message: "this source's view of the board's fields was left inconsistent by an \
4303 earlier failure; next: run the command again"
4304 .into(),
4305 })
4306 }
4307
4308 /// What a write to `item` needs of the board, read off that item when it says enough and
4309 /// off [`Self::board_fields`] when it does not.
4310 ///
4311 /// A node read of an item names its board and carries the definition of every field it
4312 /// holds a value of — so an item naming its board, holding a value of the origin field,
4313 /// and, when the write carries a status, holding a `Status` value, needs no read of the
4314 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
4315 /// of may still be on the board, and a view reading it as absent would refuse a write the
4316 /// board can take or skip a field write the board needs, so such an item — and a create,
4317 /// which has no item yet — takes the board's fields from their own read instead.
4318 async fn fields_for(
4319 &self,
4320 item: Option<&Resolved>,
4321 writes_status: bool,
4322 selects_priority: bool,
4323 ) -> Result<BoardFields, SourceError> {
4324 if let Some(board) = item.and_then(Resolved::carried_board) {
4325 return Ok(board);
4326 }
4327 if let Some(item) = item
4328 && let Some(board_id) = item.named_board()
4329 && item.defines(ORIGIN_FIELD)
4330 && (!writes_status || item.defines("Status"))
4331 && (!selects_priority || item.defines(PRIORITY_FIELD))
4332 {
4333 return Ok(BoardFields {
4334 id: board_id,
4335 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4336 });
4337 }
4338 self.board_fields().await
4339 }
4340
4341 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4342 /// when that id names nothing here with a sub-issue relationship to walk.
4343 ///
4344 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4345 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4346 /// empty vector is a project that holds nothing.
4347 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4348 let mut after: Option<String> = None;
4349 let mut children = Vec::new();
4350 loop {
4351 let asked = self
4352 .graphql(
4353 graphql::SUB_ISSUES,
4354 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4355 "nestedFirst":NESTED_PAGE_SIZE,
4356 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4357 )
4358 .await;
4359 let data = match asked {
4360 Ok(data) => data,
4361 // A string that is not a node id at all is not a failure to report: it is
4362 // the ordinary answer to a selector naming a project by its name.
4363 Err(error) if unresolvable_node(&error) => return Ok(None),
4364 Err(error) => return Err(error),
4365 };
4366 let Some(connection) = data
4367 .pointer("/node/subIssues")
4368 .filter(|value| !value.is_null())
4369 else {
4370 // No such node, or one with no sub-issue relationship — a board draft is
4371 // the one this board can really hold.
4372 return Ok(None);
4373 };
4374 for node in connection
4375 .get("nodes")
4376 .and_then(Value::as_array)
4377 .ok_or_else(|| SourceError::Malformed {
4378 message: "GitHub subIssues.nodes is not an array".into(),
4379 })?
4380 {
4381 if let Some(resolved) = self.resolve_issue(node).await? {
4382 children.push(resolved);
4383 }
4384 }
4385 let info = connection
4386 .get("pageInfo")
4387 .ok_or_else(|| SourceError::Malformed {
4388 message: "GitHub subIssues connection has no pageInfo".into(),
4389 })?;
4390 let next = required_bool(info, "hasNextPage")?
4391 .then(|| required_str(info, "endCursor"))
4392 .transpose()?;
4393 match next {
4394 Some(next) => {
4395 validate_cursor_progress(after.as_deref(), next)?;
4396 after = Some(next.to_owned());
4397 }
4398 None => return Ok(Some(children)),
4399 }
4400 }
4401 }
4402
4403 /// Which issue of this board a project *name* is, or `None` when none is.
4404 ///
4405 /// One bounded query which filters on that name at the server, rather than a walk of
4406 /// every issue the board holds. The name is compared again here: the qualifier narrows
4407 /// what GitHub sends, and this source decides what it names.
4408 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4409 let search = self.board_search(Some(&title_qualifier(name)));
4410 let mut after = None;
4411 loop {
4412 let (candidates, next) = self
4413 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4414 .await?;
4415 if let Some(item) = candidates.into_iter().find(|item| {
4416 item.kind == BoardKind::Work(ItemKind::Project)
4417 && item.title.eq_ignore_ascii_case(name)
4418 }) {
4419 return Ok(Some(item.id));
4420 }
4421 match next {
4422 Some(next) => after = Some(next),
4423 None => return Ok(None),
4424 }
4425 }
4426 }
4427
4428 /// Everything filed under one project of this board: the sub-issues of the issue that
4429 /// project is.
4430 ///
4431 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4432 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4433 /// gains projects, or as another project gains tasks.
4434 ///
4435 /// A qualified id names the issue and is asked for its sub-issues directly: one
4436 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4437 /// read as a project *name*, which costs the one bounded search
4438 /// [`Self::project_by_name`] makes.
4439 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4440 let (project, children) = match self.sub_issues(selector).await? {
4441 Some(children) => (selector.clone(), children),
4442 None => match self.project_by_name(&selector.0).await? {
4443 Some(project) => {
4444 let children = self.sub_issues(&project).await?.unwrap_or_default();
4445 (project, children)
4446 }
4447 None => return Ok(Vec::new()),
4448 },
4449 };
4450 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4451 }
4452
4453 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4454 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4455 ///
4456 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4457 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4458 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4459 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4460 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4461 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4462 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4463 /// rather than silently narrowing a caller's answer.
4464 ///
4465 /// The instant is written to the second, rounded down, which can only widen what the
4466 /// search returns; confirmation against each candidate's own comments is what makes the
4467 /// answer exact. The search is an index that lags a write by a second or two — the module
4468 /// documentation records it — so a caller that asks again from its last instant should
4469 /// overlap the two by more than that.
4470 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4471 let found = self.searched(&updated_qualifier(since)).await?;
4472 self.completed_with_written(found, |_| true)
4473 }
4474
4475 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4476 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4477 ///
4478 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4479 /// own record is the fresher of the two.
4480 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4481 let search = self.board_search(Some(also));
4482 let mut after: Option<String> = None;
4483 let mut found = Vec::new();
4484 loop {
4485 let (page, next) = self
4486 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4487 .await?;
4488 found.extend(page);
4489 match next {
4490 Some(next) => after = Some(next),
4491 None => return Ok(found),
4492 }
4493 }
4494 }
4495
4496 /// A bounded task answer; the versioned cursor carries the connection position, how
4497 /// many rows of the page starting there were already handed out, and the own-write ids
4498 /// already observed, including across a new source instance.
4499 ///
4500 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4501 /// sliced from the pages it needs; why is the module documentation's paging contract.
4502 async fn search_tasks(
4503 &self,
4504 query: &TaskQuery,
4505 page: &PageRequest,
4506 also: &str,
4507 ) -> Result<Page<Task>, SourceError> {
4508 let mut position = match &page.cursor {
4509 None => SearchPosition::default(),
4510 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4511 .ok()
4512 .filter(|position| {
4513 position.version == SEARCH_CURSOR_VERSION
4514 && position.connection.valid_resume(position.offset)
4515 })
4516 .ok_or_else(|| SourceError::Config {
4517 message: "page cursor is invalid".into(),
4518 })?,
4519 };
4520 let search = self.board_search(Some(also));
4521 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4522 let own = self.with_own_writes(Vec::new())?;
4523 for item in &own {
4524 if !position.own.contains(&item.id) {
4525 position.own.push(item.id.clone());
4526 }
4527 }
4528 let mut tasks = Vec::new();
4529 while !position.connection.exhausted() && tasks.len() < limit {
4530 let first = SEARCH_PAGE_SIZE;
4531 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4532 let key =
4533 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4534 .expect("search page key is serializable");
4535 let cached = if query.commented_since.is_none() {
4536 self.narrowed_cache()?.get(&key).cloned()
4537 } else {
4538 None
4539 };
4540 let (found, next) = match cached {
4541 Some(found) => {
4542 let next = self
4543 .search_next
4544 .lock()
4545 .map_err(|_| SourceError::Unavailable {
4546 message:
4547 "search pagination was left inconsistent; run the command again"
4548 .into(),
4549 })?
4550 .get(&key)
4551 .cloned()
4552 .flatten();
4553 (found, next)
4554 }
4555 None => {
4556 let (found, next) = self
4557 .search_page(&search, first, position.connection.after())
4558 .await?;
4559 if query.commented_since.is_none() {
4560 self.search_next
4561 .lock()
4562 .map_err(|_| SourceError::Unavailable {
4563 message:
4564 "search pagination was left inconsistent; run the command again"
4565 .into(),
4566 })?
4567 .insert(key.clone(), next.clone());
4568 self.narrowed_cache()?.insert(key, found.clone());
4569 }
4570 (found, next)
4571 }
4572 };
4573 let rows = found.len();
4574 for mut item in found.into_iter().skip(position.offset) {
4575 if tasks.len() == limit {
4576 break;
4577 }
4578 position.offset += 1;
4579 if position.own.contains(&item.id) {
4580 if position.seen.contains(&item.id) {
4581 continue;
4582 }
4583 position.seen.push(item.id.clone());
4584 let updated_at = item.updated_at;
4585 let Some(written) = self.search_written(&own, &item.id).await? else {
4586 continue;
4587 };
4588 item = written;
4589 item.updated_at = item.updated_at.max(updated_at);
4590 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4591 }
4592 if item.kind == BoardKind::Work(ItemKind::Task) {
4593 let task = item.task()?;
4594 if task_matches(&task, query, &query.project)
4595 && self.commented_since(&item, query.commented_since).await?
4596 {
4597 tasks.push(task);
4598 }
4599 }
4600 }
4601 if position.offset < rows {
4602 continue;
4603 }
4604 position.offset = 0;
4605 position.connection = match next {
4606 Some(after) => SearchConnection::Continuing {
4607 after: Cursor(after),
4608 },
4609 None => SearchConnection::Exhausted {},
4610 };
4611 }
4612 if position.connection.exhausted() {
4613 for id in position.own.clone() {
4614 if position.seen.contains(&id) {
4615 continue;
4616 }
4617 if tasks.len() == limit {
4618 break;
4619 }
4620 position.seen.push(id.clone());
4621 let Some(item) = self.search_written(&own, &id).await? else {
4622 continue;
4623 };
4624 if item.kind == BoardKind::Work(ItemKind::Task) {
4625 let task = item.task()?;
4626 if task_matches(&task, query, &query.project)
4627 && self.commented_since(&item, query.commented_since).await?
4628 {
4629 tasks.push(task);
4630 }
4631 }
4632 }
4633 }
4634 let more = !position.connection.exhausted()
4635 || position.own.iter().any(|id| !position.seen.contains(id));
4636 Ok(Page {
4637 items: tasks,
4638 next: more.then(|| {
4639 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4640 }),
4641 })
4642 }
4643
4644 /// A resumed process has the ids but no write records; resolve only a record the
4645 /// current page needs, by its uncached node read rather than the lagging search index.
4646 async fn search_written(
4647 &self,
4648 own: &[Resolved],
4649 id: &NativeId,
4650 ) -> Result<Option<Resolved>, SourceError> {
4651 match own.iter().find(|item| item.id == *id) {
4652 Some(item) => Ok(Some(item.clone())),
4653 None => self.item_by_id(id).await,
4654 }
4655 }
4656
4657 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4658 /// without enumerating the board — or `None` for a query carrying none of the three, which
4659 /// keeps the reads it always had.
4660 ///
4661 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4662 /// because it names at most a handful of items. Text and metadata are answered by one
4663 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4664 /// further by `updated:>=` when the query also asks for comment activity, since both
4665 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4666 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4667 ///
4668 /// Completed with what this process wrote, its own record winning over the index's copy
4669 /// of the same item: see [`Self::with_own_writes`].
4670 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4671 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4672 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4673 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4674 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4675 None => qualifiers,
4676 }),
4677 (None, None) => return Ok(None),
4678 };
4679 // A question about comment activity is asked afresh every time, as it always was: it
4680 // is the one a caller polls from one source while waiting for the index, and an
4681 // answer held from the first poll would be the answer to every later one.
4682 let key = query.commented_since.is_none().then(|| asked.key());
4683 let cached = match &key {
4684 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4685 None => None,
4686 };
4687 let found = match cached {
4688 Some(found) => found,
4689 None => {
4690 let found = match &asked {
4691 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4692 Narrowing::Search(also) => self.searched(also).await?,
4693 };
4694 if let Some(key) = key {
4695 self.narrowed_cache()?.insert(key, found.clone());
4696 }
4697 found
4698 }
4699 };
4700 self.with_own_writes(found).map(Some)
4701 }
4702
4703 /// Every item of this board that may carry `origin` — a superset of those that do — found
4704 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4705 ///
4706 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4707 /// which reads the field every carrier holds, whichever release wrote it — and the
4708 /// board-scoped issue search for the same id as a phrase in the body, where this source
4709 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4710 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4711 /// query's, exactly.
4712 ///
4713 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4714 /// already ended is sent its last cursor again, which answers an empty page, so the one
4715 /// document serves every page of either. What the two leave is stated in the module
4716 /// documentation: a carrier another process added within the last second or two, before
4717 /// either index has it.
4718 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4719 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4720 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4721 let mut items_after: Option<String> = None;
4722 let mut search_after: Option<String> = None;
4723 let mut found: Vec<Resolved> = Vec::new();
4724 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4725 if !found.iter().any(|held| held.id == resolved.id) {
4726 found.push(resolved);
4727 }
4728 };
4729 loop {
4730 let data = self
4731 .graphql(
4732 graphql::ORIGIN_LOOKUP,
4733 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4734 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4735 "itemsAfter":items_after,"searchAfter":search_after,
4736 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4737 "duplicates":true}),
4738 )
4739 .await?;
4740 let items = data
4741 .pointer("/originItems/projectV2/items")
4742 .filter(|value| !value.is_null())
4743 .ok_or_else(|| SourceError::Refused {
4744 message: format!(
4745 "GitHub project {}/{} was not found or is not visible to the token",
4746 self.owner, self.project_number
4747 ),
4748 })?;
4749 for item in optional_nodes(Some(items), "project items")?
4750 .into_iter()
4751 .flatten()
4752 {
4753 // The board's own items list its drafts too, and a draft is not an issue: no
4754 // narrowed read answers with one, whatever its origin field holds.
4755 if let Some(resolved) = self.resolve(item)?
4756 && resolved.content_kind == ContentKind::Issue
4757 {
4758 keep(resolved, &mut found);
4759 }
4760 }
4761 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4762 message: "GitHub search response has no search connection".into(),
4763 })?;
4764 for node in optional_nodes(Some(searched), "search")?
4765 .into_iter()
4766 .flatten()
4767 {
4768 if let Some(resolved) = self.resolve_issue(node).await? {
4769 keep(resolved, &mut found);
4770 }
4771 }
4772 let items_next = resumed(items, items_after.as_deref())?;
4773 let search_next = resumed(searched, search_after.as_deref())?;
4774 if !items_next.has_more() && !search_next.has_more() {
4775 return Ok(found);
4776 }
4777 items_after = items_next.cursor();
4778 search_after = search_next.cursor();
4779 }
4780 }
4781
4782 /// `found`, with every item this process created or wrote in its place, and every one of
4783 /// them the read did not report added.
4784 ///
4785 /// This process's own record wins over the read's copy of the same item, because a read
4786 /// of an item written moments ago can still be behind what was written onto it — the
4787 /// origin field included, which is the one a narrowed read is confirmed against — and a
4788 /// read that still names an item under a predicate this process's write moved it out of
4789 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4790 /// last saw the item change, which is what a comment-activity read rules a candidate out
4791 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4792 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4793 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4794 // A board draft is not an issue, so no narrowed read returns one, and this process
4795 // having written one does not make it an answer either.
4796 let own: Vec<Resolved> = self
4797 .created()?
4798 .iter()
4799 .chain(self.updated()?.iter())
4800 .filter(|own| own.content_kind == ContentKind::Issue)
4801 .cloned()
4802 .collect();
4803 for mut own in own {
4804 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4805 match found.iter_mut().find(|read| read.id == own.id) {
4806 Some(read) => {
4807 own.updated_at = own.updated_at.max(read.updated_at);
4808 *read = own;
4809 }
4810 None => found.push(own),
4811 }
4812 }
4813 Ok(found)
4814 }
4815
4816 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4817 /// there is no instant to hold it to.
4818 ///
4819 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4820 /// or after the instant moved it there: an issue not updated since holds no such comment,
4821 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4822 /// only as far as the first that matches. A board draft is not an issue and has no
4823 /// comments, so it never matches.
4824 async fn commented_since(
4825 &self,
4826 item: &Resolved,
4827 since: Option<DateTime<Utc>>,
4828 ) -> Result<bool, SourceError> {
4829 let Some(since) = since else {
4830 return Ok(true);
4831 };
4832 if item.content_kind == ContentKind::DraftIssue
4833 || item.updated_at.is_some_and(|updated| updated < since)
4834 {
4835 return Ok(false);
4836 }
4837 let query = TaskQuery {
4838 commented_since: Some(since),
4839 ..TaskQuery::default()
4840 };
4841 let mut after: Option<String> = None;
4842 loop {
4843 let data = self
4844 .graphql(
4845 graphql::ISSUE_COMMENTS,
4846 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4847 )
4848 .await?;
4849 let Some(connection) = data
4850 .get("node")
4851 .filter(|value| !value.is_null())
4852 .and_then(|node| node.get("comments"))
4853 .filter(|value| !value.is_null())
4854 else {
4855 // Removed since the search reported it: no longer an issue with comments.
4856 return Ok(false);
4857 };
4858 let comments = optional_nodes(Some(connection), "issue comments")?
4859 .into_iter()
4860 .flatten()
4861 .map(comment_from)
4862 .collect::<Result<Vec<_>, _>>()?;
4863 if query.comments_match(&comments) {
4864 return Ok(true);
4865 }
4866 match next_cursor(connection)? {
4867 Some(next) => {
4868 validate_cursor_progress(after.as_deref(), &next.0)?;
4869 after = Some(next.0);
4870 }
4871 None => return Ok(false),
4872 }
4873 }
4874 }
4875
4876 /// Every item on the board: the union of both enumerations GitHub offers of one.
4877 ///
4878 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4879 /// board **draft** and reads the board's own fields beside its items, and only the search
4880 /// reports an item that connection is behind on. The module documentation is where the lag and the
4881 /// measurements behind it are written down.
4882 ///
4883 /// A search result is admitted on the same terms as any other issue this source reaches
4884 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4885 /// names *this* board — so an issue the index still believes is here after it was taken
4886 /// off is refused rather than reported.
4887 ///
4888 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4889 /// which is what the cache could otherwise have broken.
4890 async fn board(&self) -> Result<Board, SourceError> {
4891 let cached = self.board_cache()?.clone();
4892 let mut board = match cached {
4893 Some(board) => board,
4894 None => {
4895 let read = self.read_board().await?;
4896 *self.board_cache()? = Some(read.clone());
4897 read
4898 }
4899 };
4900 for held in self.searched_issues().await? {
4901 if !board.items.iter().any(|item| item.id == held.id) {
4902 board.items.push(held);
4903 }
4904 }
4905 for own in self.created()?.iter() {
4906 if !board.items.iter().any(|item| item.id == own.id) {
4907 board.items.push(own.clone());
4908 }
4909 }
4910 Ok(board)
4911 }
4912
4913 /// This process's own view of the board, or the refusal a poisoned lock is.
4914 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4915 self.board_cache
4916 .lock()
4917 .map_err(|_| SourceError::Unavailable {
4918 message: "this source's view of the board was left inconsistent by an earlier \
4919 failure; next: run the command again"
4920 .into(),
4921 })
4922 }
4923
4924 /// Bring this process's own view of the board up to an item it has just written.
4925 ///
4926 /// A created item goes to `created`, which is what completes a board read GitHub's own
4927 /// eventual consistency has left behind. An item that was already there is replaced
4928 /// where it sits, so a second write of it in the same command reads its real parent
4929 /// rather than the one it had before the first write.
4930 ///
4931 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4932 /// that wins: an item this same run created is held in `created` and not in the cached
4933 /// board, and `board` completes the cached board *from* `created`, so replacing only
4934 /// the cached copy of such an item replaces nothing and the read still reports the
4935 /// title it was created with. The search is the third, and it is the one an item the
4936 /// board's own projection is behind on sits in *alone* — which is exactly the item this
4937 /// source is least able to re-read, so leaving it out would put the stale title back on
4938 /// the only items the completion in [`Self::board`] exists for.
4939 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4940 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4941 if created {
4942 self.created()?.push(item);
4943 return Ok(());
4944 }
4945 {
4946 let mut own = self.created()?;
4947 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4948 *held = item;
4949 return Ok(());
4950 }
4951 }
4952 {
4953 let mut own = self.updated()?;
4954 match own.iter_mut().find(|held| held.id == item.id) {
4955 Some(held) => *held = item.clone(),
4956 None => own.push(item.clone()),
4957 }
4958 }
4959 if let Some(board) = self.board_cache()?.as_mut()
4960 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4961 {
4962 *held = item.clone();
4963 }
4964 if let Some(found) = self.search_cache()?.as_mut()
4965 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4966 {
4967 *held = item.clone();
4968 }
4969 for found in self.narrowed_cache()?.values_mut() {
4970 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4971 *held = item.clone();
4972 }
4973 }
4974 Ok(())
4975 }
4976
4977 /// Forget one item this process has just deleted, from every half of its own view.
4978 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4979 self.resolved_cache()?.remove(id);
4980 self.created()?.retain(|own| own.id != *id);
4981 self.updated()?.retain(|own| own.id != *id);
4982 if let Some(board) = self.board_cache()?.as_mut() {
4983 board.items.retain(|item| item.id != *id);
4984 }
4985 if let Some(found) = self.search_cache()?.as_mut() {
4986 found.retain(|item| item.id != *id);
4987 }
4988 for found in self.narrowed_cache()?.values_mut() {
4989 found.retain(|item| item.id != *id);
4990 }
4991 Ok(())
4992 }
4993
4994 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4995 fn narrowed_cache(
4996 &self,
4997 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4998 self.narrowed_cache
4999 .lock()
5000 .map_err(|_| SourceError::Unavailable {
5001 message: "this source's view of a narrowed read was left inconsistent by an \
5002 earlier failure; next: run the command again"
5003 .into(),
5004 })
5005 }
5006
5007 /// Every page of the board, read from GitHub.
5008 async fn read_board(&self) -> Result<Board, SourceError> {
5009 let mut after: Option<String> = None;
5010 let mut items = Vec::new();
5011 let mut board;
5012 loop {
5013 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
5014 for item in page
5015 .pointer("/items/nodes")
5016 .and_then(Value::as_array)
5017 .ok_or_else(|| SourceError::Malformed {
5018 message: "GitHub project items.nodes is not an array".into(),
5019 })?
5020 {
5021 if let Some(resolved) = self.resolve(item)? {
5022 items.push(resolved);
5023 }
5024 }
5025 let info = page
5026 .pointer("/items/pageInfo")
5027 .ok_or_else(|| SourceError::Malformed {
5028 message: "GitHub project items have no pageInfo".into(),
5029 })?;
5030 let has_next = required_bool(info, "hasNextPage")?;
5031 let next = has_next
5032 .then(|| required_str(info, "endCursor"))
5033 .transpose()?;
5034 board = page.clone();
5035 match next {
5036 Some(next) => {
5037 validate_cursor_progress(after.as_deref(), next)?;
5038 after = Some(next.to_owned());
5039 }
5040 None => break,
5041 }
5042 }
5043 Ok(Board {
5044 id: required_str(&board, "id")?.to_owned(),
5045 fields: board.get("fields").cloned().unwrap_or(Value::Null),
5046 items,
5047 })
5048 }
5049
5050 /// The existing items this source has written, for completing a narrowed read that is
5051 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
5052 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5053 self.updated.lock().map_err(|_| SourceError::Unavailable {
5054 message: "this source's record of what it wrote in this run was left inconsistent \
5055 by an earlier failure; next: run the command again"
5056 .into(),
5057 })
5058 }
5059
5060 /// The items this source has created, for completing a board read that is behind.
5061 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
5062 self.created.lock().map_err(|_| SourceError::Unavailable {
5063 message: "this source's record of what it created in this run was left \
5064 inconsistent by an earlier failure; next: run the command again"
5065 .into(),
5066 })
5067 }
5068
5069 /// One board item as this source reports it, or `None` for content it ignores.
5070 ///
5071 /// A pull request is neither a project nor a task — it is somebody's change, not a
5072 /// unit of plan — and an item whose content the token cannot see has nothing to
5073 /// report at all.
5074 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
5075 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
5076 message: "GitHub project item is missing content".into(),
5077 })?;
5078 if content.is_null() {
5079 return Ok(None);
5080 }
5081 let content_kind = match required_str(content, "__typename")? {
5082 "Issue" => ContentKind::Issue,
5083 "DraftIssue" => ContentKind::DraftIssue,
5084 _ => return Ok(None),
5085 };
5086 let field_values = item
5087 .get("fieldValues")
5088 .ok_or_else(|| SourceError::Malformed {
5089 message: "GitHub project item is missing fieldValues".into(),
5090 })?;
5091 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
5092 let nodes = field_values
5093 .get("nodes")
5094 .and_then(Value::as_array)
5095 .ok_or_else(|| SourceError::Malformed {
5096 message: "GitHub project item fieldValues.nodes is not an array".into(),
5097 })?;
5098 if let Some(labels) = content.get("labels") {
5099 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
5100 }
5101 let raw_body = optional_str(content, "body")?.map(str::to_owned);
5102 let (body, slot) = metadata_body(raw_body.clone())?;
5103 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
5104 .map(|id| NativeId(id.to_owned()));
5105 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
5106 // to read one from; it is a task, and never a project.
5107 let sub_issues = match content_kind {
5108 ContentKind::Issue => sub_issue_total(content)?,
5109 ContentKind::DraftIssue => 0,
5110 };
5111 let content_id = required_str(content, "id")?;
5112 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
5113 message: format!("GitHub issue {content_id}: {message}"),
5114 })?;
5115 let raw_title = required_str(content, "title")?;
5116 // The design prefix is read *first*, before either of the two rules that separate
5117 // a project from a task. A document is not work whatever sub-issues it has and
5118 // whatever marker it carries, and reading the prefix later would make a design
5119 // issue with none of either an empty project.
5120 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
5121 BoardKind::Document
5122 } else if parent.is_some() {
5123 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
5124 // under a project is that project's task even when it has sub-issues of its
5125 // own.
5126 BoardKind::Work(ItemKind::Task)
5127 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
5128 BoardKind::Work(ItemKind::Project)
5129 } else {
5130 BoardKind::Work(ItemKind::Task)
5131 };
5132 // The title a person wrote, which for a document is the one without the prefix —
5133 // the same way `content` above is the body without this source's metadata slot.
5134 let title = match kind {
5135 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
5136 BoardKind::Work(_) => raw_title.to_owned(),
5137 };
5138 let own_repository = content
5139 .pointer("/repository/nameWithOwner")
5140 .and_then(Value::as_str)
5141 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
5142 .transpose()
5143 .map_err(|message| SourceError::Malformed { message })?;
5144 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
5145 Repository::from_metadata(&slot)
5146 .map_err(|message| SourceError::Malformed { message })?
5147 } else {
5148 own_repository.clone().into_iter().collect()
5149 };
5150 let id = NativeId(content_id.to_owned());
5151 // Read only for a task, because only a task has either list: a project or a
5152 // document holding one of these keys holds nothing this source reports, and the
5153 // keys are left out of its caller-visible metadata all the same.
5154 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
5155 let listed = |key: &str| {
5156 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
5157 .map_err(|message| SourceError::Malformed { message })
5158 };
5159 (
5160 listed(TaskRef::DELIVERS_KEY)?,
5161 listed(TaskRef::DELIVERED_BY_KEY)?,
5162 )
5163 } else {
5164 (Vec::new(), Vec::new())
5165 };
5166 let (option, closed, reason) = Self::status_parts(nodes, content)?;
5167 let priority = self.held_priority(nodes)?;
5168 // Present when the item was reached through its own issue, whose board entry
5169 // names the board; a read of the board's own items has the board already. An
5170 // empty id names nothing a field write could address, so it is read as absent and
5171 // the write goes back to reading the board.
5172 let board_id = item
5173 .pointer("/project/id")
5174 .and_then(Value::as_str)
5175 .filter(|id| !id.is_empty());
5176 let resolved = Resolved {
5177 item_id: required_str(item, "id")?.to_owned(),
5178 id,
5179 content_kind,
5180 kind,
5181 title,
5182 body: body.filter(|value| !value.is_empty()),
5183 raw_body,
5184 status: self.statuses.status(option, closed, reason),
5185 option: option.map(str::to_owned),
5186 priority,
5187 closed,
5188 delivers,
5189 delivered_by,
5190 labels: labels(content)?,
5191 parent,
5192 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
5193 number: match content_kind {
5194 ContentKind::Issue => Some(issue_number(content)?),
5195 // A draft is filed in no repository, so nothing ever numbered it:
5196 // `DraftIssue` declares no `number` at all, exactly as it declares no
5197 // `subIssuesSummary` the branch above reads.
5198 ContentKind::DraftIssue => None,
5199 },
5200 url: optional_str(content, "url")?.map(str::to_owned),
5201 created_at: optional_time(content, "createdAt")?,
5202 updated_at: optional_time(content, "updatedAt")?,
5203 own_repository,
5204 repositories,
5205 slot,
5206 board_id: board_id.map(str::to_owned),
5207 fields: field_definitions(nodes),
5208 board_fields: Self::carried_board_fields(content, board_id)?,
5209 blocked_by: carried_blocked_by(content)?,
5210 };
5211 self.resolved_cache()?
5212 .insert(resolved.id.clone(), resolved.clone());
5213 Ok(Some(resolved))
5214 }
5215
5216 /// The field definitions of the board `board_id` names — the project this issue's own
5217 /// board item is on — off the `boards` page a read of an issue by its own id carries, or
5218 /// `None` when the read carried none, carried no entry for that board, or the board item
5219 /// named no board, which a write then answers by reading the board's fields itself.
5220 ///
5221 /// Matched by the board's node id and never by its number alone: a project number is
5222 /// unique only within its owner, so another owner's board numbered alike can sit on the
5223 /// same page, and its field and option ids address nothing on this one.
5224 fn carried_board_fields(
5225 content: &Value,
5226 board_id: Option<&str>,
5227 ) -> Result<Option<Value>, SourceError> {
5228 let (Some(nodes), Some(board_id)) = (
5229 content.pointer("/boards/nodes").and_then(Value::as_array),
5230 board_id,
5231 ) else {
5232 return Ok(None);
5233 };
5234 let Some(board) = nodes.iter().find_map(|node| {
5235 let project = node.get("project")?;
5236 (project.get("id").and_then(Value::as_str) == Some(board_id)).then_some(project)
5237 }) else {
5238 return Ok(None);
5239 };
5240 let Some(fields) = board.get("fields").filter(|fields| !fields.is_null()) else {
5241 return Ok(None);
5242 };
5243 complete_connection(fields, "board fields", NESTED_PAGE_SIZE)?;
5244 Ok(Some(fields.clone()))
5245 }
5246
5247 /// What one board item's `Priority` field says, through this instance's mapping.
5248 ///
5249 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
5250 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
5251 /// option the mapping does not name is kept as itself — never read as a level or as
5252 /// `none` — for a read of the task to report by name.
5253 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
5254 let Some(mapping) = &self.priorities else {
5255 return Ok(HeldPriority::Read(Priority::None));
5256 };
5257 // A value of the field that names no option — a text field someone called `Priority` —
5258 // is malformed rather than `none`: reading it as no priority would let the next copy
5259 // clear one a person set.
5260 let Some(option) = field_values
5261 .iter()
5262 .find(|value| {
5263 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
5264 })
5265 .map(|value| required_str(value, "name"))
5266 .transpose()?
5267 else {
5268 return Ok(HeldPriority::Read(Priority::None));
5269 };
5270 Ok(mapping.priority_of(option).map_or_else(
5271 || HeldPriority::Unmapped(option.to_owned()),
5272 HeldPriority::Read,
5273 ))
5274 }
5275
5276 /// What one board item's status is read from: its `Status` option, whether its issue
5277 /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
5278 /// three into the status it reports.
5279 fn status_parts<'a>(
5280 field_values: &'a [Value],
5281 content: &'a Value,
5282 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
5283 let option = field_values
5284 .iter()
5285 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
5286 .map(|value| required_str(value, "name"))
5287 .transpose()?;
5288 let closed = optional_str(content, "state")? == Some("CLOSED");
5289 Ok((option, closed, optional_str(content, "stateReason")?))
5290 }
5291
5292 /// The board Status option this write selects, or the refusal that says why not.
5293 ///
5294 /// The mapped option is required for both open and terminal targets. A terminal write
5295 /// validates it before changing either representation, so it can never fall back to
5296 /// closing an issue whose board cannot display the matching status.
5297 ///
5298 /// Answers the field's id, the option's id, and the option's name as the board spells
5299 /// it — which is the name a read of the item reports once it sits there.
5300 fn column_for(
5301 &self,
5302 fields: &Value,
5303 status: &Status,
5304 target: &StatusTarget,
5305 ) -> Result<Option<(String, String, String)>, SourceError> {
5306 let wanted = match target {
5307 StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
5308 StatusTarget::Disabled => return Ok(None),
5309 };
5310 let missing = |detail: &str| SourceError::Refused {
5311 message: format!(
5312 "status {} of source {} needs the board Status option {wanted:?}, and {detail}; add that option to the board, or point status_mapping.{} of this source at one it has",
5313 category_name(status.category),
5314 self.name,
5315 category_name(status.category)
5316 ),
5317 };
5318 let Some(field) = Board::field(fields, "Status")? else {
5319 return Err(missing("this board has no Status field"));
5320 };
5321 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5322 return Err(missing(
5323 "this board's Status field is not a single-select field",
5324 ));
5325 }
5326 let option = field
5327 .get("options")
5328 .and_then(Value::as_array)
5329 .and_then(|options| {
5330 options.iter().find(|option| {
5331 option
5332 .get("name")
5333 .and_then(Value::as_str)
5334 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5335 })
5336 });
5337 match option {
5338 None => Err(missing("this board does not have it")),
5339 Some(option) => Ok(Some((
5340 required_str(field, "id")?.to_owned(),
5341 required_str(option, "id")?.to_owned(),
5342 required_str(option, "name")?.to_owned(),
5343 ))),
5344 }
5345 }
5346
5347 /// The refusal a status that closes an issue is answered with over a board draft.
5348 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
5349 SourceError::Refused {
5350 message: format!(
5351 "status {} of source {} closes the item's issue, and GitHub draft items have \
5352 no open or closed state",
5353 category_name(category),
5354 self.name
5355 ),
5356 }
5357 }
5358
5359 /// What a status write to one item needs of the board: the board's id and the
5360 /// definition of its `Status` field, read off the item when the item says both.
5361 ///
5362 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5363 /// and its `Status` value carries that field's definition, options and all. An item that
5364 /// does not say — no board id, or no `Status` value to read the field off — takes them
5365 /// from [`Self::board_fields`], which reads no item.
5366 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5367 if let Some(board) = item.carried_board() {
5368 return Ok(board);
5369 }
5370 if item.defines("Status")
5371 && let Some(board_id) = item.named_board()
5372 {
5373 return Ok(BoardFields {
5374 id: board_id,
5375 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5376 });
5377 }
5378 self.board_fields().await
5379 }
5380
5381 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5382 async fn set_status(
5383 &self,
5384 id: &NativeId,
5385 category: StatusCategory,
5386 ) -> Result<Option<Status>, SourceError> {
5387 // Refused before anything is read, in the words a write of the same status is.
5388 let target = self.resolved_target(category)?;
5389 let Some(mut item) = self
5390 .bound_item(id)
5391 .await?
5392 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5393 else {
5394 return Ok(None);
5395 };
5396 let board = self.status_board(&item).await?;
5397 let wanted = Status {
5398 category,
5399 name: category_name(category).to_owned(),
5400 };
5401 let (field, option, name) = self
5402 .column_for(&board.fields, &wanted, &target)?
5403 .ok_or_else(|| SourceError::Malformed {
5404 message: format!(
5405 "status {} of source {} names no board Status option",
5406 category_name(category),
5407 self.name
5408 ),
5409 })?;
5410 if item.status.category == category && item.option.as_deref() == Some(&name) {
5411 return Ok(Some(item.status));
5412 }
5413 match &target {
5414 StatusTarget::Terminal(_, reason) => {
5415 if item.content_kind == ContentKind::DraftIssue {
5416 return Err(self.closes_a_draft(category));
5417 }
5418 self.set_item_field(
5419 board.id.as_str(),
5420 &item.item_id,
5421 &field,
5422 json!({"singleSelectOptionId": option}),
5423 )
5424 .await?;
5425 self.update_content(
5426 ContentKind::Issue,
5427 &item.id,
5428 json!({"stateInput": state_input(Some(&target))}),
5429 )
5430 .await?;
5431 item.closed = true;
5432 item.status = self
5433 .statuses
5434 .status(Some(&name), true, Some(reason.reason()));
5435 item.option = Some(name);
5436 }
5437 StatusTarget::Column(_) => {
5438 // An option is what an open item's status is, so a closed issue is reopened
5439 // first — sitting closed in the column, it would read back as closed. A draft has
5440 // no state to reopen.
5441 if item.content_kind == ContentKind::Issue && item.closed {
5442 self.update_content(
5443 ContentKind::Issue,
5444 &item.id,
5445 json!({"stateInput": state_input(Some(&target))}),
5446 )
5447 .await?;
5448 item.closed = false;
5449 }
5450 self.set_item_field(
5451 board.id.as_str(),
5452 &item.item_id,
5453 &field,
5454 json!({"singleSelectOptionId": option}),
5455 )
5456 .await?;
5457 item.status = self.statuses.status(Some(&name), false, None);
5458 item.option = Some(name);
5459 }
5460 StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5461 }
5462 let status = item.status.clone();
5463 self.remember_written(item, false)?;
5464 Ok(Some(status))
5465 }
5466
5467 /// Replace one task's `delivered_by` and nothing else; see
5468 /// [`TaskSource::set_delivered_by`].
5469 ///
5470 /// One update of the body, which differs from the body GitHub holds only inside the
5471 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5472 async fn replace_delivered_by(
5473 &self,
5474 id: &NativeId,
5475 delivered_by: &[TaskRef],
5476 ) -> Result<Option<()>, SourceError> {
5477 let entries = TaskRef::listed(
5478 TaskRef::DELIVERED_BY_KEY,
5479 id,
5480 Some(&self.name),
5481 delivered_by.to_vec(),
5482 )
5483 .map_err(|message| SourceError::Refused { message })?;
5484 let Some(mut item) = self
5485 .bound_item(id)
5486 .await?
5487 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5488 else {
5489 return Ok(None);
5490 };
5491 let mut slot = item.slot.clone();
5492 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5493 self.write_slot(&mut item, &slot).await?;
5494 item.delivered_by = entries;
5495 self.remember_written(item, false)?;
5496 Ok(Some(()))
5497 }
5498
5499 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5500 /// see [`TaskSource::set_task_metadata`].
5501 ///
5502 /// `None` when this board holds no item by that id, or holds one of another kind. The
5503 /// answer is the item as this source now reads it, so what a caller is told the key
5504 /// holds is what the slot holds.
5505 ///
5506 /// A key already holding the value is answered without a write, compared as JSON rather
5507 /// than as the body's bytes: a slot a person spelled with other whitespace would
5508 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5509 async fn set_slot_key(
5510 &self,
5511 id: &NativeId,
5512 kind: BoardKind,
5513 key: &MetadataKey,
5514 value: &Value,
5515 ) -> Result<Option<Resolved>, SourceError> {
5516 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5517 return Ok(None);
5518 };
5519 if item.slot.get(key.as_str()) == Some(value) {
5520 return Ok(Some(item));
5521 }
5522 let mut slot = item.slot.clone();
5523 slot.insert(key.as_str().to_owned(), value.clone());
5524 self.write_slot(&mut item, &slot).await?;
5525 self.remember_written(item.clone(), false)?;
5526 Ok(Some(item))
5527 }
5528
5529 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5530 /// `item` up to what that write left.
5531 ///
5532 /// The body sent differs from the body GitHub holds only inside the slot — see
5533 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5534 /// the mutation the item's content takes, so a board draft's body is written with
5535 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5536 async fn write_slot(
5537 &self,
5538 item: &mut Resolved,
5539 slot: &BTreeMap<String, Value>,
5540 ) -> Result<(), SourceError> {
5541 let held = item.raw_body.clone().unwrap_or_default();
5542 let body = with_slot(&held, slot)?;
5543 if body != held {
5544 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5545 .await?;
5546 }
5547 let (visible, slot) = metadata_body(Some(body.clone()))?;
5548 item.body = visible.filter(|value| !value.is_empty());
5549 item.raw_body = Some(body);
5550 item.slot = slot;
5551 Ok(())
5552 }
5553
5554 /// This instance's target for a category, refusing one it has disabled.
5555 ///
5556 /// Nothing here mutates the board's option set to make room for a status. GitHub
5557 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5558 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5559 /// field and every item's status.
5560 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5561 let target = self.statuses.target(category).clone();
5562 if target != StatusTarget::Disabled {
5563 return Ok(target);
5564 }
5565 Err(SourceError::Refused {
5566 message: if category == StatusCategory::Draft {
5567 format!(
5568 "status draft is disabled for source {}: draft is incompatible with this \
5569 integration because GitHub draft issues cannot have sub-issues, and this \
5570 source stores a project's tasks as its issue's sub-issues",
5571 self.name
5572 )
5573 } else if category == StatusCategory::Unknown {
5574 format!(
5575 "status {} is disabled for source {}; set status_mapping.{} of this source \
5576 to one board Status option name; every word classified unknown is written \
5577 to that one option",
5578 category_name(category),
5579 self.name,
5580 category_name(category)
5581 )
5582 } else {
5583 format!(
5584 "status {} is disabled for source {}; set status_mapping.{} of this source \
5585 to a board Status option name",
5586 category_name(category),
5587 self.name,
5588 category_name(category)
5589 )
5590 },
5591 })
5592 }
5593
5594 /// What writing `priority` does to one item's `Priority` field on this board, or the
5595 /// refusal naming what the board lacks.
5596 ///
5597 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5598 /// none already, or of an item not created yet. Every other priority selects the option
5599 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5600 /// without that option, is refused rather than given one: reads and writes never create
5601 /// a field or an option.
5602 fn priority_write(
5603 &self,
5604 fields: &Value,
5605 existing: Option<&Resolved>,
5606 priority: Priority,
5607 ) -> Result<Option<PriorityWrite>, SourceError> {
5608 let Some(mapping) = &self.priorities else {
5609 return Err(self.holds_no_priority());
5610 };
5611 let Some(wanted) = mapping.option(priority) else {
5612 if !existing.is_some_and(Resolved::holds_priority) {
5613 return Ok(None);
5614 }
5615 let field =
5616 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5617 message: format!(
5618 "an item holding a {PRIORITY_FIELD} value was read without that field"
5619 ),
5620 })?;
5621 return Ok(Some(PriorityWrite::Clear {
5622 field: required_str(field, "id")?.to_owned(),
5623 }));
5624 };
5625 let missing = |detail: &str| SourceError::Refused {
5626 message: format!(
5627 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5628 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5629 it, or point priority_mapping.{priority} of this source at an option the board \
5630 has",
5631 self.name, self.name
5632 ),
5633 };
5634 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5635 return Err(missing(&format!(
5636 "this board has no {PRIORITY_FIELD} field"
5637 )));
5638 };
5639 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5640 return Err(missing(&format!(
5641 "this board's {PRIORITY_FIELD} field is not a single-select field"
5642 )));
5643 }
5644 // An options list that is absent or not a list is an answer this source cannot read,
5645 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5646 let option = field
5647 .get("options")
5648 .and_then(Value::as_array)
5649 .ok_or_else(|| SourceError::Malformed {
5650 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5651 })?
5652 .iter()
5653 .find(|option| {
5654 option
5655 .get("name")
5656 .and_then(Value::as_str)
5657 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5658 })
5659 .ok_or_else(|| missing("this board does not have it"))?;
5660 Ok(Some(PriorityWrite::Select {
5661 field: required_str(field, "id")?.to_owned(),
5662 option: required_str(option, "id")?.to_owned(),
5663 }))
5664 }
5665
5666 /// Apply one priority write to one board item.
5667 async fn write_priority(
5668 &self,
5669 board_id: &str,
5670 item_id: &str,
5671 write: &PriorityWrite,
5672 ) -> Result<(), SourceError> {
5673 match write {
5674 PriorityWrite::Select { field, option } => {
5675 self.set_item_field(
5676 board_id,
5677 item_id,
5678 field,
5679 json!({"singleSelectOptionId": option}),
5680 )
5681 .await
5682 }
5683 PriorityWrite::Clear { field } => {
5684 let data = self
5685 .graphql(
5686 graphql::CLEAR_FIELD,
5687 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5688 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5689 )
5690 .await?;
5691 let returned = data
5692 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5693 .ok_or_else(|| SourceError::Malformed {
5694 message: "GitHub field clear returned no project item".into(),
5695 })?;
5696 if required_str(returned, "id")? != item_id {
5697 return Err(SourceError::Malformed {
5698 message: "GitHub field clear returned the wrong project item".into(),
5699 });
5700 }
5701 Ok(())
5702 }
5703 }
5704 }
5705
5706 /// The refusal a priority is answered with by an instance configured with no
5707 /// `priority_mapping`, which holds none.
5708 fn holds_no_priority(&self) -> SourceError {
5709 SourceError::Refused {
5710 message: format!(
5711 "source {} holds no task priority: its configuration sets no priority_mapping; \
5712 next: set priority_mapping on this source, then run `onetaskgraph sources \
5713 fields {} --apply` to set its board up",
5714 self.name, self.name
5715 ),
5716 }
5717 }
5718
5719 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5720 ///
5721 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5722 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5723 async fn set_priority(
5724 &self,
5725 id: &NativeId,
5726 priority: Priority,
5727 ) -> Result<Option<Priority>, SourceError> {
5728 if self.priorities.is_none() {
5729 return Err(self.holds_no_priority());
5730 }
5731 let Some(mut item) = self
5732 .bound_item(id)
5733 .await?
5734 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5735 else {
5736 return Ok(None);
5737 };
5738 if priority == Priority::None && !item.holds_priority() {
5739 return Ok(Some(priority));
5740 }
5741 // The item's own read carries the field's definition whenever it holds a value of
5742 // it, which a clear always does; a select onto an item holding none reads the board.
5743 let board = match (item.carried_board(), item.named_board()) {
5744 (Some(board), _) => board,
5745 (None, Some(id)) if item.defines(PRIORITY_FIELD) => BoardFields {
5746 id,
5747 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5748 },
5749 _ => self.board_fields().await?,
5750 };
5751 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5752 return Ok(Some(priority));
5753 };
5754 let (document, root, input) = match write {
5755 PriorityWrite::Select { field, option } => (
5756 graphql::UPDATE_FIELD,
5757 "updateProjectV2ItemFieldValue",
5758 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5759 ),
5760 PriorityWrite::Clear { field } => (
5761 graphql::CLEAR_FIELD,
5762 "clearProjectV2ItemFieldValue",
5763 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5764 ),
5765 };
5766 let data = self
5767 .graphql(
5768 document,
5769 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5770 )
5771 .await?;
5772 let returned = data
5773 .get(root)
5774 .and_then(|value| value.get("projectV2Item"))
5775 .ok_or_else(|| SourceError::Malformed {
5776 message: "GitHub priority write returned no project item".into(),
5777 })?;
5778 if required_str(returned, "id")? != item.item_id {
5779 return Err(SourceError::Malformed {
5780 message: "GitHub priority write returned the wrong project item".into(),
5781 });
5782 }
5783 let value = returned
5784 .get("fieldValueByName")
5785 .ok_or_else(|| SourceError::Malformed {
5786 message: "GitHub priority write returned no priority read-back".into(),
5787 })?;
5788 if !value.is_null()
5789 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5790 {
5791 return Err(SourceError::Malformed {
5792 message: "GitHub priority read-back is not a Priority field value".into(),
5793 });
5794 }
5795 let values = if value.is_null() {
5796 Vec::new()
5797 } else {
5798 vec![value.clone()]
5799 };
5800 item.priority = self.held_priority(&values)?;
5801 let answer = item.task()?.priority;
5802 self.remember_written(item, false)?;
5803 Ok(Some(answer))
5804 }
5805
5806 /// Replace one task's visible body and nothing else; see
5807 /// [`TaskSource::set_task_content`].
5808 ///
5809 /// One update of the body, which differs from the body GitHub holds only outside the
5810 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5811 /// this source keeps there reads back as it was. A body that would not change is not
5812 /// sent at all.
5813 async fn replace_content(
5814 &self,
5815 id: &NativeId,
5816 content: &str,
5817 ) -> Result<Option<()>, SourceError> {
5818 let Some(mut item) = self
5819 .bound_item(id)
5820 .await?
5821 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5822 else {
5823 return Ok(None);
5824 };
5825 let held = item.raw_body.clone().unwrap_or_default();
5826 let body = with_content(&held, content)?;
5827 // Checked before anything is sent: content ending in what this source reads as its own
5828 // metadata slot would read back as metadata rather than as the content it was.
5829 let (visible, slot) = metadata_body(Some(body.clone()))?;
5830 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5831 return Err(SourceError::Refused {
5832 message: format!(
5833 "this content ends in what source {} reads as its own metadata slot \
5834 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5835 as content; next: remove that trailing block from the content",
5836 self.name
5837 ),
5838 });
5839 }
5840 if body != held {
5841 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5842 .await?;
5843 }
5844 item.body = visible.filter(|value| !value.is_empty());
5845 item.raw_body = Some(body);
5846 item.slot = slot;
5847 self.remember_written(item, false)?;
5848 Ok(Some(()))
5849 }
5850
5851 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5852 ///
5853 /// One read of the item — which carries the board's field definitions and the issue's
5854 /// `blockedBy`, so neither is read again — and then only what differs from it: the
5855 /// `Status` option and the `Priority` field together in one request, the `blockedBy`
5856 /// additions and removals the named edges differ by, and last one `updateIssue` carrying
5857 /// the title, the body — visible content and metadata slot together — and a state change.
5858 /// So an update naming any of title, body, metadata, status and priority is one read and
5859 /// at most two writes. The body goes last so that a write refused part-way leaves it, and
5860 /// the metadata in it, as it stood. A terminal status selects its option and then closes,
5861 /// as a whole write does; an open one selects its option and then reopens. The origin
5862 /// field is never written: an update is of an item that already exists, whose origin is
5863 /// what it is.
5864 ///
5865 /// The task answered is the item as those writes left it, built from the read and what was
5866 /// sent rather than read again — the same record a later read in this run answers from.
5867 async fn targeted_update(
5868 &self,
5869 id: &NativeId,
5870 update: &TaskUpdate,
5871 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5872 // Everything this source can refuse without reading the item is refused first, in the
5873 // words a whole write of the same fields is refused with.
5874 update.consistent()?;
5875 if update
5876 .title
5877 .as_deref()
5878 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5879 {
5880 return Err(SourceError::Refused {
5881 message: format!(
5882 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5883 spells a document, so it would read back as one rather than as a task; \
5884 retitle it",
5885 self.name
5886 ),
5887 });
5888 }
5889 if let Some(delivers) = &update.delivers {
5890 TaskRef::listed(
5891 TaskRef::DELIVERS_KEY,
5892 id,
5893 Some(&self.name),
5894 delivers.clone(),
5895 )
5896 .map_err(|message| SourceError::Refused { message })?;
5897 }
5898 if self.priorities.is_none()
5899 && update
5900 .priority
5901 .is_some_and(|priority| priority != Priority::None)
5902 {
5903 return Err(self.holds_no_priority());
5904 }
5905 let target = update
5906 .status
5907 .as_ref()
5908 .map(|status| self.resolved_target(status.category))
5909 .transpose()?;
5910 let Some(mut item) = self
5911 .bound_item(id)
5912 .await?
5913 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5914 else {
5915 return Ok(None);
5916 };
5917 let before = item.task()?;
5918
5919 let mut status_move = None;
5920 if let (Some(status), Some(target)) = (&update.status, target) {
5921 let board = self.status_board(&item).await?;
5922 let (field, option, name) = self
5923 .column_for(&board.fields, status, &target)?
5924 .ok_or_else(|| SourceError::Malformed {
5925 message: format!(
5926 "status {} of source {} names no board Status option",
5927 category_name(status.category),
5928 self.name
5929 ),
5930 })?;
5931 let terminal = matches!(target, StatusTarget::Terminal(_, _));
5932 if terminal && item.content_kind == ContentKind::DraftIssue {
5933 return Err(self.closes_a_draft(status.category));
5934 }
5935 let landed = match &target {
5936 StatusTarget::Terminal(_, reason) => {
5937 self.statuses
5938 .status(Some(&name), true, Some(reason.reason()))
5939 }
5940 _ => self.statuses.status(Some(&name), false, None),
5941 };
5942 let option_moves = item
5943 .option
5944 .as_deref()
5945 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5946 let state_moves = item.content_kind == ContentKind::Issue
5947 && (item.closed != terminal || (terminal && item.status != landed));
5948 if let Some(moves) = Moves::of(option_moves, state_moves) {
5949 status_move = Some(StatusMove {
5950 board: board.id,
5951 field,
5952 option,
5953 name,
5954 target,
5955 landed,
5956 moves,
5957 });
5958 }
5959 }
5960
5961 let mut priority_move = None;
5962 if let Some(priority) = update.priority
5963 && self.priorities.is_some()
5964 && item.priority != HeldPriority::Read(priority)
5965 {
5966 let board = match (item.carried_board(), item.named_board()) {
5967 (Some(board), _) => board,
5968 (None, Some(board)) if item.defines(PRIORITY_FIELD) => BoardFields {
5969 id: board,
5970 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5971 },
5972 _ => self.board_fields().await?,
5973 };
5974 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5975 priority_move = Some((board.id, write, priority));
5976 }
5977 }
5978
5979 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5980 // recorded in the slot, and the slot travels in the one body update below.
5981 let edges = match &update.depends_on {
5982 Some(edges) => Some(
5983 self.partition_edges(
5984 BoardKind::Work(ItemKind::Task),
5985 item.content_kind,
5986 item.blocked_by.as_deref(),
5987 edges,
5988 )
5989 .await?,
5990 ),
5991 None => None,
5992 };
5993
5994 let mut slot = item.slot.clone();
5995 for (key, value) in &update.metadata_set {
5996 slot.insert(key.as_str().to_owned(), value.clone());
5997 }
5998 for key in &update.metadata_remove {
5999 slot.remove(key.as_str());
6000 }
6001 if let Some(delivers) = &update.delivers {
6002 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
6003 }
6004 if let Some((_, recorded)) = &edges {
6005 record_edges(&mut slot, recorded);
6006 }
6007 let held = item.raw_body.clone().unwrap_or_default();
6008 let content = match &update.content {
6009 Some(content) => with_content(&held, content)?,
6010 None => held.clone(),
6011 };
6012 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
6013 // the body's bytes, as a metadata write compares it: a slot a person spelled with
6014 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
6015 let body = if slot == item.slot {
6016 content
6017 } else {
6018 with_slot(&content, &slot)?
6019 };
6020 // Checked before anything is sent, as a content write checks it: content ending in
6021 // what this source reads as its own slot would read back as metadata.
6022 let (visible, read) = metadata_body(Some(body.clone()))?;
6023 let wanted = update.content.as_deref().or(item.body.as_deref());
6024 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
6025 return Err(SourceError::Refused {
6026 message: format!(
6027 "this content ends in what source {} reads as its own metadata slot \
6028 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6029 as content; next: remove that trailing block from the content",
6030 self.name
6031 ),
6032 });
6033 }
6034 let recorded_moves =
6035 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
6036
6037 // One `updateIssue` carries all three, because every mutation spends the secondary
6038 // limiter and the title, body and state are one mutation's inputs.
6039 let mut fields = serde_json::Map::new();
6040 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
6041 fields.insert("title".to_owned(), json!(title));
6042 }
6043 if body != held {
6044 fields.insert("body".to_owned(), json!(body));
6045 }
6046 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
6047 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
6048 }
6049 // **The body is written last, and that is the guarantee a refusal part-way keeps.**
6050 // GitHub runs no two requests as one, and runs one document's mutation fields in order
6051 // without undoing an earlier field when a later one fails — so a body written before a
6052 // board field the board then refused would be left changed. Written after every other
6053 // write has landed, a refusal anywhere leaves the item's body, and every metadata key
6054 // it carries, exactly as they stood. So the `Status` option and the `Priority` field go
6055 // first, together in one request — a terminal option selected before the issue
6056 // closes, as a whole write does — then the `blockedBy` difference, then the body.
6057 let mut board_writes: Vec<(&BoardId, (String, Value))> = Vec::new();
6058 let mut clear: Option<(&BoardId, &str)> = None;
6059 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.option()) {
6060 board_writes.push((
6061 &moving.board,
6062 (
6063 moving.field.clone(),
6064 json!({"singleSelectOptionId": moving.option}),
6065 ),
6066 ));
6067 }
6068 match &priority_move {
6069 Some((board, PriorityWrite::Select { field, option }, _)) => board_writes.push((
6070 board,
6071 (field.clone(), json!({"singleSelectOptionId": option})),
6072 )),
6073 Some((board, PriorityWrite::Clear { field }, _)) => clear = Some((board, field)),
6074 None => {}
6075 }
6076 let mut boards: Vec<&BoardId> = board_writes.iter().map(|(board, _)| *board).collect();
6077 boards.extend(clear.map(|(board, _)| board));
6078 boards.dedup_by(|one, other| one.as_str() == other.as_str());
6079 for board in boards {
6080 let writes = board_writes
6081 .iter()
6082 .filter(|(on, _)| on.as_str() == board.as_str())
6083 .map(|(_, write)| write.clone())
6084 .collect::<Vec<_>>();
6085 let cleared = clear
6086 .filter(|(on, _)| on.as_str() == board.as_str())
6087 .map(|(_, field)| field);
6088 self.set_item_fields(board.as_str(), &item.item_id, &writes, cleared)
6089 .await?;
6090 }
6091 let mut blocked_by_moved = false;
6092 if let Some((native, _)) = &edges
6093 && item.content_kind == ContentKind::Issue
6094 {
6095 blocked_by_moved = self
6096 .reconcile_blocked_by(
6097 &item.id,
6098 native,
6099 Issue::Existing(item.blocked_by.as_deref()),
6100 )
6101 .await?;
6102 }
6103 if !fields.is_empty() {
6104 self.update_content(item.content_kind, &item.id, Value::Object(fields))
6105 .await?;
6106 }
6107
6108 if let Some(title) = &update.title {
6109 item.title.clone_from(title);
6110 }
6111 item.body = visible.filter(|value| !value.is_empty());
6112 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
6113 item.slot = slot;
6114 if let Some(delivers) = &update.delivers {
6115 item.delivers.clone_from(delivers);
6116 }
6117 if let Some(moving) = status_move {
6118 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
6119 && item.content_kind == ContentKind::Issue;
6120 item.status = moving.landed;
6121 item.option = Some(moving.name);
6122 }
6123 if let Some((_, _, priority)) = priority_move {
6124 item.priority = HeldPriority::Read(priority);
6125 }
6126 let task = item.task()?;
6127 let mut written = update.changed(&before, &task);
6128 if blocked_by_moved || recorded_moves {
6129 written.insert(UpdatedField::DependsOn);
6130 }
6131 self.remember_written(item, false)?;
6132 Ok(Some(TaskUpdateOutcome {
6133 task,
6134 written,
6135 delivers_before: before.delivers,
6136 }))
6137 }
6138
6139 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
6140 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
6141 ///
6142 /// One update of the body: the content outside the slot, and inside it that one entry,
6143 /// every other entry kept as it was. This source keeps no template answers — an issue has
6144 /// no room beside itself that is not its body, and answers written there would duplicate
6145 /// what the content already says and count against GitHub's body limit — so `answers`
6146 /// reaches nothing here. A body that would not change is not sent at all.
6147 async fn replace_rendering(
6148 &self,
6149 id: &NativeId,
6150 kind: BoardKind,
6151 content: &str,
6152 provenance: &Value,
6153 ) -> Result<Option<()>, SourceError> {
6154 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
6155 return Ok(None);
6156 };
6157 let held = item.raw_body.clone().unwrap_or_default();
6158 let mut slot = item.slot.clone();
6159 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
6160 let body = with_slot(&with_content(&held, content)?, &slot)?;
6161 // Checked before anything is sent, as a content write checks it.
6162 let (visible, read) = metadata_body(Some(body.clone()))?;
6163 if visible.as_deref().unwrap_or_default() != content || read != slot {
6164 return Err(SourceError::Refused {
6165 message: format!(
6166 "this content ends in what source {} reads as its own metadata slot \
6167 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
6168 as content; next: remove that trailing block from the template",
6169 self.name
6170 ),
6171 });
6172 }
6173 if body != held {
6174 self.update_content(item.content_kind, &item.id, json!({"body": body}))
6175 .await?;
6176 }
6177 item.body = visible.filter(|value| !value.is_empty());
6178 item.raw_body = Some(body);
6179 item.slot = read;
6180 self.remember_written(item, false)?;
6181 Ok(Some(()))
6182 }
6183
6184 async fn set_item_field(
6185 &self,
6186 board_id: &str,
6187 item_id: &str,
6188 field_id: &str,
6189 value: Value,
6190 ) -> Result<(), SourceError> {
6191 let data = self
6192 .graphql(
6193 graphql::UPDATE_FIELD,
6194 json!({"input":{
6195 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
6196 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
6197 )
6198 .await?;
6199 let returned = data
6200 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
6201 .ok_or_else(|| SourceError::Malformed {
6202 message: "GitHub field update returned no project item".into(),
6203 })?;
6204 if required_str(returned, "id")? != item_id {
6205 return Err(SourceError::Malformed {
6206 message: "GitHub field update returned the wrong project item".into(),
6207 });
6208 }
6209 Ok(())
6210 }
6211
6212 /// GitHub accepts one value per field mutation; aliases combine those mutations in
6213 /// one request. Every returned item id is checked, including optional aliases.
6214 async fn set_item_fields(
6215 &self,
6216 board: &str,
6217 item: &str,
6218 fields: &[(String, Value)],
6219 clear: Option<&str>,
6220 ) -> Result<(), SourceError> {
6221 if fields.len() <= 1 && clear.is_none() {
6222 if let Some((field, value)) = fields.first() {
6223 self.set_item_field(board, item, field, value.clone())
6224 .await?;
6225 }
6226 return Ok(());
6227 }
6228 if fields.is_empty() {
6229 if let Some(field) = clear {
6230 self.write_priority(
6231 board,
6232 item,
6233 &PriorityWrite::Clear {
6234 field: field.to_owned(),
6235 },
6236 )
6237 .await?;
6238 }
6239 return Ok(());
6240 }
6241 let input = |index: usize| {
6242 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
6243 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
6244 };
6245 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
6246 "input":input(0),"second":input(1),"third":input(2),
6247 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
6248 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
6249 })).await?;
6250 for alias in [
6251 Some("updateProjectV2ItemFieldValue"),
6252 (fields.len() > 1).then_some("second"),
6253 (fields.len() > 2).then_some("third"),
6254 clear.map(|_| "cleared"),
6255 ]
6256 .into_iter()
6257 .flatten()
6258 {
6259 let returned = data
6260 .get(alias)
6261 .and_then(|value| value.get("projectV2Item"))
6262 .ok_or_else(|| SourceError::Malformed {
6263 message: format!("GitHub field update {alias} returned no project item"),
6264 })?;
6265 if required_str(returned, "id")? != item {
6266 return Err(SourceError::Malformed {
6267 message: format!("GitHub field update {alias} returned the wrong project item"),
6268 });
6269 }
6270 }
6271 Ok(())
6272 }
6273
6274 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
6275 let mut after: Option<String> = None;
6276 let mut ids = Vec::new();
6277 loop {
6278 let data = self
6279 .graphql(
6280 graphql::ISSUE_DEPENDENCIES,
6281 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
6282 )
6283 .await?;
6284 let connection =
6285 data.pointer("/node/blockedBy")
6286 .ok_or_else(|| SourceError::Malformed {
6287 message: "GitHub dependency response has no blockedBy connection".into(),
6288 })?;
6289 ids.extend(
6290 connection
6291 .get("nodes")
6292 .and_then(Value::as_array)
6293 .ok_or_else(|| SourceError::Malformed {
6294 message: "GitHub dependency response nodes is not an array".into(),
6295 })?
6296 .iter()
6297 .map(|value| required_str(value, "id").map(str::to_owned))
6298 .collect::<Result<Vec<_>, _>>()?,
6299 );
6300 let next = next_cursor(connection)?;
6301 if let Some(next) = &next {
6302 validate_cursor_progress(after.as_deref(), &next.0)?;
6303 }
6304 after = next.map(|cursor| cursor.0);
6305 if after.is_none() {
6306 return Ok(ids);
6307 }
6308 }
6309 }
6310
6311 async fn dependencies(
6312 &self,
6313 id: &NativeId,
6314 near_kind: ItemKind,
6315 direction: Direction,
6316 page: &PageRequest,
6317 ) -> Result<Page<DependencyEdge>, SourceError> {
6318 validate_page(page)?;
6319 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
6320 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
6321 let recorded = recorded_offset(cursor, direction)?;
6322 // What this issue is blocked by, when a read of it by its own id in this command
6323 // already carried the whole connection — a copy reads the item it writes before it
6324 // reads its edges — and the page asked for is the whole of it, or the recorded tail
6325 // after it. Answered from that read, in the shape the dependency read answers in;
6326 // anything else is asked of GitHub.
6327 let carried = match direction {
6328 Direction::DependsOn => self
6329 .resolved_cache()?
6330 .get(id)
6331 .filter(|item| item.content_kind == ContentKind::Issue)
6332 .and_then(|item| Some((item.blocked_by.clone()?, item.raw_body.clone()))),
6333 Direction::DependedOnBy => None,
6334 }
6335 .filter(|(nodes, _)| recorded.is_some() || (cursor.is_none() && nodes.len() <= limit));
6336 // Asked for even in the recorded phase, whose page reads nothing from the
6337 // connection: `__typename` is what says whether this item has a native
6338 // relationship at all, and that is what decides which far ends the reserved key is
6339 // allowed to hold.
6340 let data = match carried {
6341 Some((nodes, body)) => json!({"node":{"__typename":"Issue","body":body,
6342 "blockedBy":{"nodes":nodes,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}),
6343 None => {
6344 self.graphql(
6345 graphql::ISSUE_DEPENDENCIES,
6346 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
6347 "after":if recorded.is_some() {None} else {cursor}}),
6348 )
6349 .await?
6350 }
6351 };
6352 let node =
6353 data.get("node")
6354 .filter(|v| !v.is_null())
6355 .ok_or_else(|| SourceError::Refused {
6356 message: format!(
6357 "GitHub item {} was not found or does not support dependencies",
6358 id.0
6359 ),
6360 })?;
6361 let connection_name = match direction {
6362 Direction::DependsOn => "blockedBy",
6363 Direction::DependedOnBy => "blocking",
6364 };
6365 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
6366 // named natively and the reserved key may hold any far end. An issue's connections
6367 // hold issues, and this source reads them at the near item's own level.
6368 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
6369 if let Some(offset) = recorded {
6370 return Ok(recorded_page(
6371 self.recorded_edges(id, near_kind, direction, natively_names, node)
6372 .await?,
6373 offset,
6374 limit,
6375 ));
6376 }
6377 if natively_names.is_none() {
6378 return Ok(recorded_page(
6379 self.recorded_edges(id, near_kind, direction, natively_names, node)
6380 .await?,
6381 0,
6382 limit,
6383 ));
6384 }
6385 let connection = node
6386 .get(connection_name)
6387 .ok_or_else(|| SourceError::Malformed {
6388 message: "GitHub dependency response is missing its connection".into(),
6389 })?;
6390 let nodes = connection
6391 .get("nodes")
6392 .and_then(Value::as_array)
6393 .ok_or_else(|| SourceError::Malformed {
6394 message: "GitHub dependency response nodes is not an array".into(),
6395 })?;
6396 // `from` depends on `to`, always. GitHub spells the same relationship from either
6397 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
6398 // it — so the near item is `from` in one direction and `to` in the other.
6399 let items = nodes
6400 .iter()
6401 .map(|value| {
6402 let related = NativeId(required_str(value, "id")?.into());
6403 let related_kind = related_kind(value)?;
6404 let (from, to) = match direction {
6405 Direction::DependsOn => (
6406 DependencyEndpoint::from_native(id.clone(), near_kind),
6407 DependencyEndpoint::from_native(related, related_kind),
6408 ),
6409 Direction::DependedOnBy => (
6410 DependencyEndpoint::from_native(related, related_kind),
6411 DependencyEndpoint::from_native(id.clone(), near_kind),
6412 ),
6413 };
6414 Ok(DependencyEdge {
6415 from,
6416 to,
6417 kind: DependencyKind::Blocks,
6418 })
6419 })
6420 .collect::<Result<Vec<_>, SourceError>>()?;
6421 let mut next = next_cursor(connection)?;
6422 if let Some(next) = &next {
6423 validate_cursor_progress(cursor, &next.0)?;
6424 }
6425 if next.is_none()
6426 && !self
6427 .recorded_edges(id, near_kind, direction, natively_names, node)
6428 .await?
6429 .is_empty()
6430 {
6431 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6432 }
6433 Ok(Page { items, next })
6434 }
6435
6436 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6437 /// a far end in another source has to live: no GitHub issue relationship can name one.
6438 ///
6439 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6440 /// source never writes one down.
6441 ///
6442 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6443 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6444 /// request beyond the read already made, and reading the board for them would be a
6445 /// walk of every item for one field of one. A draft has no body in that answer, because
6446 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6447 /// listing of the board, which can be behind on the very item asked about.
6448 async fn recorded_edges(
6449 &self,
6450 id: &NativeId,
6451 near_kind: ItemKind,
6452 direction: Direction,
6453 natively_names: Option<ItemKind>,
6454 node: &Value,
6455 ) -> Result<Vec<DependencyEdge>, SourceError> {
6456 if direction != Direction::DependsOn {
6457 return Ok(Vec::new());
6458 }
6459 let slot = match node.get("body") {
6460 Some(body) if natively_names.is_some() => {
6461 metadata_body(body.as_str().map(str::to_owned))?.1
6462 }
6463 _ => {
6464 let Some(item) = self.bound_item(id).await? else {
6465 return Ok(Vec::new());
6466 };
6467 item.slot
6468 }
6469 };
6470 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6471 .map_err(|message| SourceError::Malformed { message })
6472 }
6473
6474 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6475 self.repository
6476 .as_ref()
6477 .ok_or_else(|| SourceError::Refused {
6478 message: format!(
6479 "source {} has no repository configured, and a GitHub Projects board has no \
6480 repository of its own to create an issue in; set repository: owner/name on \
6481 this source",
6482 self.name
6483 ),
6484 })
6485 }
6486
6487 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6488 /// states.
6489 ///
6490 /// The fallback is demanded first, whichever arm answers: a write without a configured
6491 /// repository is refused naming the field exactly as it was before the rule existed,
6492 /// so a source that could not write before cannot write now, rather than writing for
6493 /// the one item whose own field happens to decide it.
6494 ///
6495 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6496 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6497 /// entry owned by someone other than the owner of the parent issue's repository —
6498 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6499 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6500 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6501 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6502 /// and is visible to the token is checked where its node id is resolved, still before
6503 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6504 /// looked up in a listing of the board, which can be minutes behind an issue its own
6505 /// `projectItems` already places on it — and that read answers first from this process's
6506 /// own record, so a project created moments ago in this command answers though GitHub
6507 /// has not caught up.
6508 async fn creation_target(
6509 &self,
6510 incoming: &Incoming<'_>,
6511 ) -> Result<RepositoryTarget, SourceError> {
6512 let fallback = self.configured_repository()?;
6513 let what = |incoming: &Incoming<'_>| {
6514 format!(
6515 "{} {:?}",
6516 incoming.written.kind().describes(),
6517 incoming.title
6518 )
6519 };
6520 let parent = match incoming.parent {
6521 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6522 SourceError::Refused {
6523 message: format!(
6524 "GitHub project issue {} was not found on the board of source {}, so {} \
6525 cannot be filed under it",
6526 parent.0,
6527 self.name,
6528 what(incoming)
6529 ),
6530 }
6531 })?),
6532 None => None,
6533 };
6534 let parents_repository = parent
6535 .as_ref()
6536 .map(|parent| {
6537 // A draft is on the board and so is found, but it has no repository to
6538 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6539 // would refuse the task only once `createIssue` had made it.
6540 if parent.content_kind == ContentKind::DraftIssue {
6541 return Err(SourceError::Refused {
6542 message: format!(
6543 "GitHub project item {} on the board of source {} is a draft, \
6544 which cannot have sub-issues, so {} cannot be filed under it",
6545 parent.id.0,
6546 self.name,
6547 what(incoming)
6548 ),
6549 });
6550 }
6551 // An issue's repository is where a sub-issue is placed and whose owner it
6552 // is compared against, so a parent whose repository this source cannot
6553 // spell as `owner/name` — GitHub's login grammar is wider than this
6554 // source's floor — is one nothing can be filed under.
6555 parent
6556 .own_repository
6557 .as_ref()
6558 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6559 .ok_or_else(|| SourceError::Malformed {
6560 message: format!(
6561 "GitHub project issue {} on the board of source {} is in {}, which \
6562 is not a {}/owner/name repository this source can place {} in",
6563 parent.id.0,
6564 self.name,
6565 parent
6566 .own_repository
6567 .as_ref()
6568 .map_or("no repository", Repository::as_str),
6569 RepositoryTarget::HOST,
6570 what(incoming)
6571 ),
6572 })
6573 })
6574 .transpose()?;
6575 match incoming.repositories {
6576 [named] => {
6577 let target =
6578 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6579 message: format!(
6580 "{} names repository {}, which is not a {}/owner/name repository \
6581 source {} can create an issue in; name one that is, or name none",
6582 what(incoming),
6583 named.as_str(),
6584 RepositoryTarget::HOST,
6585 self.name
6586 ),
6587 })?;
6588 if let Some(parents) = &parents_repository
6589 && parents.owner != target.owner
6590 {
6591 return Err(SourceError::Refused {
6592 message: format!(
6593 "{} names repository {}, owned by {}, but its project's issue is in \
6594 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6595 of the same owner as its parent issue; name a repository of {}, or \
6596 name none",
6597 what(incoming),
6598 target.slug(),
6599 target.owner,
6600 parents.slug(),
6601 parents.owner,
6602 parents.owner
6603 ),
6604 });
6605 }
6606 Ok(target)
6607 }
6608 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6609 }
6610 }
6611
6612 /// The node id of the repository `incoming` is being created in, or the refusal naming
6613 /// the item and the repository the token cannot see.
6614 ///
6615 /// Resolved once per command per repository; see [`Self::repository_cache`].
6616 async fn repository_id(
6617 &self,
6618 repository: &RepositoryTarget,
6619 incoming: &Incoming<'_>,
6620 ) -> Result<String, SourceError> {
6621 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6622 return Ok(id);
6623 }
6624 let data = self
6625 .graphql(
6626 graphql::REPOSITORY,
6627 json!({"owner":repository.owner,"name":repository.name}),
6628 )
6629 .await?;
6630 self.repository_read(&data, repository, incoming)
6631 }
6632
6633 /// The repository's node id out of an answer carrying the `repository` root, held for
6634 /// the rest of this command, or the refusal naming the item that cannot be created in it.
6635 fn repository_read(
6636 &self,
6637 data: &Value,
6638 repository: &RepositoryTarget,
6639 incoming: &Incoming<'_>,
6640 ) -> Result<String, SourceError> {
6641 let node = data
6642 .get("repository")
6643 .filter(|value| !value.is_null())
6644 .ok_or_else(|| SourceError::Refused {
6645 message: format!(
6646 "GitHub repository {} was not found or is not visible to the token, so {} \
6647 {:?} cannot be created in it",
6648 repository.slug(),
6649 incoming.written.kind().describes(),
6650 incoming.title
6651 ),
6652 })?;
6653 let id = required_str(node, "id")?.to_owned();
6654 self.repository_cache()?
6655 .insert(repository.clone(), id.clone());
6656 Ok(id)
6657 }
6658
6659 fn repository_cache(
6660 &self,
6661 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6662 self.repository_cache
6663 .lock()
6664 .map_err(|_| SourceError::Unavailable {
6665 message: "this source's record of the destination repository was left \
6666 inconsistent by an earlier failure; next: run the command again"
6667 .into(),
6668 })
6669 }
6670
6671 /// Create or update one board item, whichever kind it is.
6672 async fn write_item(
6673 &self,
6674 incoming: &Incoming<'_>,
6675 target: Option<&NativeId>,
6676 depends_on: &[DependencyEdge],
6677 ) -> Result<NativeId, SourceError> {
6678 // Refused before anything is read or written: a task or a project titled the way
6679 // this board spells a document would land as an issue this same source reads back
6680 // as a document, so the field this destination cannot carry is named rather than
6681 // written and silently reclassified.
6682 if let Written::Work(kind, _) = incoming.written
6683 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6684 {
6685 return Err(SourceError::Refused {
6686 message: format!(
6687 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6688 spells a document, so it would read back as one rather than as a {}; \
6689 retitle it, or copy it as a document",
6690 kind.marker(),
6691 self.name,
6692 kind.marker()
6693 ),
6694 });
6695 }
6696 // The destination is read by its own id, and whether this board holds it is decided
6697 // by that read — its own `projectItems` — rather than by whether a listing of the
6698 // board happens to include it yet. See the module documentation.
6699 let existing = match target {
6700 Some(target) => {
6701 Some(
6702 self.bound_item(target)
6703 .await?
6704 .ok_or_else(|| SourceError::Refused {
6705 message: format!("GitHub destination item {} was not found", target.0),
6706 })?,
6707 )
6708 }
6709 None => None,
6710 };
6711 let existing = existing.as_ref();
6712 // An existing issue is never moved; a new one is created where the rule says — and
6713 // knowing where is what lets the board's fields and that repository's id be read
6714 // together, before anything below needs either.
6715 let creation_target = match existing {
6716 Some(_) => None,
6717 None => {
6718 let target = self.creation_target(incoming).await?;
6719 self.creation_context(&target, incoming).await?;
6720 Some(target)
6721 }
6722 };
6723 let board = self
6724 .fields_for(
6725 existing,
6726 incoming.written.status().is_some(),
6727 incoming
6728 .priority
6729 .is_some_and(|priority| priority != Priority::None),
6730 )
6731 .await?;
6732 let status_target = incoming
6733 .written
6734 .status()
6735 .map(|status| self.resolved_target(status.category))
6736 .transpose()?;
6737 let column = match (incoming.written.status(), status_target.as_ref()) {
6738 (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6739 _ => None,
6740 };
6741 // Resolved before anything is created, for the reason the column above is: a
6742 // priority this board has no option for is refused while nothing has been written.
6743 let priority_write = match incoming.priority {
6744 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6745 None => None,
6746 };
6747 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6748 if content_kind == ContentKind::DraftIssue {
6749 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6750 (status_target.as_ref(), incoming.written.status())
6751 {
6752 return Err(self.closes_a_draft(status.category));
6753 }
6754 if incoming.parent.is_some() {
6755 return Err(SourceError::Refused {
6756 message: "GitHub draft items cannot be a project's sub-issue".into(),
6757 });
6758 }
6759 }
6760 match existing {
6761 Some(item) if content_kind == ContentKind::Issue => {
6762 if item.labels != incoming.labels {
6763 return Err(SourceError::Refused {
6764 message: "GitHub issue labels differ from the labels being written".into(),
6765 });
6766 }
6767 }
6768 _ => {
6769 if !incoming.labels.is_empty() {
6770 return Err(SourceError::Refused {
6771 message: "GitHub items created by this destination carry no labels".into(),
6772 });
6773 }
6774 }
6775 }
6776
6777 // The repository the issue really lives in is what the slot below is written against,
6778 // so a single entry that is where the issue is created travels as no key at all, and
6779 // the read side derives it back from the issue.
6780 let own_repository = match (existing, &creation_target) {
6781 (Some(item), _) => item.own_repository.clone(),
6782 (None, Some(target)) => Some(
6783 Repository::try_from(target.origin())
6784 .map_err(|message| SourceError::Config { message })?,
6785 ),
6786 (None, None) => None,
6787 };
6788 let (native, fallback) = self
6789 .partition_edges(
6790 incoming.written.kind(),
6791 content_kind,
6792 existing.and_then(|item| item.blocked_by.as_deref()),
6793 depends_on,
6794 )
6795 .await?;
6796 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6797 let body = compose_body(incoming.content, &slot)?;
6798 // Read before anything is created, for the reason the field below is: a value
6799 // this destination cannot store has to refuse, and refusing after `createIssue`
6800 // would leave an issue behind that nothing asked for. The engine writes a
6801 // qualified id here; a caller handing this key anything else is told so rather
6802 // than having it silently stored as no origin at all.
6803 // 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.
6804 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6805 None => "",
6806 Some(Value::String(origin)) => origin.as_str(),
6807 Some(other) => {
6808 return Err(SourceError::Refused {
6809 message: format!(
6810 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6811 is {other}"
6812 ),
6813 });
6814 }
6815 };
6816 // Resolved before anything is created: a board that cannot carry the copy origin
6817 // has to refuse the write, and refusing it after `createIssue` would leave an
6818 // issue behind that nothing asked for.
6819 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6820 Some(field) => {
6821 if required_str(field, "__typename")? != "ProjectV2Field" {
6822 return Err(SourceError::Refused {
6823 message: format!(
6824 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6825 ),
6826 });
6827 }
6828 Some(required_str(field, "id")?.to_owned())
6829 }
6830 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6831 return Err(SourceError::Refused {
6832 message: format!(
6833 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6834 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6835 the board"
6836 ),
6837 });
6838 }
6839 None => None,
6840 };
6841
6842 let Landed {
6843 content_id,
6844 item_id,
6845 url,
6846 number,
6847 } = match existing {
6848 // Its content is written last, below, once everything else has landed.
6849 Some(item) => Landed {
6850 content_id: item.id.clone(),
6851 item_id: item.item_id.clone(),
6852 url: item.url.clone(),
6853 number: item.number,
6854 },
6855 None => {
6856 let target = creation_target
6857 .as_ref()
6858 .ok_or_else(|| SourceError::Malformed {
6859 message: "a new item was decided without a repository to create it in"
6860 .into(),
6861 })?;
6862 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6863 .await?
6864 }
6865 };
6866
6867 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6868 let column = column
6869 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6870 .map(|(field, option, _)| (field, option));
6871 // Creating an item here is several calls — `createIssue`, which files it on the
6872 // board, then its board fields, the parent and the dependencies — and GitHub can fail
6873 // at any of them. Everything this source can refuse *before* the first of those is
6874 // already checked above, so what is left is GitHub itself failing part way. When it
6875 // does over an item this call created, the issue is taken back: a write that
6876 // refused must not leave an item behind that nobody asked for, and one that does
6877 // makes the retry create a second.
6878 // Whether the board-field write carrying a moved origin was answered as landing whole.
6879 // When it was refused, GitHub does not say which of its fields ran before the one that
6880 // failed, so the origin may or may not have moved.
6881 let mut origin_landed = false;
6882 let landed = self
6883 .finish_write(
6884 board.id.as_str(),
6885 incoming,
6886 &content_id,
6887 &item_id,
6888 content_kind,
6889 existing,
6890 origin_field.as_deref(),
6891 origin,
6892 column,
6893 status_target.as_ref(),
6894 priority_write.as_ref(),
6895 &native,
6896 &mut origin_landed,
6897 )
6898 .await;
6899 // An existing item's title, body and state go last, in one `updateIssue`, once its board
6900 // fields and its relationships have landed: a refusal of any of those then leaves its
6901 // body — and the metadata slot inside it — exactly as it stood.
6902 let landed = match (landed, existing) {
6903 (Ok(()), Some(item)) => {
6904 self.update_existing(item, incoming, &body, status_target.as_ref())
6905 .await
6906 }
6907 (landed, _) => landed,
6908 };
6909 if let Err(error) = landed {
6910 match existing {
6911 // Best effort, and the write's own failure is what the caller is told: a
6912 // refusal naming the tidy-up would hide why the write failed at all.
6913 None => {
6914 let _ = self.delete_issue(&content_id).await;
6915 }
6916 // The origin field is the one piece of an existing item's metadata written
6917 // before its body, so a write refused after it puts it back as it was. When
6918 // that is refused too, the write's own failure is still what the caller is
6919 // told — with what it left behind added, because the item's metadata is then
6920 // not as it stood and a caller retrying has to know which key moved.
6921 Some(item) => {
6922 let before = item.origin.as_deref().unwrap_or("");
6923 if let Some(field) = origin_field.as_deref()
6924 && before != origin
6925 && let Err(restore) = self
6926 .set_item_field(
6927 board.id.as_str(),
6928 &item.item_id,
6929 field,
6930 json!({"text": before}),
6931 )
6932 .await
6933 {
6934 let left = if origin_landed {
6935 format!(
6936 "its {ORIGIN_KEY} was moved to {origin:?} before that and could \
6937 not be put back to {before:?} ({restore}), so item {} still \
6938 holds {origin:?} there",
6939 item.id.0
6940 )
6941 } else {
6942 format!(
6943 "the refused write carried its {ORIGIN_KEY} from {before:?} to \
6944 {origin:?}, GitHub does not say whether that part of it ran, \
6945 and putting it back to {before:?} was refused ({restore}), so \
6946 item {} holds {origin:?} or {before:?} there",
6947 item.id.0
6948 )
6949 };
6950 return Err(noting(
6951 error,
6952 &format!(
6953 "; {left}; next: set {ORIGIN_KEY} on it back to {before:?}, or \
6954 run the write again"
6955 ),
6956 ));
6957 }
6958 }
6959 }
6960 return Err(error);
6961 }
6962
6963 let written_status = match (incoming.written.status(), status_target.as_ref()) {
6964 (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6965 self.statuses
6966 .status(written_option.as_deref(), true, Some(reason.reason()))
6967 }
6968 (Some(_), Some(StatusTarget::Column(_))) => {
6969 self.statuses.status(written_option.as_deref(), false, None)
6970 }
6971 (Some(status), _) => status.clone(),
6972 (None, _) => Status {
6973 category: StatusCategory::Unknown,
6974 name: "Open".to_owned(),
6975 },
6976 };
6977
6978 // So the rest of this command reads what it just did rather than what the board
6979 // said before it. See `remember_written` for which half takes it.
6980 let remembered = Resolved {
6981 item_id,
6982 id: content_id.clone(),
6983 content_kind,
6984 kind: incoming.written.kind(),
6985 title: incoming.title.to_owned(),
6986 // The visible half of the body this write composed, split back off it the
6987 // way a read splits it — so what this record reports is what a read of the
6988 // same issue reports, rather than the person's text with the metadata slot
6989 // still on the end of it.
6990 body: metadata_body(body.clone())?.0,
6991 raw_body: body.clone(),
6992 // A document has no status of its own; what it reads back as is whatever
6993 // the issue's own state says, which is what a re-read reports.
6994 status: written_status,
6995 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6996 priority: match incoming.priority {
6997 Some(priority) => HeldPriority::Read(priority),
6998 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6999 item.priority.clone()
7000 }),
7001 },
7002 // What `state_input` asked for: closed for a terminal target, open for any other
7003 // status, and the issue's own state left as it was by a document write.
7004 closed: content_kind == ContentKind::Issue
7005 && match status_target.as_ref() {
7006 Some(StatusTarget::Terminal(_, _)) => true,
7007 Some(_) => false,
7008 None => existing.is_some_and(|item| item.closed),
7009 },
7010 delivers: incoming.delivers.to_vec(),
7011 delivered_by: incoming.delivered_by.to_vec(),
7012 labels: incoming.labels.to_vec(),
7013 parent: incoming.parent.cloned(),
7014 origin: (!origin.is_empty()).then(|| origin.to_owned()),
7015 number,
7016 // In the update path this is the item's own url, read off `existing` where the
7017 // record above was bound, so one expression serves both halves.
7018 url,
7019 created_at: existing.and_then(|item| item.created_at),
7020 updated_at: existing.and_then(|item| item.updated_at),
7021 own_repository,
7022 repositories: incoming.repositories.to_vec(),
7023 slot,
7024 board_id: Some(board.id.as_str().to_owned()),
7025 fields: board
7026 .fields
7027 .get("nodes")
7028 .and_then(Value::as_array)
7029 .cloned()
7030 .unwrap_or_default(),
7031 board_fields: Some(board.fields.clone()),
7032 // What this write left the relationship holding is known by id alone, and a
7033 // later read of its edges needs each far end's kind, so it reads them again.
7034 blocked_by: None,
7035 };
7036 self.remember_written(remembered, existing.is_none())?;
7037 Ok(content_id)
7038 }
7039
7040 /// Everything a write does after the item exists: its board fields, its parent, and
7041 /// its dependencies.
7042 ///
7043 /// Split out of `write_item` so there is one place a failure past the point of no
7044 /// return is caught, rather than a tidy-up repeated at each `?` above.
7045 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
7046 // so there is one place a failure past the point of no return is caught, and its
7047 // arguments are exactly the values that tail already had in scope. Bundling them into a
7048 // struct would describe no concept — it would be "the arguments of this function" — and
7049 // would put the whole of `write_item`'s locals behind one more indirection.
7050 #[allow(clippy::too_many_arguments)]
7051 async fn finish_write(
7052 &self,
7053 board_id: &str,
7054 incoming: &Incoming<'_>,
7055 content_id: &NativeId,
7056 item_id: &str,
7057 content_kind: ContentKind,
7058 existing: Option<&Resolved>,
7059 origin_field: Option<&str>,
7060 origin: &str,
7061 column: Option<(String, String)>,
7062 status_target: Option<&StatusTarget>,
7063 priority: Option<&PriorityWrite>,
7064 native: &[String],
7065 origin_landed: &mut bool,
7066 ) -> Result<(), SourceError> {
7067 let mut fields = Vec::new();
7068 if let Some(field_id) = origin_field
7069 && existing.map_or(!origin.is_empty(), |item| {
7070 item.origin.as_deref().unwrap_or("") != origin
7071 })
7072 {
7073 fields.push((field_id.to_owned(), json!({"text":origin})));
7074 }
7075 if let Some((field_id, option_id)) = column {
7076 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
7077 }
7078 let clear = match priority {
7079 Some(PriorityWrite::Select { field, option }) => {
7080 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
7081 None
7082 }
7083 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
7084 None => None,
7085 };
7086 self.set_item_fields(board_id, item_id, &fields, clear)
7087 .await?;
7088 *origin_landed = true;
7089
7090 // An existing issue closes in the `updateIssue` its write ends with; one created just
7091 // now closes here, once its option is selected.
7092 if existing.is_none()
7093 && content_kind == ContentKind::Issue
7094 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
7095 {
7096 self.update_content(
7097 ContentKind::Issue,
7098 content_id,
7099 json!({"stateInput":state_input(status_target)}),
7100 )
7101 .await?;
7102 }
7103
7104 if content_kind == ContentKind::Issue {
7105 self.reparent(
7106 existing.and_then(|item| item.parent.clone()),
7107 content_id,
7108 incoming.parent,
7109 )
7110 .await?;
7111 // A document takes part in no dependency graph, so writing one neither reads
7112 // nor changes the issue's own `blockedBy` relationships. Reconciling them
7113 // against the empty list a document write carries would *delete* whatever
7114 // relationships a person had made on that issue, which is a write nobody
7115 // asked for.
7116 if incoming.written.kind() != BoardKind::Document {
7117 let issue = match existing {
7118 Some(item) => Issue::Existing(item.blocked_by.as_deref()),
7119 None => Issue::Created,
7120 };
7121 self.reconcile_blocked_by(content_id, native, issue).await?;
7122 }
7123 }
7124 Ok(())
7125 }
7126
7127 /// Delete one issue, which takes its board item with it.
7128 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
7129 let data = self
7130 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7131 .await?;
7132 data.pointer("/deleteIssue/repository")
7133 .filter(|value| !value.is_null())
7134 .ok_or_else(|| SourceError::Malformed {
7135 message: "GitHub issue deletion returned no repository".into(),
7136 })?;
7137 self.forget(id)?;
7138 Ok(())
7139 }
7140
7141 /// Remove one item this copy created, so a copy that could not finish leaves the board
7142 /// as it found it.
7143 ///
7144 /// Deleting the issue takes its board item with it, so there is no second mutation to
7145 /// keep in step. An id the board does not hold is not an error: the item is already
7146 /// gone, which is the state this asks for. Which that is, is decided by reading the item
7147 /// by its own id — a listing of the board can still be missing an item it holds, and
7148 /// reading that as *already gone* would leave behind the very item this was asked to
7149 /// take back.
7150 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
7151 let Some(item) = self.bound_item(id).await? else {
7152 return Ok(());
7153 };
7154 if item.content_kind == ContentKind::DraftIssue {
7155 return Err(SourceError::Refused {
7156 message: format!(
7157 "GitHub item {} is a draft, and this source removes an item by deleting \
7158 its issue; next: remove it from the board by hand",
7159 id.0
7160 ),
7161 });
7162 }
7163 let data = self
7164 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
7165 .await?;
7166 data.pointer("/deleteIssue/repository")
7167 .filter(|value| !value.is_null())
7168 .ok_or_else(|| SourceError::Malformed {
7169 message: "GitHub issue deletion returned no repository".into(),
7170 })?;
7171 self.forget(id)?;
7172 Ok(())
7173 }
7174
7175 /// The issue a comment call on `task` is about, or `None` when this board holds no such
7176 /// task.
7177 ///
7178 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
7179 /// read of the task cannot disagree about which ids name one: a project or a document of
7180 /// this board is not a task here either.
7181 ///
7182 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
7183 /// issues and a draft is not one. It is refused rather than answered with an empty page,
7184 /// which would read as a task nobody has commented on yet.
7185 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
7186 let cached = self.resolved_cache()?.get(task).cloned();
7187 let Some(item) = (match cached {
7188 Some(item) => Some(item),
7189 None => self.item_by_id(task).await?,
7190 })
7191 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7192 return Ok(None);
7193 };
7194 if item.content_kind == ContentKind::DraftIssue {
7195 return Err(self.draft_has_no_comments(task));
7196 }
7197 Ok(Some(item.id))
7198 }
7199
7200 /// The refusal a comment call on a board draft is answered with: GitHub keeps comments on
7201 /// issues, and a draft is not one.
7202 fn draft_has_no_comments(&self, task: &NativeId) -> SourceError {
7203 SourceError::Refused {
7204 message: format!(
7205 "task {} of source {} is a draft item on the board, and GitHub keeps \
7206 comments on issues alone, so a draft has none to read or write; next: \
7207 convert the draft to an issue on the board, then comment on the issue it \
7208 becomes",
7209 task.0, self.name
7210 ),
7211 }
7212 }
7213
7214 /// One task and a page of its comments, read with [`graphql::ISSUE_DETAIL`] in one
7215 /// request — or `None` when this board holds no task by that id.
7216 ///
7217 /// What `task show` and a comment listing read. A draft is a task with no comments, so it
7218 /// is answered with the draft and the refusal, at the price of the draft's own read.
7219 async fn issue_detail(
7220 &self,
7221 id: &NativeId,
7222 page: &PageRequest,
7223 ) -> Result<Option<TaskDetailRead>, SourceError> {
7224 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7225 let asked = self
7226 .graphql(
7227 graphql::ISSUE_DETAIL,
7228 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after,
7229 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
7230 "duplicates":true}),
7231 )
7232 .await;
7233 let data = match asked {
7234 Ok(data) => data,
7235 Err(error) if unresolvable_node(&error) => return Ok(None),
7236 Err(error) => return Err(error),
7237 };
7238 // `node` is null for an id that names nothing, and absent only from an answer this
7239 // source cannot read — never the same thing.
7240 let node = data.get("node").ok_or_else(|| SourceError::Malformed {
7241 message: format!("GitHub answered the read of {} with no node", id.0),
7242 })?;
7243 self.detail_of(id, node, true, after).await
7244 }
7245
7246 /// Several tasks, each with the first page of its comments when `comments` is set, read
7247 /// [`DETAIL_BATCH`] at a time with [`graphql::ISSUE_DETAILS`] — one answer per id, in
7248 /// order.
7249 ///
7250 /// A batch GitHub refuses because one of its ids resolves to no node at all is read again
7251 /// one item at a time, so that id is answered as missing and the others as themselves; any
7252 /// other refusal is every id of that batch's answer.
7253 async fn issue_details(
7254 &self,
7255 ids: &[NativeId],
7256 comments: Option<&PageRequest>,
7257 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
7258 let mut read = Vec::with_capacity(ids.len());
7259 for batch in ids.chunks(DETAIL_BATCH) {
7260 match self
7261 .graphql(graphql::ISSUE_DETAILS, detail_batch(batch, comments))
7262 .await
7263 {
7264 Ok(data) => {
7265 for (slot, id) in batch.iter().enumerate() {
7266 // Every alias asked for is answered, null for an id naming nothing;
7267 // one missing is an answer this source cannot read.
7268 let read_one = match data.get(format!("i{slot}")) {
7269 Some(node) => self.detail_of(id, node, comments.is_some(), None).await,
7270 None => Err(SourceError::Malformed {
7271 message: format!(
7272 "GitHub answered a batch read with no item for {}",
7273 id.0
7274 ),
7275 }),
7276 };
7277 read.push(read_one);
7278 }
7279 }
7280 Err(error) if unresolvable_node(&error) => {
7281 for id in batch {
7282 read.push(match comments {
7283 Some(page) => self.issue_detail(id, page).await,
7284 None => self.task_read(id).await,
7285 });
7286 }
7287 }
7288 Err(error) => read.extend(batch.iter().map(|_| Err(error.clone()))),
7289 }
7290 }
7291 read
7292 }
7293
7294 /// One task and nothing of its comments, as [`TaskSource::get_task`] reads it.
7295 async fn task_read(&self, id: &NativeId) -> Result<Option<TaskDetailRead>, SourceError> {
7296 Ok(self.get_task(id).await?.map(|task| TaskDetailRead {
7297 task,
7298 comments: None,
7299 }))
7300 }
7301
7302 /// What one node a detail read reached says: the task this board holds by `id`, with the
7303 /// page of comments the node carries when `commented` — or `None` for a node that is no
7304 /// task of this board.
7305 ///
7306 /// Resolved as [`Self::item_by_id`] resolves an item: a draft is read again as a draft,
7307 /// and an item this process created answers from this process's own record, which a node
7308 /// read taken moments after the write can still be behind.
7309 async fn detail_of(
7310 &self,
7311 id: &NativeId,
7312 node: &Value,
7313 commented: bool,
7314 after: Option<&str>,
7315 ) -> Result<Option<TaskDetailRead>, SourceError> {
7316 if node.is_null() {
7317 return Ok(None);
7318 }
7319 let draft = optional_str(node, "__typename")? == Some("DraftIssue");
7320 // An issue answered under one id is that id's, or the answer is not one this source
7321 // can report: reporting another issue's task and comments under the qualified id asked
7322 // for would be the one wrong answer here. A draft's own read checks the same.
7323 if !draft
7324 && optional_str(node, "__typename")? == Some("Issue")
7325 && required_str(node, "id")? != id.0
7326 {
7327 return Err(SourceError::Malformed {
7328 message: format!(
7329 "GitHub answered the read of {} with issue {}",
7330 id.0,
7331 required_str(node, "id")?
7332 ),
7333 });
7334 }
7335 let item = if draft {
7336 self.draft_by_id(id).await?
7337 } else {
7338 self.resolve_issue(node).await?
7339 };
7340 let Some(item) = item.filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
7341 return Ok(None);
7342 };
7343 let own = self.created()?.iter().find(|own| own.id == *id).cloned();
7344 let task = own.unwrap_or(item).task()?;
7345 let comments = match (commented, draft) {
7346 (false, _) => None,
7347 (true, true) => Some(Err(self.draft_has_no_comments(id))),
7348 (true, false) => Some(comment_page(node, &id.0, after).map(Some)),
7349 };
7350 Ok(Some(TaskDetailRead { task, comments }))
7351 }
7352
7353 /// Whether the comment `comment` is one of `issue`'s own.
7354 ///
7355 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
7356 /// comment's id and nothing else: a comment id given against the wrong task would
7357 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
7358 /// names something that is not an issue comment, is a comment this task does not have —
7359 /// which is what GitHub refusing to resolve it means too.
7360 async fn comment_is_on(
7361 &self,
7362 issue: &NativeId,
7363 comment: &NativeId,
7364 ) -> Result<bool, SourceError> {
7365 let asked = self
7366 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
7367 .await;
7368 let data = match asked {
7369 Ok(data) => data,
7370 Err(error) if unresolvable_node(&error) => return Ok(false),
7371 Err(error) => return Err(error),
7372 };
7373 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7374 return Ok(false);
7375 };
7376 if optional_str(node, "__typename")? != Some("IssueComment") {
7377 return Ok(false);
7378 }
7379 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
7380 message: format!("GitHub issue comment {} names no issue", comment.0),
7381 })?;
7382 Ok(required_str(on, "id")? == issue.0)
7383 }
7384
7385 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
7386 async fn partition_edges(
7387 &self,
7388 near_kind: BoardKind,
7389 near_content: ContentKind,
7390 carried: Option<&[Value]>,
7391 depends_on: &[DependencyEdge],
7392 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
7393 let mut native = Vec::new();
7394 let mut fallback = Vec::new();
7395 let far_ends: Vec<(&DependencyEdge, &str, bool, Option<&Value>)> = depends_on
7396 .iter()
7397 .map(|edge| {
7398 let same_source = edge
7399 .to
7400 .source()
7401 .is_none_or(|source| source == self.name.as_str());
7402 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
7403 // `DependencyEndpoint::source` both read it that way — and a native id may hold
7404 // colons of its own, so the far end is everything after that one separator.
7405 // Splitting at the last would truncate `work:urn:task:7` to `7`.
7406 let far_id = if edge.to.is_qualified() {
7407 edge.to
7408 .id()
7409 .split_once(':')
7410 .map_or(edge.to.id(), |(_, native)| native)
7411 } else {
7412 edge.to.id()
7413 };
7414 // One that already blocks the near issue was answered by that issue's own
7415 // read, which carried each of its blockers' kinds — an issue every one — so it
7416 // is not read again.
7417 let blocking = carried.and_then(|nodes| {
7418 nodes
7419 .iter()
7420 .find(|node| node.get("id").and_then(Value::as_str) == Some(far_id))
7421 });
7422 (edge, far_id, same_source, blocking)
7423 })
7424 .collect();
7425 // Every other same-source far end is read by its own id, exactly as the item it is a
7426 // far end of is: whether this board holds it is that read's answer, never a listing's.
7427 // They are read together, [`DETAIL_BATCH`] to a request, rather than one each.
7428 let mut unread: Vec<NativeId> = Vec::new();
7429 for (_, far_id, same_source, blocking) in &far_ends {
7430 let id = NativeId((*far_id).to_owned());
7431 if *same_source && blocking.is_none() && !unread.contains(&id) {
7432 unread.push(id);
7433 }
7434 }
7435 let read: BTreeMap<NativeId, Option<Resolved>> = unread
7436 .iter()
7437 .cloned()
7438 .zip(self.items_by_ids(&unread).await?)
7439 .collect();
7440 for (edge, far_id, same_source, blocking) in far_ends {
7441 let far = match (same_source, blocking) {
7442 (false, _) => None,
7443 (true, Some(node)) => Some(FarEnd {
7444 kind: if required_str(node, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7445 BoardKind::Document
7446 } else {
7447 BoardKind::Work(related_kind(node)?)
7448 },
7449 content_kind: ContentKind::Issue,
7450 }),
7451 (true, None) => {
7452 let read = read
7453 .get(&NativeId(far_id.to_owned()))
7454 .cloned()
7455 .flatten()
7456 .ok_or_else(|| SourceError::Refused {
7457 message: format!("GitHub dependency item {far_id} was not found"),
7458 })?;
7459 Some(FarEnd {
7460 kind: read.kind,
7461 content_kind: read.content_kind,
7462 })
7463 }
7464 };
7465 let far = far.as_ref();
7466 // The caller says which kind the far end is, and this board holds the far end
7467 // itself, so a disagreement is settled here rather than stored: recorded, the
7468 // wrong kind would read back as a cross-level edge that never existed; written
7469 // natively, it would name a relationship of a different level than the caller
7470 // asked for.
7471 //
7472 // A far end this board holds as a *document* fails the same comparison and is
7473 // refused by the same sentence: `ItemKind` has no document variant because
7474 // nothing may point at one, so no caller can name it correctly and the refusal
7475 // is the only honest answer.
7476 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
7477 return Err(SourceError::Refused {
7478 message: format!(
7479 "GitHub dependency item {far_id} is a {} of this board, and this item \
7480 names it as a {}; record the kind it is",
7481 disagreeing.kind.describes(),
7482 edge.to.kind.marker()
7483 ),
7484 });
7485 }
7486 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
7487 // however the far end is spelled — and one classified native here would be
7488 // written nowhere at all, because a draft's native reconciliation never runs.
7489 let native_here = near_content == ContentKind::Issue
7490 && far.is_some_and(|far| {
7491 far.content_kind == ContentKind::Issue
7492 && BoardKind::Work(edge.to.kind) == near_kind
7493 });
7494 if native_here {
7495 native.push(far_id.to_owned());
7496 } else {
7497 fallback.push(edge.clone());
7498 }
7499 }
7500 Ok((native, fallback))
7501 }
7502
7503 async fn update_existing(
7504 &self,
7505 item: &Resolved,
7506 incoming: &Incoming<'_>,
7507 body: &Option<String>,
7508 status_target: Option<&StatusTarget>,
7509 ) -> Result<(), SourceError> {
7510 let title = incoming.written_title();
7511 // A terminal status closes the issue here, in the same mutation as its body: its board
7512 // option was selected before this, so a close never lands on an item whose board cannot
7513 // show it.
7514 let fields = match item.content_kind {
7515 ContentKind::DraftIssue => json!({"title":title,"body":body}),
7516 ContentKind::Issue => json!({"title":title,"body":body,
7517 "stateInput":state_input(status_target)}),
7518 };
7519 self.update_content(item.content_kind, &item.id, fields)
7520 .await
7521 }
7522
7523 /// Update one board item's content with exactly `fields` beside its id, through the
7524 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
7525 /// a draft.
7526 ///
7527 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
7528 /// is what lets a narrow write carry the one thing it changes and nothing else.
7529 async fn update_content(
7530 &self,
7531 kind: ContentKind,
7532 id: &NativeId,
7533 fields: Value,
7534 ) -> Result<(), SourceError> {
7535 let (operation, id_key, pointer) = match kind {
7536 ContentKind::DraftIssue => (
7537 graphql::UPDATE_DRAFT,
7538 "draftIssueId",
7539 "/updateProjectV2DraftIssue/draftIssue",
7540 ),
7541 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
7542 };
7543 let mut input = fields;
7544 input[id_key] = json!(id.0);
7545 let data = self.graphql(operation, json!({"input":input})).await?;
7546 let returned = data
7547 .pointer(pointer)
7548 .ok_or_else(|| SourceError::Malformed {
7549 message: "GitHub item update returned no item".into(),
7550 })?;
7551 if required_str(returned, "id")? != id.0 {
7552 return Err(SourceError::Malformed {
7553 message: "GitHub item update returned the wrong item".into(),
7554 });
7555 }
7556 Ok(())
7557 }
7558
7559 /// Creates one issue, files it on the board, and reports what a read of it would say:
7560 /// its content id, its board item id, and the web address GitHub gave it.
7561 ///
7562 /// Two calls rather than one: `createIssue` answers with an issue that is on no board,
7563 /// and `addProjectV2ItemById` is what puts it there. Filing it at creation through
7564 /// `CreateIssueInput.projectV2Ids` was tried and is not done: GitHub answered with no
7565 /// board item, and the `addProjectV2ItemById` that then had to follow was refused
7566 /// "Content already exists in this project". A terminal status is not written here:
7567 /// `finish_write` selects its option first and closes the issue after, so a close never
7568 /// lands on an item whose board cannot show it.
7569 ///
7570 /// The address and the number come back here because this is the only place either is
7571 /// known before GitHub's own board read catches up — an item this run created answers
7572 /// the reads that follow it out of the record below, and one remembered without them
7573 /// would report no location and no key for the rest of the run.
7574 async fn create_and_file_issue(
7575 &self,
7576 board_id: &str,
7577 repository: &RepositoryTarget,
7578 incoming: &Incoming<'_>,
7579 body: &Option<String>,
7580 ) -> Result<Landed, SourceError> {
7581 let repository_id = self.repository_id(repository, incoming).await?;
7582 let data = self
7583 .graphql(
7584 graphql::CREATE_ISSUE,
7585 json!({"input":{
7586 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
7587 }}),
7588 )
7589 .await?;
7590 let created = data
7591 .pointer("/createIssue/issue")
7592 .filter(|value| !value.is_null())
7593 .ok_or_else(|| SourceError::Malformed {
7594 message: "GitHub issue creation returned no issue".into(),
7595 })?;
7596 let content_id = NativeId(required_str(created, "id")?.to_owned());
7597 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
7598 // a response without it is not worth failing a landed write over — the item simply
7599 // reports no location until the board read catches up, which is what it did before.
7600 let url = optional_str(created, "url")?.map(str::to_owned);
7601 // The issue exists from here on, so an unreadable number and a refused board
7602 // filing below each try, best effort, to take it back: an issue in the repository
7603 // that is on no board is an item nobody asked for and nothing here would find again.
7604 //
7605 // Its number is optional on the same terms its address is — a landed write is not
7606 // worth failing over a member that came back missing, and such an item reports no
7607 // handle until a board read catches up. A number that is *present* and is not an
7608 // unsigned integer is still a response this source cannot read.
7609 let number = match created_issue_number(created) {
7610 Ok(number) => number,
7611 Err(error) => {
7612 let _ = self.delete_issue(&content_id).await;
7613 return Err(error);
7614 }
7615 };
7616 let added = match self
7617 .graphql(
7618 graphql::ADD_TO_BOARD,
7619 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
7620 )
7621 .await
7622 {
7623 Ok(added) => added,
7624 Err(error) => {
7625 let _ = self.delete_issue(&content_id).await;
7626 return Err(error);
7627 }
7628 };
7629 let item = added
7630 .pointer("/addProjectV2ItemById/item")
7631 .filter(|value| !value.is_null())
7632 .ok_or_else(|| SourceError::Malformed {
7633 message: "GitHub board addition returned no project item".into(),
7634 })?;
7635 Ok(Landed {
7636 content_id,
7637 item_id: required_str(item, "id")?.to_owned(),
7638 url,
7639 number,
7640 })
7641 }
7642
7643 /// Move one issue under the project it now belongs to, or out of the one it left.
7644 async fn reparent(
7645 &self,
7646 held: Option<NativeId>,
7647 child: &NativeId,
7648 wanted: Option<&NativeId>,
7649 ) -> Result<(), SourceError> {
7650 if held.as_ref() == wanted {
7651 return Ok(());
7652 }
7653 if let Some(held) = &held {
7654 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
7655 .await?;
7656 }
7657 if let Some(wanted) = wanted {
7658 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
7659 .await?;
7660 }
7661 Ok(())
7662 }
7663
7664 async fn sub_issue(
7665 &self,
7666 operation: &str,
7667 parent: &NativeId,
7668 child: &NativeId,
7669 root: &str,
7670 ) -> Result<(), SourceError> {
7671 let data = self
7672 .graphql(
7673 operation,
7674 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
7675 )
7676 .await?;
7677 let issue =
7678 data.pointer(&format!("/{root}/issue"))
7679 .ok_or_else(|| SourceError::Malformed {
7680 message: "GitHub sub-issue update returned no issue".into(),
7681 })?;
7682 let sub =
7683 data.pointer(&format!("/{root}/subIssue"))
7684 .ok_or_else(|| SourceError::Malformed {
7685 message: "GitHub sub-issue update returned no sub-issue".into(),
7686 })?;
7687 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7688 return Err(SourceError::Malformed {
7689 message: "GitHub sub-issue update returned the wrong issues".into(),
7690 });
7691 }
7692 Ok(())
7693 }
7694
7695 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7696 /// whether there was one.
7697 ///
7698 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7699 /// relationships are not read: there is nothing a read of them could find.
7700 async fn reconcile_blocked_by(
7701 &self,
7702 content_id: &NativeId,
7703 native: &[String],
7704 issue: Issue<'_>,
7705 ) -> Result<bool, SourceError> {
7706 let current = match issue {
7707 Issue::Created => Vec::new(),
7708 Issue::Existing(Some(held)) => held
7709 .iter()
7710 .map(|far| required_str(far, "id").map(str::to_owned))
7711 .collect::<Result<Vec<_>, _>>()?,
7712 Issue::Existing(None) => self.native_dependency_ids(content_id).await?,
7713 };
7714 let mut changed = false;
7715 for (operation, far_id) in current
7716 .iter()
7717 .filter(|id| !native.contains(id))
7718 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7719 .chain(
7720 native
7721 .iter()
7722 .filter(|id| !current.contains(id))
7723 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7724 )
7725 {
7726 let data = self
7727 .graphql(
7728 operation,
7729 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7730 )
7731 .await?;
7732 let root = if operation == graphql::ADD_BLOCKED_BY {
7733 "addBlockedBy"
7734 } else {
7735 "removeBlockedBy"
7736 };
7737 let issue =
7738 data.pointer(&format!("/{root}/issue"))
7739 .ok_or_else(|| SourceError::Malformed {
7740 message: "GitHub dependency update returned no issue".into(),
7741 })?;
7742 let blocker = data
7743 .pointer(&format!("/{root}/blockingIssue"))
7744 .ok_or_else(|| SourceError::Malformed {
7745 message: "GitHub dependency update returned no blocking issue".into(),
7746 })?;
7747 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7748 {
7749 return Err(SourceError::Malformed {
7750 message: "GitHub dependency update returned the wrong issues".into(),
7751 });
7752 }
7753 changed = true;
7754 }
7755 Ok(changed)
7756 }
7757}
7758
7759/// What a write needs to know of one far end it names: which kind of item it is, and whether
7760/// it is an issue a native relationship can name.
7761struct FarEnd {
7762 kind: BoardKind,
7763 content_kind: ContentKind,
7764}
7765
7766/// Whether the issue one write reconciles was created by that write or was already there.
7767#[derive(Clone, Copy, PartialEq, Eq)]
7768enum Issue<'a> {
7769 /// Created by this write, so it holds no relationships yet.
7770 Created,
7771 /// On the board before this write, holding whatever relationships it holds — the far
7772 /// ends of its whole `blockedBy`, when the read that reached it carried them.
7773 Existing(Option<&'a [Value]>),
7774}
7775
7776/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7777enum Reached {
7778 /// An issue this board holds, resolved into everything this source reports about it.
7779 Held(Box<Resolved>),
7780 /// Nothing this board holds: no such node, or a node on some other board.
7781 Nothing,
7782 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7783 /// again by [`GitHubProjectsSource::draft_by_id`].
7784 Draft,
7785}
7786
7787/// What GitHub says when a string is not a node id it can resolve.
7788///
7789/// Matched because it is the ordinary answer to a project selector naming a project by its
7790/// *name*, and reporting that as a failure would make naming one impossible. It is read
7791/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7792/// does not define the syntax of a GitHub node id and would be wrong about it.
7793const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7794
7795/// `error` with `note` added to the end of what it says, its kind and every other member
7796/// unchanged — so a caller still branches on the failure that happened, and reads beside it
7797/// what that failure left behind.
7798fn noting(error: SourceError, note: &str) -> SourceError {
7799 match error {
7800 SourceError::Config { message } => SourceError::Config {
7801 message: message + note,
7802 },
7803 SourceError::Auth { message } => SourceError::Auth {
7804 message: message + note,
7805 },
7806 SourceError::Refused { message } => SourceError::Refused {
7807 message: message + note,
7808 },
7809 SourceError::RateLimited {
7810 retry_after_seconds,
7811 message,
7812 } => SourceError::RateLimited {
7813 retry_after_seconds,
7814 message: Some(message.unwrap_or_default() + note),
7815 },
7816 SourceError::Unavailable { message } => SourceError::Unavailable {
7817 message: message + note,
7818 },
7819 SourceError::Malformed { message } => SourceError::Malformed {
7820 message: message + note,
7821 },
7822 }
7823}
7824
7825/// The variables of one [`graphql::ISSUE_DETAILS`] request over `batch` — at most
7826/// [`DETAIL_BATCH`] ids — each item with the first page of its comments when `comments` asks
7827/// for them.
7828///
7829/// The document is fixed-size, so a slot `batch` has no id for is bound to its last id, which
7830/// is read again at no added price.
7831fn detail_batch(batch: &[NativeId], comments: Option<&PageRequest>) -> Value {
7832 let mut variables = serde_json::Map::new();
7833 for slot in 0..DETAIL_BATCH {
7834 let id = batch.get(slot).or(batch.last()).map(|id| id.0.clone());
7835 variables.insert(format!("id{slot}"), json!(id));
7836 }
7837 variables.insert(
7838 "first".to_owned(),
7839 json!(comments.map_or(MAX_PAGE_SIZE, |page| page.limit.min(MAX_PAGE_SIZE))),
7840 );
7841 variables.insert("comments".to_owned(), json!(comments.is_some()));
7842 variables.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
7843 variables.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
7844 variables.insert("duplicates".to_owned(), json!(true));
7845 Value::Object(variables)
7846}
7847
7848/// Whether this refusal is GitHub saying the id names no node at all.
7849fn unresolvable_node(error: &SourceError) -> bool {
7850 matches!(error, SourceError::Refused { message }
7851 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7852}
7853
7854/// One project name, as a search qualifier which filters on it at the server.
7855///
7856/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7857/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7858/// the way it documents. A title matched here is still compared for equality afterwards:
7859/// the qualifier narrows what the server sends, and this source decides what it names.
7860fn title_qualifier(name: &str) -> String {
7861 format!("in:title {}", quoted(name))
7862}
7863
7864/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7865/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7866/// it documents — so a value holding a qualifier's spelling is searched for rather than
7867/// obeyed.
7868fn quoted(value: &str) -> String {
7869 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7870 format!("\"{escaped}\"")
7871}
7872
7873/// The search qualifier for the issues updated at or after `since`.
7874///
7875/// Written to the second, rounded down, which can only widen what the search returns.
7876fn updated_qualifier(since: DateTime<Utc>) -> String {
7877 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7878}
7879
7880/// The search terms that narrow a board-scoped issue search to a task query's text and
7881/// metadata predicates, or `None` when it carries neither.
7882///
7883/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7884/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7885/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7886/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7887/// a metadata value searches both fields for both — wider than asked, never narrower, and
7888/// every candidate is confirmed in process afterwards.
7889///
7890/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7891/// matches whole tokens where a substring rule would match inside a word, so an item holding
7892/// the text only inside a longer word is not returned. A text of nothing but whitespace
7893/// matches every item, so it narrows nothing and is not sent.
7894fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7895 let text = query
7896 .text
7897 .as_ref()
7898 .filter(|text| !text.terms.trim().is_empty());
7899 if text.is_none() && query.metadata.is_empty() {
7900 return None;
7901 }
7902 let (title, body) = match text.map(|text| text.fields) {
7903 None => (false, true),
7904 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7905 Some(TextFields::Content) => (false, true),
7906 Some(TextFields::TitleOrContent) => (true, true),
7907 };
7908 let fields = match (title, body) {
7909 (true, true) => "in:title,body",
7910 (true, false) => "in:title",
7911 _ => "in:body",
7912 };
7913 let phrases = text
7914 .map(|text| text.terms.clone())
7915 .into_iter()
7916 .chain(
7917 query
7918 .metadata
7919 .iter()
7920 .map(|wanted| as_stored(wanted.value())),
7921 )
7922 .map(|phrase| quoted(&phrase))
7923 .collect::<Vec<_>>();
7924 Some(format!("{fields} {}", phrases.join(" ")))
7925}
7926
7927/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7928/// before anything is asked of GitHub.
7929///
7930/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7931/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7932/// left out, the search is every issue of the board. So this source says it cannot answer
7933/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7934/// nothing GitHub could search for, and keeps the board read it always had.
7935fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7936 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7937 letter or digit with a bounded query";
7938 if let Some(text) = &query.text
7939 && !text.terms.trim().is_empty()
7940 && !has_words(&text.terms)
7941 {
7942 return Err(SourceError::Refused {
7943 message: format!(
7944 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7945 text.terms
7946 ),
7947 });
7948 }
7949 if let Some(wanted) = query
7950 .metadata
7951 .iter()
7952 .find(|wanted| !has_words(wanted.value()))
7953 {
7954 return Err(SourceError::Refused {
7955 message: format!(
7956 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
7957 wanted.value(),
7958 std::iter::once(wanted.key())
7959 .chain(wanted.path().iter().map(String::as_str))
7960 .collect::<Vec<_>>()
7961 .join("/"),
7962 ),
7963 });
7964 }
7965 Ok(())
7966}
7967
7968/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
7969fn has_words(phrase: &str) -> bool {
7970 phrase.chars().any(char::is_alphanumeric)
7971}
7972
7973/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
7974///
7975/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
7976/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
7977/// which GitHub's word match would read as different words.
7978fn as_stored(value: &str) -> String {
7979 let encoded = Value::String(value.to_owned()).to_string();
7980 encoded[1..encoded.len() - 1].to_owned()
7981}
7982
7983/// The one narrower question a task query carrying a text, metadata or origin predicate is
7984/// sent as.
7985enum Narrowing {
7986 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
7987 Origin(String),
7988 /// The board-scoped issue search narrowed by these qualifiers.
7989 Search(String),
7990}
7991
7992impl Narrowing {
7993 /// What this question is remembered under for the length of one command.
7994 fn key(&self) -> String {
7995 match self {
7996 Self::Origin(origin) => format!("origin {origin}"),
7997 Self::Search(also) => format!("search {also}"),
7998 }
7999 }
8000}
8001
8002/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
8003enum Resumed {
8004 /// It reported another page, which starts after this cursor.
8005 More(String),
8006 /// It has ended. Sending this cursor again — the page's own end when it had one, and
8007 /// otherwise the cursor it was reached from — answers an empty page, so the one document
8008 /// can go on walking the other connection.
8009 Ended(Option<String>),
8010}
8011
8012impl Resumed {
8013 /// Whether the connection has another page.
8014 const fn has_more(&self) -> bool {
8015 matches!(self, Self::More(_))
8016 }
8017
8018 /// The cursor to send this connection next.
8019 fn cursor(self) -> Option<String> {
8020 match self {
8021 Self::More(next) => Some(next),
8022 Self::Ended(last) => last,
8023 }
8024 }
8025}
8026
8027/// Where `connection`, reached from `after`, resumes — refused when it reports another page
8028/// with no cursor to it, or from a cursor that does not advance.
8029fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
8030 let info = connection
8031 .get("pageInfo")
8032 .ok_or_else(|| SourceError::Malformed {
8033 message: "GitHub connection has no pageInfo".into(),
8034 })?;
8035 let end = optional_str(info, "endCursor")?;
8036 if required_bool(info, "hasNextPage")? {
8037 let next = end.ok_or_else(|| SourceError::Malformed {
8038 message: "GitHub connection reports another page and no endCursor".into(),
8039 })?;
8040 validate_cursor_progress(after, next)?;
8041 return Ok(Resumed::More(next.to_owned()));
8042 }
8043 Ok(Resumed::Ended(
8044 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
8045 ))
8046}
8047
8048/// The board, and every item on it this source reports.
8049#[derive(Clone)]
8050struct Board {
8051 id: String,
8052 fields: Value,
8053 items: Vec<Resolved>,
8054}
8055
8056/// What a write needs of the board and nothing more: its node id and its field
8057/// definitions, in the shape a read of the board's own `fields` gives them.
8058///
8059/// Deliberately no items. A write decides which item it writes, which parent it files
8060/// under and which far ends it names by reading each of them by its own id; this is the
8061/// half of the board those reads cannot carry, and holding no item is what keeps it from
8062/// ever being asked whether an item is there.
8063#[derive(Clone)]
8064struct BoardFields {
8065 id: BoardId,
8066 fields: Value,
8067}
8068
8069/// A board's node id: what a field write and `addProjectV2ItemById` address.
8070///
8071/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
8072/// refused where it is read, and one an item names blank is read as not named at all.
8073#[derive(Clone)]
8074struct BoardId(String);
8075
8076/// Where one write left its item, for the record the rest of the command reads it out of.
8077///
8078/// A named record rather than a tuple because the update arm and the create arm each fill
8079/// all four, and two `Option`s of different meaning side by side in a tuple are two
8080/// positions a reader has to count.
8081struct Landed {
8082 /// The issue's own node id, which is the [`NativeId`] this source reports.
8083 content_id: NativeId,
8084 /// The board item's id, which is what a field write addresses.
8085 // 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.
8086 item_id: String,
8087 /// The web address GitHub gave the issue, when it gave one.
8088 // 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.
8089 url: Option<String>,
8090 /// The issue's number on its repository, when GitHub reported one.
8091 number: Option<u64>,
8092}
8093
8094impl BoardId {
8095 fn parse(id: &str) -> Result<Self, SourceError> {
8096 if id.trim().is_empty() {
8097 return Err(SourceError::Malformed {
8098 message: "GitHub named a board with a blank node id".into(),
8099 });
8100 }
8101 Ok(Self(id.to_owned()))
8102 }
8103
8104 fn as_str(&self) -> &str {
8105 &self.0
8106 }
8107}
8108
8109impl Board {
8110 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
8111 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
8112 let nodes = fields
8113 .get("nodes")
8114 .and_then(Value::as_array)
8115 .ok_or_else(|| SourceError::Malformed {
8116 message: "GitHub project fields.nodes is not an array".into(),
8117 })?;
8118 Ok(nodes
8119 .iter()
8120 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
8121 }
8122}
8123
8124/// One board item, resolved into everything this source reports about it.
8125#[derive(Clone)]
8126struct Resolved {
8127 item_id: String,
8128 id: NativeId,
8129 content_kind: ContentKind,
8130 kind: BoardKind,
8131 title: String,
8132 body: Option<String>,
8133 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
8134 /// that changes the slot alone has to keep byte for byte outside it.
8135 raw_body: Option<String>,
8136 status: Status,
8137 /// The name of the board `Status` option this item sits in, as the board spells it.
8138 option: Option<String>,
8139 /// What its `Priority` field says, read through this instance's mapping.
8140 priority: HeldPriority,
8141 /// Whether this item's issue is closed. A draft has no such state and is never closed.
8142 closed: bool,
8143 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
8144 delivers: Vec<TaskRef>,
8145 /// Every task that delivers this one, read out of its slot. Empty for anything not a
8146 /// task.
8147 delivered_by: Vec<TaskRef>,
8148 labels: Vec<Label>,
8149 parent: Option<NativeId>,
8150 // 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.
8151 origin: Option<String>,
8152 /// The issue's own number on its repository, as GitHub reports it.
8153 ///
8154 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
8155 /// declares none, and a draft is not filed in a repository to be numbered by one — and
8156 /// an issue this run created whose creating mutation answered without one, which is a
8157 /// response GitHub's own schema says cannot happen and which a landed write is not
8158 /// worth failing over. An `Issue` read off the board always has one.
8159 number: Option<u64>,
8160 url: Option<String>,
8161 created_at: Option<DateTime<Utc>>,
8162 updated_at: Option<DateTime<Utc>>,
8163 own_repository: Option<Repository>,
8164 repositories: Vec<Repository>,
8165 slot: BTreeMap<String, Value>,
8166 /// The node id of the board this item sits on, when the read that reached it said.
8167 board_id: Option<String>,
8168 /// The definition of every board field this item holds a value of, in the shape a read
8169 /// of the board's own `fields` gives one.
8170 ///
8171 /// Only the fields this item has a value in: a field it holds nothing of is not here,
8172 /// which says nothing about whether the board has it.
8173 fields: Vec<Value>,
8174 /// Every field the board this item sits on defines, as its own read of the board's
8175 /// `fields` gives them — when the read that reached the item carried them, which a read
8176 /// of it by its own id does. What a write of it needs of the board, then, needs no read
8177 /// of the board.
8178 board_fields: Option<Value>,
8179 /// The far ends of this issue's whole `blockedBy` connection, each as a dependency read
8180 /// selects one — when the read that reached it carried the connection to its end, which a
8181 /// read of it by its own id does for any issue blocked by no more than a page. What a
8182 /// write reconciles that relationship against, and what a read of its forward edges in
8183 /// the same command answers with.
8184 blocked_by: Option<Vec<Value>>,
8185}
8186
8187impl Resolved {
8188 /// The board this item's own read names it on, when that read named one this source can
8189 /// address.
8190 fn named_board(&self) -> Option<BoardId> {
8191 self.board_id
8192 .as_deref()
8193 .and_then(|id| BoardId::parse(id).ok())
8194 }
8195
8196 /// The board's id and every field it defines, when the read that reached this item
8197 /// carried both — which a read of it by its own id does.
8198 fn carried_board(&self) -> Option<BoardFields> {
8199 Some(BoardFields {
8200 id: self.named_board()?,
8201 fields: self.board_fields.clone()?,
8202 })
8203 }
8204
8205 /// Whether this item holds a value of the board field called `name`, and so carries
8206 /// that field's definition. `false` says nothing about whether the board has the field.
8207 fn defines(&self, name: &str) -> bool {
8208 self.fields
8209 .iter()
8210 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
8211 }
8212
8213 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
8214 /// in a field of its own, and none of the five keys that are only an encoding.
8215 ///
8216 /// The two delivery keys are left out for every kind, not only for a task: they are
8217 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
8218 /// document carrying one holds nothing a caller's own metadata could mean by it.
8219 fn metadata(&self) -> BTreeMap<String, Value> {
8220 let mut metadata = self.slot.clone();
8221 metadata.remove(Repository::METADATA_KEY);
8222 metadata.remove(DependencyEdge::RECORDED_KEY);
8223 metadata.remove(ItemKind::METADATA_KEY);
8224 metadata.remove(TaskRef::DELIVERS_KEY);
8225 metadata.remove(TaskRef::DELIVERED_BY_KEY);
8226 // The board field is the origin, and the body's copy of it is only a mirror for the
8227 // issue search to find: an item whose field holds none has none, whatever its body
8228 // says, so no reader ever sees two answers.
8229 metadata.remove(ORIGIN_KEY);
8230 if let Some(origin) = &self.origin {
8231 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
8232 }
8233 metadata
8234 }
8235
8236 /// Where this item is, as a link a reader can open.
8237 ///
8238 /// A board is a hosted place and every issue on it has a web address, so that address
8239 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
8240 /// of place it is, so a reader knows to open it rather than to read a file out. It
8241 /// does not replace or derive from `url`: the field goes on reporting exactly what it
8242 /// reported before, and this says what that address *is*.
8243 ///
8244 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
8245 /// rather than a third variant, which is the contract's "the source did not say". An
8246 /// issue this run created is not one of those: its address comes back from the
8247 /// creating mutation, so it is somewhere a reader can open from the moment it exists
8248 /// rather than from whenever the board read catches up.
8249 fn location(&self) -> Option<Location> {
8250 self.url.clone().map(Location::Url)
8251 }
8252
8253 /// The short handle this board's backend shows people for a task: the issue's number
8254 /// alone, as a decimal string.
8255 ///
8256 /// The number alone rather than `owner/repo#1043`, because that is the contract's
8257 /// value for this backend. A draft has no number and so no handle, which is the
8258 /// contract's *absent* rather than a handle of some other shape — and the native
8259 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
8260 /// derives from.
8261 fn key(&self) -> Option<String> {
8262 self.number.map(|number| number.to_string())
8263 }
8264
8265 /// Whether its `Priority` field holds a value at all, mapped or not.
8266 fn holds_priority(&self) -> bool {
8267 self.priority != HeldPriority::Read(Priority::None)
8268 }
8269
8270 /// The task this item is.
8271 ///
8272 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
8273 /// reading that as a level would be a guess, and reading it as `none` would let the next
8274 /// copy clear a priority a person set.
8275 fn task(&self) -> Result<Task, SourceError> {
8276 let priority = match &self.priority {
8277 HeldPriority::Read(priority) => *priority,
8278 HeldPriority::Unmapped(option) => {
8279 return Err(SourceError::Malformed {
8280 message: format!(
8281 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
8282 this source's priority_mapping does not name, so its priority cannot be \
8283 read; next: name {option:?} under priority_mapping, or move the item to \
8284 a mapped option",
8285 self.id,
8286 self.number
8287 .map(|number| format!(" (#{number})"))
8288 .unwrap_or_default()
8289 ),
8290 });
8291 }
8292 };
8293 Ok(Task {
8294 id: self.id.clone(),
8295 key: self.key(),
8296 title: self.title.clone(),
8297 content: self.body.clone(),
8298 status: self.status.clone(),
8299 priority,
8300 labels: self.labels.clone(),
8301 project: self.parent.clone(),
8302 url: self.url.clone(),
8303 location: self.location(),
8304 created_at: self.created_at,
8305 updated_at: self.updated_at,
8306 metadata: self.metadata(),
8307 repositories: self.repositories.clone(),
8308 delivers: self.delivers.clone(),
8309 delivered_by: self.delivered_by.clone(),
8310 })
8311 }
8312
8313 fn project(&self) -> Project {
8314 Project {
8315 id: self.id.clone(),
8316 title: self.title.clone(),
8317 content: self.body.clone(),
8318 status: self.status.clone(),
8319 labels: self.labels.clone(),
8320 url: self.url.clone(),
8321 location: self.location(),
8322 created_at: self.created_at,
8323 updated_at: self.updated_at,
8324 metadata: self.metadata(),
8325 repositories: self.repositories.clone(),
8326 }
8327 }
8328
8329 /// The same issue as a document: the project it is filed under, and no status and no
8330 /// dependencies, because a document is not work.
8331 fn document(&self) -> Document {
8332 Document {
8333 id: self.id.clone(),
8334 title: self.title.clone(),
8335 content: self.body.clone(),
8336 project: self.parent.clone(),
8337 labels: self.labels.clone(),
8338 url: self.url.clone(),
8339 location: self.location(),
8340 created_at: self.created_at,
8341 updated_at: self.updated_at,
8342 metadata: self.metadata(),
8343 repositories: self.repositories.clone(),
8344 }
8345 }
8346}
8347
8348/// Where one targeted update moves an item's status, and which of its two halves move.
8349struct StatusMove {
8350 /// The board the item's `Status` field is on.
8351 board: BoardId,
8352 /// The `Status` field's id.
8353 field: String,
8354 /// The option's id.
8355 option: String,
8356 /// The option's name, as the board spells it.
8357 name: String,
8358 /// What the status asks of the issue's state.
8359 target: StatusTarget,
8360 /// The status the item reads as once it is there.
8361 landed: Status,
8362 /// Which of the status's two halves differ from what the item holds.
8363 moves: Moves,
8364}
8365
8366/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
8367/// closed state of its issue, or both. A status neither half of which differs is no move at all,
8368/// and is not a value of this type.
8369#[derive(Clone, Copy, PartialEq, Eq)]
8370enum Moves {
8371 /// The option alone.
8372 Option,
8373 /// The issue's state alone: open, closed, or closed with another reason.
8374 State,
8375 /// Both.
8376 Both,
8377}
8378
8379impl Moves {
8380 /// What differs, or `None` when nothing does.
8381 const fn of(option: bool, state: bool) -> Option<Self> {
8382 match (option, state) {
8383 (true, true) => Some(Self::Both),
8384 (true, false) => Some(Self::Option),
8385 (false, true) => Some(Self::State),
8386 (false, false) => None,
8387 }
8388 }
8389
8390 /// Whether the option moves.
8391 const fn option(self) -> bool {
8392 matches!(self, Self::Option | Self::Both)
8393 }
8394
8395 /// Whether the issue's state moves.
8396 const fn state(self) -> bool {
8397 matches!(self, Self::State | Self::Both)
8398 }
8399}
8400
8401/// What one write is, and the status that comes with being it.
8402///
8403/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
8404/// status and a task or a project always has one, so "a document carrying a status" and
8405/// "a task carrying none" are states a write cannot be in rather than states every use
8406/// site below has to defend against.
8407enum Written<'a> {
8408 /// A document, which is not work and so has no status at all.
8409 Document,
8410 /// A task or a project, and the status it is being written with.
8411 Work(ItemKind, &'a Status),
8412}
8413
8414impl Written<'_> {
8415 /// Which of the board's three kinds this write is.
8416 const fn kind(&self) -> BoardKind {
8417 match self {
8418 Self::Document => BoardKind::Document,
8419 Self::Work(kind, _) => BoardKind::Work(*kind),
8420 }
8421 }
8422
8423 /// The status this write carries. A document carries none, so a write of one says
8424 /// nothing about the issue's open or closed state and selects no board `Status`
8425 /// option.
8426 const fn status(&self) -> Option<&Status> {
8427 match self {
8428 Self::Document => None,
8429 Self::Work(_, status) => Some(status),
8430 }
8431 }
8432}
8433
8434/// The item being written, in the one shape all three write methods reach.
8435struct Incoming<'a> {
8436 written: Written<'a>,
8437 /// The title a person wrote. A document's goes onto the issue with
8438 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
8439 title: &'a str,
8440 content: Option<&'a str>,
8441 labels: &'a [Label],
8442 metadata: &'a BTreeMap<String, Value>,
8443 repositories: &'a [Repository],
8444 parent: Option<&'a NativeId>,
8445 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
8446 /// what keeps either key out of their slot.
8447 delivers: &'a [TaskRef],
8448 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
8449 delivered_by: &'a [TaskRef],
8450 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
8451 /// project, a document, and every write to an instance with no `priority_mapping` —
8452 /// which is what keeps such a write's requests exactly what they were before.
8453 priority: Option<Priority>,
8454}
8455
8456/// What one write does to an item's `Priority` field.
8457enum PriorityWrite {
8458 /// Select this option of this field.
8459 Select {
8460 /// The `Priority` field's id.
8461 field: String,
8462 /// The mapped option's id.
8463 option: String,
8464 },
8465 /// Clear the field's value, which is what `none` is.
8466 Clear {
8467 /// The `Priority` field's id.
8468 field: String,
8469 },
8470}
8471
8472impl Incoming<'_> {
8473 /// The title this write puts on the issue.
8474 fn written_title(&self) -> String {
8475 match self.written {
8476 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
8477 Written::Work(..) => self.title.to_owned(),
8478 }
8479 }
8480}
8481
8482#[derive(Clone, Copy, PartialEq, Eq)]
8483enum ContentKind {
8484 DraftIssue,
8485 Issue,
8486}
8487
8488/// What one board issue is: a document, or the work an [`ItemKind`] names.
8489///
8490/// A type of this source's own rather than an `ItemKind` with a third variant, because
8491/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
8492/// document — the contract keeps a document out of that enum deliberately. Holding the
8493/// board's three answers in one value is what makes every place that asks "which is this?"
8494/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
8495/// two thirds of the board.
8496#[derive(Clone, Copy, PartialEq, Eq)]
8497enum BoardKind {
8498 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
8499 Document,
8500 /// Every other issue, and every draft.
8501 Work(ItemKind),
8502}
8503
8504impl BoardKind {
8505 /// How a refusal names this kind to the person reading it.
8506 const fn describes(self) -> &'static str {
8507 match self {
8508 Self::Document => "document",
8509 Self::Work(kind) => kind.marker(),
8510 }
8511 }
8512}
8513
8514/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
8515///
8516/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
8517/// the shared cross-source journeys assert one answer to one question, so two sources
8518/// that disagree about what "carries the label bug" means fail them.
8519fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
8520 let holds = |name: &String| {
8521 labels
8522 .iter()
8523 .any(|label| label.name.eq_ignore_ascii_case(name))
8524 };
8525 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
8526 && filter.all_of.iter().all(holds)
8527 && !filter.none_of.iter().any(holds)
8528}
8529
8530/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
8531/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
8532fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
8533 statuses.is_empty() || statuses.contains(&category)
8534}
8535
8536/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
8537///
8538/// `content` is the item's own prose — the body with this source's trailing metadata
8539/// comment already taken off — so a search never matches an encoding the author of the
8540/// issue never wrote.
8541fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
8542 let terms = query.terms.to_lowercase();
8543 let in_title = title.to_lowercase().contains(&terms);
8544 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
8545 match query.fields {
8546 TextFields::Title => in_title,
8547 TextFields::Content => in_content,
8548 TextFields::TitleOrContent => in_title || in_content,
8549 }
8550}
8551
8552/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
8553///
8554/// The project predicate is passed separately because a read narrowed to one project has
8555/// already answered it by asking *that project* for its own items — and re-applying it
8556/// there would compare the caller's selector, which may be a project's **name**, against
8557/// the id of the project that name resolved to, and keep nothing. Every other read passes
8558/// `query.project` and applies it here, which is what keeps `projects` a predicate this
8559/// source really does apply.
8560fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
8561 labels_match(&task.labels, &query.labels)
8562 && status_matches(task.status.category, &query.statuses)
8563 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
8564 && match project {
8565 ProjectFilter::Any => true,
8566 ProjectFilter::Orphans => task.project.is_none(),
8567 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
8568 }
8569 && query
8570 .text
8571 .as_ref()
8572 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
8573 // Against the parsed metadata slot, and against the origin field, which is where
8574 // `Resolved::metadata` reads each of them from.
8575 && query.metadata_matches(&task.metadata)
8576 && query.origin_matches(&task.metadata)
8577}
8578
8579fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
8580 labels_match(&project.labels, &query.labels)
8581 && status_matches(project.status.category, &query.statuses)
8582 && query
8583 .text
8584 .as_ref()
8585 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
8586}
8587
8588/// The same three predicates a task query carries, minus the status filter.
8589///
8590/// A document is not work, so it has no status for one to compare against and the query
8591/// type carries none. The project predicate is the same one — a design issue filed under a
8592/// project issue is in that project, and one filed under nothing is in none — so it is
8593/// spelled the same way here rather than answered differently.
8594fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
8595 labels_match(&document.labels, &query.labels)
8596 && match project {
8597 ProjectFilter::Any => true,
8598 ProjectFilter::Orphans => document.project.is_none(),
8599 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
8600 }
8601 && query
8602 .text
8603 .as_ref()
8604 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
8605}
8606
8607#[async_trait::async_trait]
8608impl TaskSource for GitHubProjectsSource {
8609 fn kind(&self) -> &'static str {
8610 KIND
8611 }
8612 fn capabilities(&self) -> Capabilities {
8613 Capabilities {
8614 projects: Support::Native,
8615 documents: Support::Native,
8616 comments: Support::Native,
8617 priority: if self.priorities.is_some() {
8618 Support::Native
8619 } else {
8620 Support::Unsupported
8621 },
8622 filter_by_priority: Support::Native,
8623 filter_by_comment_activity: Support::Native,
8624 filter_by_metadata: Support::Native,
8625 filter_by_origin: Support::Native,
8626 orphan_tasks: Support::Native,
8627 filter_by_label: Support::Native,
8628 filter_by_status: Support::Native,
8629 search_title: Support::Native,
8630 search_content: Support::Native,
8631 task_dependencies: DependencySupport::BothDirections,
8632 project_dependencies: DependencySupport::BothDirections,
8633 max_page_size: MAX_PAGE_SIZE,
8634 }
8635 }
8636 async fn health(&self) -> Result<Health, SourceError> {
8637 let board = self.board_page(None, 1).await?;
8638 Ok(Health {
8639 reachable: true,
8640 detail: Some(format!(
8641 "reading GitHub project {}/{} ({})",
8642 self.owner,
8643 self.project_number,
8644 required_str(&board, "title")?
8645 )),
8646 })
8647 }
8648 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
8649 self.item_by_id(id)
8650 .await?
8651 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8652 .map(|item| item.task())
8653 .transpose()
8654 }
8655 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
8656 Ok(self
8657 .item_by_id(id)
8658 .await?
8659 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8660 .map(|item| item.project()))
8661 }
8662 async fn query_tasks(
8663 &self,
8664 query: &TaskQuery,
8665 page: &PageRequest,
8666 ) -> Result<Page<Task>, SourceError> {
8667 validate_page(page)?;
8668 refuse_unsearchable(query)?;
8669 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
8670 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
8671 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
8672 (Some(also), None) => Some(also),
8673 (None, Some(since)) => Some(updated_qualifier(since)),
8674 (None, None) => None,
8675 };
8676 if let Some(also) = qualifiers {
8677 return self.search_tasks(query, page, &also).await;
8678 }
8679 }
8680
8681 // A read narrowed to one project asks that project for its own tasks, so nothing
8682 // about it costs what the rest of the board holds. A read carrying a text, metadata
8683 // or origin predicate asks GitHub the narrower question those predicates are, and a
8684 // read narrowed to comment activity alone asks the board's own issue search for the
8685 // issues updated since, which is every issue a comment could have been written or
8686 // edited on since. Every other task read is a question about the whole board and is
8687 // answered by reading it.
8688 let (held, membership) = match (&query.project, query.commented_since) {
8689 (ProjectFilter::Is(project), _) => (
8690 self.project_children(project).await?,
8691 // Answered by where these items came from; see `task_matches`.
8692 &ProjectFilter::Any,
8693 ),
8694 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
8695 match (self.narrowed(query).await?, since) {
8696 (Some(narrowed), _) => (narrowed, &query.project),
8697 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
8698 (None, None) => (self.board().await?.items, &query.project),
8699 }
8700 }
8701 };
8702 // Filtered before paged: a page of a filtered result is a page of the survivors,
8703 // never the survivors of a page.
8704 let mut tasks = Vec::new();
8705 for item in held
8706 .iter()
8707 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
8708 {
8709 let task = item.task()?;
8710 if task_matches(&task, query, membership)
8711 && self.commented_since(item, query.commented_since).await?
8712 {
8713 tasks.push(task);
8714 }
8715 }
8716 Ok(offset_page(
8717 tasks,
8718 numeric_cursor(page.cursor.as_ref())?,
8719 page.limit.min(MAX_PAGE_SIZE) as usize,
8720 ))
8721 }
8722 async fn query_projects(
8723 &self,
8724 query: &ProjectQuery,
8725 page: &PageRequest,
8726 ) -> Result<Page<Project>, SourceError> {
8727 validate_page(page)?;
8728 // The projects a board holds are found by an issue search scoped to that board,
8729 // never by walking the board's own item connection: what tells a project from a
8730 // task is the `parent` each issue carries, which costs nothing to read.
8731 let projects = self
8732 .board_issues()
8733 .await?
8734 .iter()
8735 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
8736 .map(Resolved::project)
8737 .filter(|project| project_matches(project, query))
8738 .collect();
8739 Ok(offset_page(
8740 projects,
8741 numeric_cursor(page.cursor.as_ref())?,
8742 page.limit.min(MAX_PAGE_SIZE) as usize,
8743 ))
8744 }
8745 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
8746 Ok(self
8747 .item_by_id(id)
8748 .await?
8749 .filter(|item| item.kind == BoardKind::Document)
8750 .map(|item| item.document()))
8751 }
8752 async fn query_documents(
8753 &self,
8754 query: &DocumentQuery,
8755 page: &PageRequest,
8756 ) -> Result<Page<Document>, SourceError> {
8757 validate_page(page)?;
8758 // Narrowed to one project, this is the same sub-issue read a task list scoped to
8759 // that project makes — a document filed under a project is a sub-issue of it too,
8760 // and which of them come back is the kind this caller asked for.
8761 let (held, membership) = match &query.project {
8762 ProjectFilter::Is(project) => (
8763 self.project_children(project).await?,
8764 // Answered by where these items came from; see `task_matches`.
8765 &ProjectFilter::Any,
8766 ),
8767 ProjectFilter::Any | ProjectFilter::Orphans => {
8768 (self.board().await?.items, &query.project)
8769 }
8770 };
8771 // Filtered before paged, exactly as a task read is: a page of a filtered result is
8772 // a page of the survivors, never the survivors of a page.
8773 let documents = held
8774 .iter()
8775 .filter(|item| item.kind == BoardKind::Document)
8776 .map(Resolved::document)
8777 .filter(|document| document_matches(document, query, membership))
8778 .collect();
8779 Ok(offset_page(
8780 documents,
8781 numeric_cursor(page.cursor.as_ref())?,
8782 page.limit.min(MAX_PAGE_SIZE) as usize,
8783 ))
8784 }
8785 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8786 validate_page(page)?;
8787 let offset = numeric_cursor(page.cursor.as_ref())?;
8788 let mut labels = self
8789 .board()
8790 .await?
8791 .items
8792 .into_iter()
8793 .flat_map(|item| item.labels)
8794 .fold(Vec::new(), |mut all, label| {
8795 if !all.iter().any(|x: &Label| x.id == label.id) {
8796 all.push(label);
8797 }
8798 all
8799 });
8800 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8801 Ok(offset_page(
8802 labels,
8803 offset,
8804 page.limit.min(MAX_PAGE_SIZE) as usize,
8805 ))
8806 }
8807 async fn task_dependencies(
8808 &self,
8809 id: &NativeId,
8810 direction: Direction,
8811 page: &PageRequest,
8812 ) -> Result<Page<DependencyEdge>, SourceError> {
8813 self.dependencies(id, ItemKind::Task, direction, page).await
8814 }
8815 async fn project_dependencies(
8816 &self,
8817 id: &NativeId,
8818 direction: Direction,
8819 page: &PageRequest,
8820 ) -> Result<Page<DependencyEdge>, SourceError> {
8821 self.dependencies(id, ItemKind::Project, direction, page)
8822 .await
8823 }
8824
8825 fn writes(&self) -> WriteSupport {
8826 WriteSupport::Supported
8827 }
8828
8829 /// Create or update one task.
8830 ///
8831 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8832 /// neither may name the task itself or name one task twice — and land in the body's
8833 /// metadata slot under their reserved keys, in place of any caller metadata of those
8834 /// names.
8835 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8836 let near = write.target.as_ref().unwrap_or(&write.item.id);
8837 for (key, entries) in [
8838 (TaskRef::DELIVERS_KEY, &write.item.delivers),
8839 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8840 ] {
8841 TaskRef::listed(key, near, Some(&self.name), entries.clone())
8842 .map_err(|message| SourceError::Refused { message })?;
8843 }
8844 if self.priorities.is_none() && write.item.priority != Priority::None {
8845 return Err(self.holds_no_priority());
8846 }
8847 self.write_item(
8848 &Incoming {
8849 written: Written::Work(ItemKind::Task, &write.item.status),
8850 title: &write.item.title,
8851 content: write.item.content.as_deref(),
8852 labels: &write.item.labels,
8853 metadata: &write.item.metadata,
8854 repositories: &write.item.repositories,
8855 parent: write.item.project.as_ref(),
8856 delivers: &write.item.delivers,
8857 delivered_by: &write.item.delivered_by,
8858 priority: self.priorities.as_ref().map(|_| write.item.priority),
8859 },
8860 write.target.as_ref(),
8861 &write.depends_on,
8862 )
8863 .await
8864 }
8865
8866 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8867 self.write_item(
8868 &Incoming {
8869 written: Written::Work(ItemKind::Project, &write.item.status),
8870 title: &write.item.title,
8871 content: write.item.content.as_deref(),
8872 labels: &write.item.labels,
8873 metadata: &write.item.metadata,
8874 repositories: &write.item.repositories,
8875 parent: None,
8876 delivers: &[],
8877 delivered_by: &[],
8878 priority: None,
8879 },
8880 write.target.as_ref(),
8881 &write.depends_on,
8882 )
8883 .await
8884 }
8885
8886 /// Create or update one document, which is one issue titled the way this board spells
8887 /// a document.
8888 ///
8889 /// Everything else is exactly a task write: caller metadata goes to the same canonical
8890 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8891 /// or a field this board cannot carry is refused by name rather than dropped, a target
8892 /// naming an issue this board does not hold is refused rather than created, and an
8893 /// issue this call created is taken back when the rest of the write fails.
8894 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8895 // A document takes part in no dependency graph, so there is no far end to write
8896 // natively and none to record: a caller naming one is told so rather than having it
8897 // stored under the reserved key, where a later read would report an edge the
8898 // contract says cannot exist.
8899 if !write.depends_on.is_empty() {
8900 return Err(SourceError::Refused {
8901 message: format!(
8902 "this write names {} dependencies for a document, and a document takes \
8903 part in no dependency graph; next: put the dependency on the task or \
8904 project the document is about",
8905 write.depends_on.len()
8906 ),
8907 });
8908 }
8909 self.write_item(
8910 &Incoming {
8911 written: Written::Document,
8912 title: &write.item.title,
8913 content: write.item.content.as_deref(),
8914 labels: &write.item.labels,
8915 metadata: &write.item.metadata,
8916 repositories: &write.item.repositories,
8917 parent: write.item.project.as_ref(),
8918 delivers: &[],
8919 delivered_by: &[],
8920 priority: None,
8921 },
8922 write.target.as_ref(),
8923 &[],
8924 )
8925 .await
8926 }
8927
8928 /// Set one task's status alone.
8929 ///
8930 /// An open target reopens a closed issue with an `updateIssue` carrying only its
8931 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8932 /// terminal target selects its mapped option, then closes with its fixed reason. No
8933 /// request carries a title, a body or a label. The status
8934 /// answered is what [`StatusMapping::status`] reads off the state just written, which is
8935 /// what a re-read reports.
8936 async fn set_task_status(
8937 &self,
8938 id: &NativeId,
8939 category: StatusCategory,
8940 ) -> Result<Option<Status>, SourceError> {
8941 self.set_status(id, category).await
8942 }
8943
8944 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
8945 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
8946 /// for `none`. Refused by an instance with no `priority_mapping`.
8947 async fn set_task_priority(
8948 &self,
8949 id: &NativeId,
8950 priority: Priority,
8951 ) -> Result<Option<Priority>, SourceError> {
8952 self.set_priority(id, priority).await
8953 }
8954
8955 /// Replace one task's content with a single body update that keeps the metadata slot
8956 /// byte for byte.
8957 async fn set_task_content(
8958 &self,
8959 id: &NativeId,
8960 content: &str,
8961 ) -> Result<Option<()>, SourceError> {
8962 self.replace_content(id, content).await
8963 }
8964
8965 /// Replace one task issue's content and its provenance slot entry with a single body
8966 /// update. The answers are not kept: see `replace_rendering`.
8967 async fn set_task_rendering(
8968 &self,
8969 id: &NativeId,
8970 content: &str,
8971 provenance: &Value,
8972 _answers: &BTreeMap<String, Value>,
8973 ) -> Result<Option<()>, SourceError> {
8974 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
8975 .await
8976 }
8977
8978 /// Replace one design-document issue's content and its provenance slot entry, on exactly
8979 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
8980 async fn set_document_rendering(
8981 &self,
8982 id: &NativeId,
8983 content: &str,
8984 provenance: &Value,
8985 _answers: &BTreeMap<String, Value>,
8986 ) -> Result<Option<()>, SourceError> {
8987 self.replace_rendering(id, BoardKind::Document, content, provenance)
8988 .await
8989 }
8990
8991 /// Apply a targeted update with one read of the item and a write only for what differs:
8992 /// the `Status` and `Priority` field writes in one request, the `blockedBy` difference,
8993 /// and last one `updateIssue` for title, body and state. See `targeted_update`.
8994 async fn update_task(
8995 &self,
8996 id: &NativeId,
8997 update: &TaskUpdate,
8998 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8999 self.targeted_update(id, update).await
9000 }
9001
9002 /// Replace one task's `delivered_by` with a single body update that changes the
9003 /// metadata slot and nothing outside it.
9004 async fn set_delivered_by(
9005 &self,
9006 id: &NativeId,
9007 delivered_by: &[TaskRef],
9008 ) -> Result<Option<()>, SourceError> {
9009 self.replace_delivered_by(id, delivered_by).await
9010 }
9011
9012 /// Set one key of one task issue's metadata with a single body update that changes the
9013 /// metadata slot and nothing outside it — no title, label, state or board field request —
9014 /// and sends nothing when the task already holds that value under the key.
9015 async fn set_task_metadata(
9016 &self,
9017 id: &NativeId,
9018 key: &MetadataKey,
9019 value: &Value,
9020 ) -> Result<Option<Task>, SourceError> {
9021 Ok(self
9022 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
9023 .await?
9024 .map(|item| item.task())
9025 .transpose()?)
9026 }
9027
9028 /// Set one key of one project issue's metadata, on exactly the terms of
9029 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9030 async fn set_project_metadata(
9031 &self,
9032 id: &NativeId,
9033 key: &MetadataKey,
9034 value: &Value,
9035 ) -> Result<Option<Project>, SourceError> {
9036 Ok(self
9037 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
9038 .await?
9039 .map(|item| item.project()))
9040 }
9041
9042 /// Set one key of one design-document issue's metadata, on exactly the terms of
9043 /// [`set_task_metadata`](TaskSource::set_task_metadata).
9044 async fn set_document_metadata(
9045 &self,
9046 id: &NativeId,
9047 key: &MetadataKey,
9048 value: &Value,
9049 ) -> Result<Option<Document>, SourceError> {
9050 Ok(self
9051 .set_slot_key(id, BoardKind::Document, key, value)
9052 .await?
9053 .map(|item| item.document()))
9054 }
9055
9056 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
9057 self.delete_item(id).await
9058 }
9059
9060 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
9061 self.delete_item(id).await
9062 }
9063
9064 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
9065 self.delete_item(id).await
9066 }
9067
9068 /// One page of the task issue's own comments, walked by GitHub's own cursor.
9069 ///
9070 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
9071 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
9072 ///
9073 /// One request, [`graphql::ISSUE_DETAIL`]: the read that says the id names a task of this
9074 /// board is the read of its comments. A draft this process already resolved is refused
9075 /// without one.
9076 async fn task_comments(
9077 &self,
9078 task: &NativeId,
9079 page: &PageRequest,
9080 ) -> Result<Option<Page<Comment>>, SourceError> {
9081 validate_page(page)?;
9082 let cached = self.resolved_cache()?.get(task).cloned();
9083 if let Some(item) = cached {
9084 if item.kind != BoardKind::Work(ItemKind::Task) {
9085 return Ok(None);
9086 }
9087 if item.content_kind == ContentKind::DraftIssue {
9088 return Err(self.draft_has_no_comments(task));
9089 }
9090 }
9091 match self.issue_detail(task, page).await? {
9092 Some(TaskDetailRead {
9093 comments: Some(comments),
9094 ..
9095 }) => comments,
9096 _ => Ok(None),
9097 }
9098 }
9099
9100 /// Every id's task, with the first page of its comments when `comments` names it:
9101 /// [`DETAIL_BATCH`] items per [`graphql::ISSUE_DETAILS`] request, and one item with its
9102 /// comments in one [`graphql::ISSUE_DETAIL`] request.
9103 async fn get_task_details(
9104 &self,
9105 ids: &[NativeId],
9106 comments: Option<&PageRequest>,
9107 ) -> Vec<Result<Option<TaskDetailRead>, SourceError>> {
9108 if let Some(page) = comments
9109 && let Err(error) = validate_page(page)
9110 {
9111 return ids.iter().map(|_| Err(error.clone())).collect();
9112 }
9113 match (ids, comments) {
9114 ([id], Some(page)) => vec![self.issue_detail(id, page).await],
9115 ([id], None) => vec![self.task_read(id).await],
9116 _ => self.issue_details(ids, comments).await,
9117 }
9118 }
9119
9120 /// Add one comment to the task's issue, as the account the token belongs to.
9121 ///
9122 /// The author is refused before anything is sent — not even the task is read — because
9123 /// no answer GitHub could give would make posting under another name than the one asked
9124 /// for the right outcome.
9125 async fn add_comment(
9126 &self,
9127 task: &NativeId,
9128 comment: &NewComment,
9129 ) -> Result<Option<Comment>, SourceError> {
9130 if let Some(author) = &comment.author {
9131 return Err(SourceError::Refused {
9132 message: format!(
9133 "source {} cannot post a comment as {author:?}: GitHub records the account \
9134 the token signs in as the author of every comment; next: leave --author \
9135 out, and the comment is posted as that account",
9136 self.name
9137 ),
9138 });
9139 }
9140 let Some(issue) = self.commented_issue(task).await? else {
9141 return Ok(None);
9142 };
9143 let data = self
9144 .graphql(
9145 graphql::ADD_COMMENT,
9146 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
9147 )
9148 .await?;
9149 let subject = data
9150 .pointer("/addComment/subject")
9151 .filter(|value| !value.is_null())
9152 .ok_or_else(|| SourceError::Malformed {
9153 message: "GitHub comment addition returned no subject".into(),
9154 })?;
9155 if required_str(subject, "id")? != issue.0 {
9156 return Err(SourceError::Malformed {
9157 message: "GitHub comment addition answered about another issue".into(),
9158 });
9159 }
9160 let added = data
9161 .pointer("/addComment/commentEdge/node")
9162 .filter(|value| !value.is_null())
9163 .ok_or_else(|| SourceError::Malformed {
9164 message: "GitHub comment addition returned no comment".into(),
9165 })?;
9166 comment_from(added).map(Some)
9167 }
9168
9169 async fn edit_comment(
9170 &self,
9171 task: &NativeId,
9172 comment: &NativeId,
9173 body: &CommentBody,
9174 ) -> Result<Option<Comment>, SourceError> {
9175 let Some(issue) = self.commented_issue(task).await? else {
9176 return Ok(None);
9177 };
9178 if !self.comment_is_on(&issue, comment).await? {
9179 return Ok(None);
9180 }
9181 let data = self
9182 .graphql(
9183 graphql::UPDATE_COMMENT,
9184 json!({"input":{"id":comment.0,"body":body.as_str()}}),
9185 )
9186 .await?;
9187 let edited = data
9188 .pointer("/updateIssueComment/issueComment")
9189 .filter(|value| !value.is_null())
9190 .ok_or_else(|| SourceError::Malformed {
9191 message: "GitHub comment update returned no comment".into(),
9192 })?;
9193 let edited = comment_from(edited)?;
9194 if edited.id != *comment {
9195 return Err(SourceError::Malformed {
9196 message: "GitHub comment update returned the wrong comment".into(),
9197 });
9198 }
9199 Ok(Some(edited))
9200 }
9201
9202 async fn delete_comment(
9203 &self,
9204 task: &NativeId,
9205 comment: &NativeId,
9206 ) -> Result<Option<NativeId>, SourceError> {
9207 let Some(issue) = self.commented_issue(task).await? else {
9208 return Ok(None);
9209 };
9210 if !self.comment_is_on(&issue, comment).await? {
9211 return Ok(None);
9212 }
9213 let data = self
9214 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
9215 .await?;
9216 // The payload says nothing about the comment it removed, so what is checked is that
9217 // GitHub answered the mutation at all rather than leaving it unanswered.
9218 data.get("deleteIssueComment")
9219 .filter(|value| !value.is_null())
9220 .ok_or_else(|| SourceError::Malformed {
9221 message: "GitHub comment deletion returned no payload".into(),
9222 })?;
9223 Ok(Some(comment.clone()))
9224 }
9225
9226 /// Every request this source has recorded, and what each of GitHub's two budgets was
9227 /// attributed — read off the same accounting the session report is rendered from, so
9228 /// the two cannot count one request two ways.
9229 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
9230 Ok(Some(self.ledger.snapshot().metering()))
9231 }
9232}
9233
9234/// One issue comment as the contract carries it.
9235///
9236/// `author` is absent both when GitHub answers `null` for an account that no longer exists
9237/// and when it answers an actor with no login, because either way the source did not say who
9238/// wrote it — which is what an absent author means, rather than an author called nothing.
9239fn comment_from(value: &Value) -> Result<Comment, SourceError> {
9240 Ok(Comment {
9241 id: NativeId(required_str(value, "id")?.to_owned()),
9242 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
9243 .map(str::to_owned),
9244 created_at: optional_time(value, "createdAt")?,
9245 updated_at: optional_time(value, "updatedAt")?,
9246 body: required_str(value, "body")?.to_owned(),
9247 url: optional_str(value, "url")?.map(str::to_owned),
9248 })
9249}
9250
9251/// The page of comments one issue node carries, resumed from `after`.
9252fn comment_page(
9253 node: &Value,
9254 issue: &str,
9255 after: Option<&str>,
9256) -> Result<Page<Comment>, SourceError> {
9257 let connection = node
9258 .get("comments")
9259 .filter(|value| !value.is_null())
9260 .ok_or_else(|| SourceError::Malformed {
9261 message: format!("GitHub issue {issue} answered with no comments connection"),
9262 })?;
9263 let items = optional_nodes(Some(connection), "issue comments")?
9264 .into_iter()
9265 .flatten()
9266 .map(comment_from)
9267 .collect::<Result<Vec<_>, _>>()?;
9268 let next = next_cursor(connection)?;
9269 if let Some(next) = &next {
9270 validate_cursor_progress(after, &next.0)?;
9271 }
9272 Ok(Page { items, next })
9273}
9274
9275/// The far ends of an issue's whole `blockedBy` connection, when the read carried it to its
9276/// end — `None` when it carried none, or a page with more past it.
9277fn carried_blocked_by(content: &Value) -> Result<Option<Vec<Value>>, SourceError> {
9278 let Some(connection) = content.get("blockedBy").filter(|value| !value.is_null()) else {
9279 return Ok(None);
9280 };
9281 if next_cursor(connection)?.is_some() {
9282 return Ok(None);
9283 }
9284 Ok(Some(
9285 optional_nodes(Some(connection), "blocked-by issues")?
9286 .into_iter()
9287 .flatten()
9288 .cloned()
9289 .collect(),
9290 ))
9291}
9292
9293/// Where the recorded tail of a dependency walk resumes; see
9294/// [`GitHubProjectsSource::recorded_edges`].
9295const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
9296
9297/// The board text field this source keeps a copy's origin in.
9298///
9299/// Named after the key it holds, and held to that name by the guard below rather than by
9300/// a reader noticing.
9301const ORIGIN_FIELD: &str = "onetaskgraph.origin";
9302
9303/// The metadata key that field holds.
9304///
9305/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
9306/// constructs or interprets the qualified id it carries. This source names it only to
9307/// route it — a short, typed value belongs in a typed field rather than in the body slot
9308/// a caller's own prose shares.
9309///
9310/// Restated rather than imported, because no plugin crate may depend on the engine. What
9311/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
9312/// target in `check`: it reads the engine's own literal and fails naming the file and the
9313/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
9314/// that creates a second item every run instead of finding the one it wrote — and that is
9315/// too late to learn it.
9316const ORIGIN_KEY: &str = "onetaskgraph.origin";
9317
9318/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
9319///
9320/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
9321/// is derived from the far end, never written down on the near item — so only a forward
9322/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
9323/// it did not come from, and it is told so rather than answered with an empty page that
9324/// reads as a walk which ended.
9325fn recorded_offset(
9326 cursor: Option<&str>,
9327 direction: Direction,
9328) -> Result<Option<usize>, SourceError> {
9329 cursor
9330 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
9331 .map(|offset| {
9332 if direction != Direction::DependsOn {
9333 return Err(SourceError::Config {
9334 message: format!(
9335 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
9336 reverse dependency read never issues; resume it in the direction \
9337 that reported it"
9338 ),
9339 });
9340 }
9341 offset.parse().map_err(|_| SourceError::Config {
9342 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
9343 })
9344 })
9345 .transpose()
9346}
9347
9348fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
9349 let mut page = offset_page(edges, offset, limit.max(1));
9350 page.next = page
9351 .next
9352 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
9353 page
9354}
9355
9356/// The kind of one issue reached through a dependency connection.
9357///
9358/// The same questions the board scan asks, over the fields the dependency document
9359/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
9360/// then anything with sub-issues or the marker is a project.
9361///
9362/// # Errors
9363///
9364/// A far end this board holds as a document is refused rather than reported. The two
9365/// answers that are not refusals would both be wrong: reporting it as a task names an id
9366/// no task read of this source can find, and reporting it as a project names one no
9367/// project read can. There is no third value to return — `ItemKind` has no document
9368/// variant, because nothing may point at a document — so the relationship itself is what
9369/// the person is told about.
9370fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
9371 let id = required_str(value, "id")?;
9372 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
9373 return Err(SourceError::Refused {
9374 message: format!(
9375 "GitHub issue {id} is a document of this board — its title begins \
9376 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
9377 on by one; next: remove that issue's blocking relationship on this board"
9378 ),
9379 });
9380 }
9381 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
9382 if parent.is_some() {
9383 return Ok(ItemKind::Task);
9384 }
9385 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
9386 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
9387 message: format!("GitHub issue {id}: {message}"),
9388 })?;
9389 let sub_issues = sub_issue_total(value)?;
9390 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
9391 ItemKind::Project
9392 } else {
9393 ItemKind::Task
9394 })
9395}
9396
9397/// The `IssueStateUpdateInput` one status target asks for.
9398///
9399/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
9400/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
9401/// a currently-closed issue: without that the item would read back `Unknown` and a copy
9402/// would report a change forever. A document has no status at all, and asks for neither.
9403fn state_input(target: Option<&StatusTarget>) -> Value {
9404 match target {
9405 Some(StatusTarget::Terminal(_, reason)) => {
9406 json!({"value":"CLOSED","stateReason":reason.reason()})
9407 }
9408 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
9409 // A document has no status, so a write of one says nothing about the issue's open
9410 // or closed state rather than forcing it open: `stateInput` is what carries that
9411 // instruction, and an explicit null asks for no change to it.
9412 None => Value::Null,
9413 }
9414}
9415
9416/// The metadata one write stores in the item's body slot.
9417///
9418/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
9419/// rather than carried: the kind marker so an empty project stays readable, the
9420/// repository list only when it is not exactly the issue's own repository, and the far
9421/// ends no relationship here can name.
9422///
9423/// The copy origin is the one typed field that is also mirrored here, and only as a
9424/// mirror: it lands in the board's origin field as well, which stays the one every reader
9425/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
9426/// and catches up with a write in seconds rather than minutes — can find the item by it.
9427/// A reader of the release before this one drops the slot's copy and reads the field, so an
9428/// item written here still reads with exactly one origin there.
9429fn slot_metadata(
9430 incoming: &Incoming<'_>,
9431 own_repository: Option<&Repository>,
9432 fallback: &[DependencyEdge],
9433) -> BTreeMap<String, Value> {
9434 let mut metadata = incoming.metadata.clone();
9435 match metadata.remove(ORIGIN_KEY) {
9436 Some(Value::String(origin)) if !origin.is_empty() => {
9437 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
9438 }
9439 _ => {}
9440 }
9441 match incoming.written.kind() {
9442 BoardKind::Work(kind) => metadata.insert(
9443 ItemKind::METADATA_KEY.to_owned(),
9444 Value::String(kind.marker().to_owned()),
9445 ),
9446 // A document is told by its title, so it carries no kind marker: that key names
9447 // what a dependency endpoint points at, and nothing may point at a document.
9448 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
9449 };
9450 let derivable = own_repository
9451 .map(|own| incoming.repositories == [own.clone()])
9452 .unwrap_or(incoming.repositories.is_empty());
9453 if derivable {
9454 metadata.remove(Repository::METADATA_KEY);
9455 } else {
9456 metadata.insert(
9457 Repository::METADATA_KEY.to_owned(),
9458 Value::Array(
9459 incoming
9460 .repositories
9461 .iter()
9462 .map(|repository| Value::String(repository.as_str().to_owned()))
9463 .collect(),
9464 ),
9465 );
9466 }
9467 // The typed lists are what land, whatever the caller's own metadata held under their
9468 // keys: a key of either name travelling beside the field would otherwise be a second
9469 // answer to the same question, and the field is the one the contract names.
9470 for (key, entries) in [
9471 (TaskRef::DELIVERS_KEY, incoming.delivers),
9472 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
9473 ] {
9474 set_task_list(&mut metadata, key, entries);
9475 }
9476 record_edges(&mut metadata, fallback);
9477 metadata
9478}
9479
9480/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
9481/// one slot's metadata, or no such key when there are none.
9482fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
9483 if fallback.is_empty() {
9484 metadata.remove(DependencyEdge::RECORDED_KEY);
9485 } else {
9486 metadata.insert(
9487 DependencyEdge::RECORDED_KEY.to_owned(),
9488 Value::Array(
9489 fallback
9490 .iter()
9491 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
9492 .collect(),
9493 ),
9494 );
9495 }
9496}
9497
9498/// Every label one item carries, from its content's own connection and nowhere else.
9499///
9500/// There is no second place to read one from: no document this source sends selects the
9501/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
9502/// cannot carry one at all. The module documentation records the three schema facts that
9503/// settle it.
9504fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
9505 optional_nodes(content.get("labels"), "content labels")?
9506 .into_iter()
9507 .flatten()
9508 .map(|v| {
9509 Ok(Label {
9510 id: NativeId(required_str(v, "id")?.to_owned()),
9511 name: required_str(v, "name")?.to_owned(),
9512 color: optional_str(v, "color")?.map(str::to_owned),
9513 })
9514 })
9515 .collect()
9516}
9517
9518/// The definition of each board field one item's values are values of, in the shape a read
9519/// of the board's own `fields` gives one.
9520///
9521/// A value names its field through a fragment on that field's own type, so the type is
9522/// known from which kind of value it is: a single-select value's field is a
9523/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
9524/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
9525fn field_definitions(field_values: &[Value]) -> Vec<Value> {
9526 field_values
9527 .iter()
9528 .filter_map(|value| {
9529 let field = value.get("field")?.as_object()?;
9530 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
9531 let typename = if value.get("text").is_some() {
9532 "ProjectV2Field"
9533 } else if value.get("name").is_some() {
9534 "ProjectV2SingleSelectField"
9535 } else {
9536 return None;
9537 };
9538 let mut defined = field.clone();
9539 defined.insert("__typename".to_owned(), json!(typename));
9540 Some(Value::Object(defined))
9541 })
9542 .collect()
9543}
9544
9545fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
9546 let Some(node) = field_values
9547 .iter()
9548 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
9549 else {
9550 return Ok(None);
9551 };
9552 Ok(optional_str(node, "text")?.map(str::to_owned))
9553}
9554
9555fn valid_github_owner(owner: &str) -> bool {
9556 !owner.is_empty()
9557 && owner.len() <= 39
9558 && !owner.starts_with('-')
9559 && !owner.ends_with('-')
9560 && !owner.contains("--")
9561 && owner
9562 .bytes()
9563 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
9564}
9565
9566/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
9567/// neither of the two names a path segment already means.
9568fn valid_github_repository_name(name: &str) -> bool {
9569 !name.is_empty()
9570 && name.len() <= 100
9571 && name != "."
9572 && name != ".."
9573 && name
9574 .bytes()
9575 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
9576}
9577
9578fn valid_environment_name(name: &str) -> bool {
9579 let mut bytes = name.bytes();
9580 bytes
9581 .next()
9582 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
9583 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
9584}
9585
9586/// How many sub-issues one issue has.
9587///
9588/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
9589/// absent or non-integer one is a response this source cannot read — and reading it as
9590/// zero would classify a project as a task, which is exactly the mistake the marker
9591/// exists to keep from happening quietly.
9592fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
9593 let summary = issue
9594 .get("subIssuesSummary")
9595 .ok_or_else(|| SourceError::Malformed {
9596 message: "GitHub issue is missing subIssuesSummary".into(),
9597 })?;
9598 summary
9599 .get("total")
9600 .and_then(Value::as_u64)
9601 .ok_or_else(|| SourceError::Malformed {
9602 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
9603 })
9604}
9605
9606/// One issue's own `number`.
9607///
9608/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
9609/// an issue in this module asks for it. So a read of one that comes back without it, or
9610/// with something that is not an unsigned integer, is a response this source cannot read —
9611/// absence here is **not** "this issue has no number". A draft is the content that has
9612/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
9613/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
9614fn issue_number(issue: &Value) -> Result<u64, SourceError> {
9615 issue
9616 .get("number")
9617 .and_then(Value::as_u64)
9618 .ok_or_else(|| SourceError::Malformed {
9619 message: "GitHub issue number is missing or is not an unsigned integer".into(),
9620 })
9621}
9622
9623/// The `number` a creating mutation answered with, and `None` when it answered without one;
9624/// why a missing one is tolerated is at the call in `create_and_file_issue`.
9625fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
9626 match created.get("number") {
9627 None | Some(Value::Null) => Ok(None),
9628 Some(value) => value
9629 .as_u64()
9630 .map(Some)
9631 .ok_or_else(|| SourceError::Malformed {
9632 message: "GitHub created issue number is not an unsigned integer".into(),
9633 }),
9634 }
9635}
9636
9637fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9638 value
9639 .get(field)
9640 .and_then(Value::as_str)
9641 .ok_or_else(|| SourceError::Malformed {
9642 message: format!("GitHub response is missing string field {field}"),
9643 })
9644}
9645
9646fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
9647 let found = required_str(value, field)?;
9648 if found.trim().is_empty() {
9649 return Err(SourceError::Malformed {
9650 message: format!("GitHub response has blank string field {field}"),
9651 });
9652 }
9653 Ok(found)
9654}
9655
9656/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
9657/// needs one — Linear spells them too, in its own description field.
9658///
9659/// Restated rather than shared, because a plugin crate depends on the contract crate and
9660/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
9661/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
9662/// source round-trips its own writes perfectly well under its own spelling.
9663const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
9664const METADATA_CLOSE: &str = "\n-->";
9665
9666/// What the composer puts between a non-empty visible body and the slot, and the one thing
9667/// the parser takes off the visible body when it takes the slot off — exactly once, so every
9668/// other trailing byte of the body comes back as it was written.
9669// 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.
9670const METADATA_SEPARATOR: &str = "\n\n";
9671
9672/// The visible body and the metadata slot at the end of it.
9673///
9674/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
9675/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
9676/// own content and is left alone. The visible body is everything before the slot less the
9677/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
9678fn metadata_body(
9679 body: Option<String>,
9680) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
9681 let Some(body) = body else {
9682 return Ok((None, BTreeMap::new()));
9683 };
9684 let Some(slot) = slot_span(&body)? else {
9685 return Ok((Some(body), BTreeMap::new()));
9686 };
9687 let metadata =
9688 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
9689 SourceError::Malformed {
9690 message: format!(
9691 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
9692 ),
9693 }
9694 })?;
9695 let before = &body[..slot.start];
9696 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
9697 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
9698}
9699
9700/// Where the metadata slot sits in one body, as byte offsets into it.
9701struct SlotSpan {
9702 /// Where [`METADATA_OPEN`] begins.
9703 start: usize,
9704 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
9705 encoded_start: usize,
9706 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
9707 encoded_end: usize,
9708 /// Just past [`METADATA_CLOSE`].
9709 end: usize,
9710}
9711
9712/// The slot at the very end of `body`, or `None` when it has none.
9713///
9714/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
9715/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
9716/// slot.
9717fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
9718 let Some(start) = body.rfind(METADATA_OPEN) else {
9719 return Ok(None);
9720 };
9721 let encoded_start = start + METADATA_OPEN.len();
9722 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
9723 return Err(SourceError::Malformed {
9724 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
9725 });
9726 };
9727 let encoded_end = encoded_start + relative_end;
9728 let end = encoded_end + METADATA_CLOSE.len();
9729 if !body[end..].trim().is_empty() {
9730 return Ok(None);
9731 }
9732 Ok(Some(SlotSpan {
9733 start,
9734 encoded_start,
9735 encoded_end,
9736 end,
9737 }))
9738}
9739
9740/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
9741/// slot as it was.
9742///
9743/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
9744/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
9745/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
9746/// or alone in an empty body — and a body with no slot that is given no metadata is
9747/// returned as it is.
9748fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
9749 let encoded = if metadata.is_empty() {
9750 None
9751 } else {
9752 Some(
9753 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9754 message: error.to_string(),
9755 })?,
9756 )
9757 };
9758 Ok(match (slot_span(body)?, encoded) {
9759 (Some(slot), Some(encoded)) => format!(
9760 "{}{encoded}{}",
9761 &body[..slot.encoded_start],
9762 &body[slot.encoded_end..]
9763 ),
9764 (Some(slot), None) => {
9765 let before = &body[..slot.start];
9766 format!(
9767 "{}{}",
9768 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
9769 &body[slot.end..]
9770 )
9771 }
9772 (None, None) => body.to_owned(),
9773 (None, Some(encoded)) if body.is_empty() => {
9774 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9775 }
9776 (None, Some(encoded)) => {
9777 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9778 }
9779 })
9780}
9781
9782/// `body` with everything before its metadata slot replaced by `content`, and the slot
9783/// itself kept byte for byte.
9784///
9785/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
9786/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
9787/// `content` is empty — so a read of the result reports `content` as the visible body and
9788/// the slot's metadata exactly as it was.
9789fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
9790 let Some(slot) = slot_span(body)? else {
9791 return Ok(content.to_owned());
9792 };
9793 let kept = &body[slot.start..];
9794 Ok(if content.is_empty() {
9795 kept.to_owned()
9796 } else {
9797 format!("{content}{METADATA_SEPARATOR}{kept}")
9798 })
9799}
9800
9801/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
9802fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
9803 if entries.is_empty() {
9804 metadata.remove(key);
9805 } else {
9806 metadata.insert(
9807 key.to_owned(),
9808 Value::Array(
9809 entries
9810 .iter()
9811 .map(|entry| Value::String(entry.as_str().to_owned()))
9812 .collect(),
9813 ),
9814 );
9815 }
9816}
9817
9818fn compose_body(
9819 content: Option<&str>,
9820 metadata: &BTreeMap<String, Value>,
9821) -> Result<Option<String>, SourceError> {
9822 let visible = content.unwrap_or_default();
9823 if metadata.is_empty() {
9824 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9825 }
9826 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9827 message: error.to_string(),
9828 })?;
9829 Ok(Some(if visible.is_empty() {
9830 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9831 } else {
9832 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9833 }))
9834}
9835
9836fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9837 value
9838 .get(field)
9839 .and_then(Value::as_bool)
9840 .ok_or_else(|| SourceError::Malformed {
9841 message: format!("GitHub response is missing boolean field {field}"),
9842 })
9843}
9844fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9845 match value.get(field) {
9846 None | Some(Value::Null) => Ok(None),
9847 Some(value) => value
9848 .as_str()
9849 .map(Some)
9850 .ok_or_else(|| SourceError::Malformed {
9851 message: format!("GitHub response field {field} is not a string or null"),
9852 }),
9853 }
9854}
9855fn optional_nodes<'a>(
9856 connection: Option<&'a Value>,
9857 name: &str,
9858) -> Result<Option<&'a Vec<Value>>, SourceError> {
9859 match connection {
9860 None | Some(Value::Null) => Ok(None),
9861 Some(value) => value
9862 .get("nodes")
9863 .and_then(Value::as_array)
9864 .map(Some)
9865 .ok_or_else(|| SourceError::Malformed {
9866 message: format!("GitHub {name}.nodes is not an array"),
9867 }),
9868 }
9869}
9870fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9871 let page_info = connection
9872 .get("pageInfo")
9873 .ok_or_else(|| SourceError::Malformed {
9874 message: format!("GitHub {name} has no pageInfo"),
9875 })?;
9876 if required_bool(page_info, "hasNextPage")? {
9877 return Err(SourceError::Malformed {
9878 message: format!(
9879 "GitHub {name} exceeds the supported nested connection size of {size}"
9880 ),
9881 });
9882 }
9883 Ok(())
9884}
9885fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9886 optional_str(value, field)?
9887 .map(|timestamp| {
9888 timestamp.parse().map_err(|error| SourceError::Malformed {
9889 message: format!("GitHub response field {field} is not a timestamp: {error}"),
9890 })
9891 })
9892 .transpose()
9893}
9894fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9895 if page.limit == 0 {
9896 Err(SourceError::Config {
9897 message: "page limit must be at least 1".into(),
9898 })
9899 } else {
9900 Ok(())
9901 }
9902}
9903fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9904 let page = connection
9905 .get("pageInfo")
9906 .filter(|value| value.is_object())
9907 .ok_or_else(|| SourceError::Malformed {
9908 message: "GitHub connection is missing pageInfo".into(),
9909 })?;
9910 if required_bool(page, "hasNextPage")? {
9911 let cursor = required_str(page, "endCursor")?;
9912 validate_cursor_progress(None, cursor)?;
9913 Ok(Some(Cursor(cursor.into())))
9914 } else {
9915 Ok(None)
9916 }
9917}
9918fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9919 if next.is_empty() || previous == Some(next) {
9920 Err(SourceError::Malformed {
9921 message: "GitHub pagination cursor is empty or did not advance".into(),
9922 })
9923 } else {
9924 Ok(())
9925 }
9926}
9927/// The version of this plugin's opaque narrowing-search cursor.
9928pub const SEARCH_CURSOR_VERSION: u32 = 4;
9929
9930#[derive(Serialize, Deserialize)]
9931#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9932enum SearchConnection {
9933 Initial {},
9934 Continuing { after: Cursor },
9935 Exhausted {},
9936}
9937impl SearchConnection {
9938 fn after(&self) -> Option<&str> {
9939 match self {
9940 Self::Continuing { after } => Some(&after.0),
9941 _ => None,
9942 }
9943 }
9944 fn exhausted(&self) -> bool {
9945 matches!(self, Self::Exhausted { .. })
9946 }
9947 /// Whether a cursor naming this position, `offset` rows into its page, is one this
9948 /// plugin could have handed out: a page is resumed only part of the way through it — an
9949 /// offset of a whole page or more would skip rows nobody was given — an initial page
9950 /// only once some of it was handed out, and an exhausted connection has no page to be
9951 /// part of the way through.
9952 fn valid_resume(&self, offset: usize) -> bool {
9953 let within = offset < SEARCH_PAGE_SIZE as usize;
9954 match self {
9955 Self::Initial { .. } => offset > 0 && within,
9956 Self::Continuing { after } => !after.0.is_empty() && within,
9957 Self::Exhausted { .. } => offset == 0,
9958 }
9959 }
9960}
9961
9962/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
9963#[derive(Serialize, Deserialize)]
9964#[serde(deny_unknown_fields)]
9965struct SearchPosition {
9966 version: u32,
9967 connection: SearchConnection,
9968 /// How many rows of the page `connection` starts were already handed out.
9969 #[serde(default, skip_serializing_if = "is_zero")]
9970 offset: usize,
9971 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9972 seen: Vec<NativeId>,
9973 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9974 own: Vec<NativeId>,
9975}
9976impl Default for SearchPosition {
9977 fn default() -> Self {
9978 Self {
9979 version: SEARCH_CURSOR_VERSION,
9980 connection: SearchConnection::Initial {},
9981 offset: 0,
9982 seen: Vec::new(),
9983 own: Vec::new(),
9984 }
9985 }
9986}
9987
9988fn is_zero(offset: &usize) -> bool {
9989 *offset == 0
9990}
9991
9992fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
9993 cursor.map_or(Ok(0), |c| {
9994 c.0.parse().map_err(|_| SourceError::Config {
9995 message: "page cursor is invalid".into(),
9996 })
9997 })
9998}
9999fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
10000 if offset > items.len() {
10001 return Page::last(vec![]);
10002 }
10003 let tail = items.split_off(offset);
10004 let mut selected = tail;
10005 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
10006 selected.truncate(limit);
10007 Page {
10008 items: selected,
10009 next,
10010 }
10011}