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:)` — and, when that node is a board draft, [`graphql::DRAFT`] — the draft and the one board item it is | the item |
183//! | 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` | the board's fields |
184//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
185//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
186//! | 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 |
187//! | 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 |
188//! | 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 |
189//! | 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 |
190//! | 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 |
191//!
192//! The following standalone-ticket requests are pinned by the real CLI fixture journey
193//! `follow_up_writes_resolve_each_item_once_and_batch_the_copy_fields`. They include the
194//! origin lookup and field/repository discovery a create needs. A bound re-copy changes
195//! status, priority, content and metadata; comment recount means a subsequent detail read.
196//! Each request here costs one declared point. A membership beyond the embedded page can
197//! additionally require the one-point membership recovery described above.
198//!
199//! | Verb | Requests / points | Documents |
200//! | --- | --- | --- |
201//! | new copy | 6 | ORIGIN_LOOKUP, BOARD_FIELDS, REPOSITORY, CREATE_ISSUE, ADD_TO_BOARD, UPDATE_FIELDS |
202//! | bound copy | 5 | ISSUE, BOARD_FIELDS, ISSUE_DEPENDENCIES, UPDATE_ISSUE, UPDATE_FIELDS |
203//! | comment | 2 | ISSUE, ADD_COMMENT |
204//! | recount | 2 | ISSUE, ISSUE_COMMENTS |
205//! | status | 2 | ISSUE, UPDATE_FIELD; a terminal status additionally updates issue state |
206//! | priority | 2 | ISSUE, UPDATE_FIELD or CLEAR_FIELD, with stored priority in the mutation response |
207//! | content | 2 | ISSUE, UPDATE_ISSUE |
208//! | metadata | 2 | ISSUE, UPDATE_ISSUE |
209//! | record only | 1 | ISSUE |
210//!
211//! <!-- github-search-paging:start -->
212//! Board-scoped text, metadata, project-name and comment-activity searches send every
213//! page at `first = 20` (SEARCH_PAGE_SIZE), the SEARCH_ISSUES document's one-point
214//! ceiling. A later page is sent only when `hasNextPage` is true and the caller still
215//! needs rows. A page is never resized to the rows still needed: GitHub orders one
216//! search differently at different page sizes, so one fixed size makes a paged walk
217//! send exactly the requests one whole read sends, and the answer's order is the order
218//! those pages arrive in. A page below twenty would cost the same one point, and GitHub
219//! prices this document by rows, so twenty-row pages cost per row what 100-row pages do.
220//! Project-name lookup continues until an exact match or exhaustion. A task limit bounds
221//! returned and fetched pages: a limit is sliced from the pages it needs, and local
222//! confirmation can require more candidates than matching rows. Walking all pages
223//! returns the whole answer. The opaque version-4 source cursor carries GitHub's page
224//! cursor and how far into that page the last answer stopped, and resumes in the same
225//! process or a new one, without duplicates or gaps. It carries no rows: one process
226//! sends each page's search once, and a new process re-reads only the page it resumes
227//! in, then sends a further page once, never as a re-read, only when its limit still
228//! needs rows. Every request either walk sends is the one a whole read sends for that page. Own writes replace stale index
229//! copies and complete missing rows at exhaustion. Cache entries are whole GitHub pages,
230//! so a small answer cannot truncate a wider question. Origin pages remain three; whole-board sizing is unchanged.
231//! Read-after-write is a per-process guarantee. A cursor resumed in a new process is
232//! not required to include the original process's writes still omitted by the index.
233//! <!-- github-search-paging:end -->
234//!
235//! The board half of an issue — its board item's id, its `Status` option and this
236//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
237//! an item reached any of those ways resolves through the same
238//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
239//! same status, the same labels and the same qualified id. That connection comes back a
240//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
241//! on the page in hand and — only if that page reports more of the connection — in the
242//! last row's read of that one issue's memberships, resumed from the page's own cursor and
243//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
244//! report, which is what keeps an id naming another repository's issue from being answered
245//! as an item of this board; and because the page is where the search starts rather than
246//! where it ends, that answer is one about a connection read to exhaustion and never about
247//! an unread page. Nothing costs the extra read but an issue on more boards than a page
248//! holds: an issue this board really does not hold reports no next page, so its
249//! memberships are already exhausted where they arrived.
250//!
251//! **No document here selects the board's own `Labels` field, and nothing is lost by
252//! that.** An item's labels are read from its content alone, wherever that content is
253//! reached: the three documents above select `Issue.labels` on the fragment, and
254//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
255//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
256//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
257//! create one, and `ProjectV2FieldValue` — the whole of what
258//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
259//! it from the content, for every content type it exists on, and there is nothing it can
260//! hold that the content does not already say: for an `Issue` it *is* that issue's own
261//! labels, so selecting it beside them unions a set with itself.
262//!
263//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
264//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
265//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
266//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
267//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
268//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
269//! label set, and that set is the fixture issue's own, by
270//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
271//! item whose content is a draft reports an empty set, by
272//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
273//! selection is held over [`graphql::DOCUMENTS`] by
274//! `no_document_selects_the_boards_own_labels_field`.
275//!
276//! The whole-board row is still the board's own item connection, and deliberately: a
277//! **draft** board item is not an issue, so no search can list one, and the reads that have
278//! to answer for the whole board are the ones whose cost is the board's size anyway.
279//!
280//! **A question about one item this source already names by id never lists the board.**
281//! Whether that item is on this board, and what its board fields are, is answered by reading
282//! that item — its own `Issue.projectItems`, walked to exhaustion by
283//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
284//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
285//! holds. That covers a write's destination, the project a new item is filed under, a
286//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
287//! and the delete that takes back an item a copy made. What such a write needs of the board
288//! and the item does not carry — the board's id, the `Status` and origin field definitions —
289//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
290//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
291//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
292//! minutes. Scanning this host's 842-item board has refused a document copy and an update
293//! even though the items' own reads named that board. A scan there gives the wrong answer
294//! as well as paying for every page. So a `board.items` lookup does not belong on any of
295//! those paths.
296//!
297//! **What a read may return is capped too, and that cap is on the document rather than on
298//! the board.** GitHub limits the number of nodes **one query may return** to
299//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
300//! an error naming the connection the count crossed at, not a slow or a partial result.
301//! Every board this source reads is refused the same way, so no board is too big for these
302//! documents and none is small enough to save one that is over.
303//!
304//! The count is arithmetic over the document's own text: each connection contributes the
305//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
306//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
307//! restate them — `github-graphql-node-count` implements them, and
308//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
309//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
310//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
311//! same text on every run and fails naming any that reaches the limit — so a connection
312//! added to a shared fragment is caught there rather than by GitHub.
313//!
314//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
315//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
316//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
317//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
318//! effectively squared there, which is why it is the one the limit is most sensitive to.
319//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
320//! page of memberships misses is recovered by one further read rather than refused, so it
321//! buys a bound every read pays for at the price of a request only a multi-board issue
322//! pays.
323//!
324//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
325//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
326//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
327//! `cost` is rate-limit points, metered per hour across everything one credential does; it
328//! is what the two limiters [`Limiter`] tells apart meter, and a document under
329//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
330//! that second number, and `tests/point_cost.rs` pins every document in
331//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
332//! one under, the pin itself is the check. The credentialed lane reconciles both figures
333//! against GitHub's own, off a probe it already sends.
334//!
335//! **What is pinned that way is a per-document price and never a session's.** The record in
336//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
337//! **requests** and **worst-case nodes** — and neither is points. What one whole session
338//! consumes of the hourly point allowance is observable only from a credentialed run's own
339//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
340//! and what `tests/live.rs` prints at the end of every run.
341//!
342//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
343//!
344//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
345//! enumerations of a board can supply one alone.** Resolving a node id is strongly
346//! consistent, so a read by id and a project's own sub-issues are already current. The
347//! other two are not, and they are behind by different amounts and in different directions:
348//!
349//! - GitHub's **issue search** is an index and answers a write made moments ago with the
350//! value from before it — usually for a second or two.
351//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
352//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
353//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
354//! for *minutes*, while `Issue.projectItems` names the same membership at once.
355//!
356//! That second one is a measurement rather than a caution. This repository's own
357//! credentialed journey writes a project and waits for the board to report it, then writes a
358//! task and waits for the same thing seconds later on the same board: the project wait is
359//! answered through the search and converged in two or three attempts in each of three runs,
360//! and the task wait is answered through `ProjectV2.items` and converged in none of them
361//! inside thirty. Separately, an item added to a second and larger board was read back by
362//! `Issue.projectItems` on that board's own id while every one of that connection's nine
363//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
364//! through the lagging one alone is what had a board read deny an issue that had certainly
365//! landed on it.
366//!
367//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
368//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
369//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
370//! search reports what the projection is behind on. What closes the last
371//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
372//! source answers is completed with what this process itself wrote, so an item created
373//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
374//! nothing is written down, and the record dies with the process. **A wait that has to
375//! observe GitHub's own data cannot be answered from that record** — which is why the
376//! credentialed journey asks through a source built afresh, and why the union above rather
377//! than a longer wait is what makes such a wait converge.
378//!
379//! **A narrowed read is the same bargain, stated for each of the three predicates it
380//! answers.** A read carrying a text, metadata or origin predicate asks GitHub's index rather
381//! than walking the board, and every such answer is completed with what this process wrote —
382//! its [`created`](GitHubProjectsSource::created) record and every existing item it wrote,
383//! each filtered by the same predicates as the rest — so an item this command wrote a moment
384//! ago is returned by a query that matches it whether or not the index has caught up. An item
385//! a caller holds the id of is read by that id, with `node(id:)`, which is strongly
386//! consistent. What is left is stated rather than papered over:
387//!
388//! | Read | Finds | Behind by |
389//! | --- | --- | --- |
390//! | text, metadata | the issue search for the phrases | what another process wrote in the last second or two, until GitHub indexes it |
391//! | 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 |
392//! | 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 |
393//! | origin, third read | this process's own writes | nothing |
394//!
395//! So an origin carrier another process added within the last second or two, before either
396//! index has it, can be missing from an origin query, and one written by the release before
397//! this one — its origin in the field alone — can be missing for as long as the board's own
398//! item connection is behind on it. A copy that must not duplicate its own earlier write
399//! relies on the link it records, not on either index. **A board draft is not an issue**, so
400//! a draft is never returned by a text, metadata or origin query, whatever it holds: no search
401//! lists one, the origin lookup drops any the board's own field filter names, and one this
402//! process wrote is not added back either.
403//!
404//! **The origin lives in the board field, and the body holds a mirror of it.** A write that
405//! carries an origin writes it to the `onetaskgraph.origin` text field and also into the
406//! body's metadata slot, so the issue search can find it in seconds. The field is
407//! authoritative: this source reads an item's origin from the field alone, so a slot that
408//! disagrees with it, or holds one where the field holds none, is never read as a second
409//! origin — and the release before this one reads the slot, drops that key's copy for the
410//! field's, and sees the same one origin.
411//!
412//! Filtering happens before paging, so a page of a filtered result is a page of the
413//! survivors rather than the survivors of a page. Label matching and the substring rule a
414//! text candidate is confirmed by answer the same question the same way the local Markdown
415//! source's do; which candidates a text search has to confirm is GitHub's word match, which
416//! is the one place the two sources can answer the same text differently.
417//!
418//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
419//! has one source, `capabilities`, and the note above is the reasoning behind it rather
420//! than a second copy of it: without the three facts recorded here a reader takes the
421//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
422//! crate's own capabilities test, which pins every field of it against a fully spelled-out
423//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
424//! fails to compile there rather than going unasserted. -->
425//! The fixture-server tests above run wherever this crate is selected; the credentialed
426//! lane runs in the same required check, beside them, and can fail it — it verifies the
427//! 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,
428//! one filed under neither, a label on one of the three and a closed status on another —
429//! because that shape is what tells an honoured predicate from an ignored one: a board
430//! holding a single project answers a project filter the same way whether or not this
431//! source applies it, which is exactly how the defect above went unseen.
432//!
433//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
434//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
435//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
436//! nominated is what keeps a credentialed write lane off a board and a repository nobody
437//! nominated; it never asks GitHub which project was updated most recently. Before it
438//! starts, the lane also clears any item titled — and any repository label named — the way
439//! it titles and names its own artifacts, which is self-healing after an interrupted run:
440//! a process killed between its writes and its cleanup leaves artifacts the next run
441//! removes.
442//!
443//! # What a session of requests costs, and where the report is
444//!
445//! This source records **every** request it sends into [`accounting::Accounting`], at
446//! `send_once` — the one place a request leaves this crate, which is why a read path added
447//! later is counted without anybody remembering to count it. That is the whole of what this
448//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
449//! session's spend is arrived at, and what it deliberately does not know are set out.
450//!
451//! What one whole session of the live journey costs, counted that way against this crate's
452//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
453//! reduction it came out of, and with what it does and does not say about rate-limit points.
454//!
455//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
456//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
457//! code path — no environment variable, no feature, no build configuration — because an
458//! instrument nobody switches on measures nothing, and
459//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
460//! source's counts the whole session rather than this source's share. The credentialed lane
461//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
462//! passed or failed.
463//!
464//! **A live session refuses to start unless the account can afford it.** Before it does any
465//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
466//! GitHub documents as not counting against the REST rate limit and which answers both of
467//! its budgets at once — and starts only if, for each of them, what remains minus this
468//! session's estimated cost is still at least
469//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
470//! allowance. A session that cannot **declines**: it did not run, so it is
471//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
472//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
473//! waiting for the budget to come back. The estimate is derived offline from
474//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
475//! which is also where the published rule that model rests on is cited; the accounting
476//! above records the gate's own read like any other request, and
477//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
478//!
479//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
480//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
481//! text, which is what lets it run on every platform and on a pull request from a fork with
482//! no credential — and that is what actually stops a regression merging. But an offline
483//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
484//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
485//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
486//! number of nodes this query may return"* and whose `cost` is what that document would
487//! spend, both for a document **without executing it**, and the lane asks it for every query
488//! document this source sends, under the largest bindings this source sends, and fails when
489//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
490//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
491//! one; the offline pins still cover it. It records what those calls reported about the
492//! account's own allowance, because whether asking is free is a thing to observe rather than
493//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
494//! and `cost` is metered against an hourly allowance the accounting above reads off a
495//! credentialed run's own response headers.
496//!
497//! **GitHub has two rate limiters and this source is refused by both, so nothing here
498//! treats them as one thing.** The primary budget is the hourly allowance `gh api
499//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
500//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
501//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
502//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
503//! one part of.
504#![deny(missing_docs)]
505
506use std::collections::BTreeMap;
507use std::sync::{Arc, Mutex};
508use std::time::{Duration, Instant};
509
510use chrono::{DateTime, Utc};
511use onetaskgraph_plugin_api::{
512 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
513 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
514 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
515 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
516 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskRef,
517 TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
518};
519use reqwest::{Client, StatusCode, Url};
520use schemars::{Schema, schema_for};
521use secrecy::{ExposeSecret, SecretString};
522use serde::{Deserialize, Serialize};
523use serde_json::{Value, json};
524
525pub mod accounting;
526
527use accounting::Accounting;
528
529/// The registry name for this plugin.
530pub const KIND: &str = "github-projects";
531/// GitHub's maximum connection page size.
532pub const MAX_PAGE_SIZE: u32 = 100;
533/// Every page of a board-scoped narrowing search: 20 rows, one point of SEARCH_ISSUES, the
534/// most one point buys. GitHub prices that document by rows, so pages of 20 cost what pages
535/// of 100 cost per row, and a page of fewer than 20 costs the same one point.
536pub const SEARCH_PAGE_SIZE: u32 = 20;
537
538/// The most nodes any one document this source sends may be asked to return.
539///
540/// GitHub's own published per-query ceiling, taken from
541/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
542/// workspace cannot hold a stale copy of somebody else's number. A query above it is
543/// **refused before it is executed**, whoever is asking and whatever board they are
544/// asking about — so this is a bound on the documents rather than a budget that runs out.
545///
546/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
547/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
548/// everything the credential does — two numbers against two limits, and this constant
549/// bounds only the first. The second is computed offline too, per document:
550/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
551/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
552/// lane. There is no constant like this one to hold a price under, because points are an
553/// hourly allowance rather than a per-call bound.
554///
555/// Neither is a session's price. What `session-cost.md` records of a whole session is its
556/// **requests** and its **worst-case nodes**; what a whole session spends in points is
557/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
558/// [`accounting`]. The module section on the three ways this source reaches an item says how
559/// the count is arrived at, and which of the page sizes below decide it.
560pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
561
562/// Nested connection size for the connections that hang off one item.
563///
564/// It multiplies through every document that reaches an item under a page — the count
565/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
566/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
567/// every document under these constants and fails naming any that reaches the limit, so
568/// raising this is caught there rather than by GitHub.
569const NESTED_PAGE_SIZE: u32 = 50;
570/// How many of one issue's board memberships are read when an issue is reached directly.
571///
572/// An issue reached through a search or through its own node id carries its board half in
573/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
574/// under a page of issues, so every point of it multiplies through the whole document and
575/// is paid for whether or not any issue is on a second board — which is why it is
576/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
577///
578/// **Three, because what a page misses is now recovered rather than refused**, and the
579/// recovery is what the value is chosen against. An issue whose entry for this board sits
580/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
581/// that page's own cursor — so the value trades a bound every read pays for a request only
582/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
583/// boards would pay that request *per issue*, which is order N against the one page per
584/// hundred issues a read costs today. At three it is only reached by an issue on four or
585/// more boards at once, which keeps the recovery path exceptional rather than routine for
586/// a plausible deployment.
587const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
588/// How many carriers of one copy origin one page of [`graphql::ORIGIN_LOOKUP`] asks each of
589/// its two connections for.
590///
591/// An origin names one item, so the answer an origin lookup expects is one carrier, and a
592/// second is a duplicate a copy already takes the first of. Both connections are walked to
593/// exhaustion whatever this is, so it decides how many requests an unusual answer costs and
594/// never what the answer is. It is small because every point of it is paid on every lookup,
595/// and a copy makes one lookup per item it has no link for: at three, ten lookups cost fewer
596/// worst-case nodes than the one whole-board read they replaced.
597const ORIGIN_PAGE_SIZE: u32 = 3;
598
599pub use github_graphql_node_count::{NodeCountError, Variables};
600
601/// The largest value this source can bind to each page-size variable its documents name.
602///
603/// Every `first:` in [`graphql`] reads one of these four, and each is capped at the
604/// constant above it wherever a caller's own limit could reach it — `$first` at
605/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
606/// `BOARD_ITEMS_PAGE_SIZE`, `$originFirst` at `ORIGIN_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
607/// not one configuration of it, which is what makes a bound computed under it a bound on
608/// every read.
609pub fn largest_page_sizes() -> Variables {
610 Variables::from([
611 ("first".to_owned(), MAX_PAGE_SIZE),
612 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
613 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
614 ("originFirst".to_owned(), ORIGIN_PAGE_SIZE),
615 ])
616}
617
618/// The most nodes `document` could be asked to return, by GitHub's published rules.
619///
620/// Computed offline from the document's own text under [`largest_page_sizes`] — no
621/// network, no credential and no schema — by
622/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
623/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
624/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
625///
626/// # Errors
627///
628/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
629/// no single operation, or binds a page size this source does not name — each of which is
630/// a defect in the document rather than a number.
631pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
632 node_count(document, &largest_page_sizes())
633}
634
635/// The most rate-limit points one call of `document` could spend, by GitHub's published
636/// rules.
637///
638/// Computed offline from the document's own text under [`largest_page_sizes`] — no
639/// network, no credential and no schema — by
640/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
641/// This is `cost`, metered **per hour** against the allowance one credential shares across
642/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
643/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
644/// under, so what `tests/point_cost.rs` does with it is pin every document in
645/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
646/// figures against GitHub's own reported `cost`.
647///
648/// # Errors
649///
650/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
651/// no single operation, or binds a page size this source does not name — each of which is
652/// a defect in the document rather than a number.
653pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
654 github_graphql_node_count::point_cost(document, &largest_page_sizes())
655}
656
657/// The most nodes `document` could be asked to return under `variables`.
658///
659/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
660/// [`accounting`] is this under the bindings one request really sent — one spelling of the
661/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
662/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
663///
664/// # Errors
665///
666/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
667/// no single operation, or binds a page size `variables` does not name.
668pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
669 github_graphql_node_count::node_count(document, variables)
670}
671
672/// The issue-title prefix that makes a board issue a document.
673///
674/// A GitHub Projects board has no document type — it holds issues — so the discriminator
675/// is the title, and this is the whole of it: an issue whose title begins with these bytes
676/// is a document and every other issue is the task or project the sub-issue rule makes it.
677///
678/// It is spelled **once**, here, and read rather than restated everywhere else — including
679/// by the shared journeys, which take it from this constant so a board fixture cannot
680/// drift from what this source reads. `docs/metadata.md` records the two consequences that
681/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
682/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
683/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
684pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
685
686/// Exact GraphQL query documents issued by this plugin.
687///
688/// Keeping the production documents here lets the pinned-schema test validate the same
689/// bytes that are sent to GitHub, rather than a test-only copy which could drift
690/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
691/// field, and its guarded caller always supplies the complete existing option set with ids.
692pub mod graphql {
693 /// The board half of one item: the field values every document here reads it from.
694 ///
695 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
696 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
697 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
698 /// *the same value*, because
699 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
700 /// one path. Three spellings of it is what would drift, so there is one.
701 ///
702 /// The `Status` option and this source's own origin text field are the whole of it. It
703 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
704 /// content, so it holds nothing the content's own `labels` do not already say, and it
705 /// would sit a label connection two page sizes deep.
706 macro_rules! board_item_values {
707 () => {
708 r#"fieldValues(first:$nestedFirst){nodes{
709 ... on ProjectV2ItemFieldSingleSelectValue{name field{
710 ... on ProjectV2SingleSelectField{id name options{id name}}
711 }}
712 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
713 }pageInfo{hasNextPage}}"#
714 };
715 }
716
717 /// Everything this source reads about one issue, wherever it reaches that issue.
718 ///
719 /// A macro rather than a constant so the three documents below can `concat!` it: one
720 /// spelling of these fields is what makes an issue read through the board-scoped
721 /// search, through its own node id, and through its project's sub-issue relationship
722 /// resolve to *the same* item, which is the whole of what
723 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
724 ///
725 /// `projectItems` is what carries the board half of an issue: the board item's own id
726 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
727 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
728 /// issue rather than on the board, which is what makes the cost of a read proportional
729 /// to what was asked for instead of to the board's size.
730 ///
731 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
732 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
733 /// not on that page: a page here is where the search for the entry starts rather than
734 /// where it ends.
735 ///
736 /// It does **not** select the board's `Labels` field value, and that is the whole of
737 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
738 /// a label connection there sits under `fieldValues` under `projectItems` under a page
739 /// of issues, spending `$nestedFirst` twice down one path, and took
740 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
741 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
742 /// above, and that connection is where every label this source reports comes from. No
743 /// document in this module selects the board field any longer, [`BOARD`] included; the
744 /// module documentation records why nothing it could have held is lost.
745 macro_rules! board_issue {
746 () => {
747 concat!(
748 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
749 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
750 projectItems(first:$boardItems){nodes{id project{id number}
751 "#,
752 board_item_values!(),
753 r#"}pageInfo{hasNextPage endCursor}}}"#
754 )
755 };
756 }
757
758 /// Every issue of one board, found by a search scoped to that board.
759 ///
760 /// This is how the projects a board holds are listed, and it selects no `items`
761 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
762 /// container walked page by page, so nothing nested inside a board item is paid for.
763 /// Which of the issues it returns is a project is then read off `parent` — GitHub
764 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
765 /// discriminator has to be applied to the field, which is a scalar on the issue and
766 /// costs nothing.
767 pub const SEARCH_ISSUES: &str = concat!(
768 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
769 search(query:$search,type:$type,first:$first,after:$after){
770 pageInfo{hasNextPage endCursor}
771 nodes{__typename ...BoardIssue}
772 }
773 }"#,
774 board_issue!()
775 );
776
777 /// One issue by its own node id, which is what a qualified id names here.
778 ///
779 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
780 /// answers a write made moments ago with the value from before it, and resolving a node
781 /// id does not.
782 pub const ISSUE: &str = concat!(
783 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
784 node(id:$id){__typename ...BoardIssue}
785 }"#,
786 board_issue!()
787 );
788
789 /// One project's tasks: the sub-issues of the issue that project is.
790 ///
791 /// The work this costs is the project's own size. Nothing about it grows as the board
792 /// gains projects, or as those projects gain tasks.
793 pub const SUB_ISSUES: &str = concat!(
794 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
795 node(id:$id){__typename
796 ... on Issue{subIssues(first:$first,after:$after){
797 pageInfo{hasNextPage endCursor}
798 nodes{__typename ...BoardIssue}
799 }}}
800 }"#,
801 board_issue!()
802 );
803
804 /// What a read of the board's own `items` selects of each item's content.
805 ///
806 /// A macro for the reason [`board_item_values!`] is one: [`BOARD`] and [`ORIGIN_LOOKUP`]
807 /// both walk `ProjectV2.items` and hand each item to one resolver, so they select its
808 /// content by one spelling.
809 macro_rules! board_item_content {
810 () => {
811 r#" content{
812 ... 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}}}
813 ... on PullRequest{__typename id}
814 ... on DraftIssue{__typename id title body createdAt updatedAt}
815 }"#
816 };
817 }
818
819 /// Reads the board's fields and one page of its items.
820 pub const BOARD: &str = concat!(
821 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
822 owner:repositoryOwner(login:$owner){
823 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
824 }
825 } fragment Board on ProjectV2 { id title
826 fields(first:$nestedFirst){nodes{
827 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
828 ... on ProjectV2Field{__typename id name}
829 }pageInfo{hasNextPage}}
830 items(first:$first,after:$after){nodes{id "#,
831 board_item_values!(),
832 board_item_content!(),
833 r#"} pageInfo{hasNextPage endCursor}}
834 }"#
835 );
836
837 /// Every carrier of one copy origin, by two reads in one request, and nothing else of
838 /// the board.
839 ///
840 /// **`originItems`** is the board's own items narrowed by its own field filter —
841 /// `ProjectV2.items(query:)`, which GitHub's schema declares as "Search query for
842 /// filtering items" — to those whose `onetaskgraph.origin` text field holds the
843 /// qualified id, quoted. It reads the field every carrier already holds, whichever release
844 /// wrote it, and matches it exactly: measured on 2026-09-29 against a 394-item board,
845 /// the quoted, the unquoted and the bare-value spellings each returned exactly the one
846 /// carrier and a prefix of the value returned none. It is `ProjectV2.items`, so it lags a
847 /// fresh `addProjectV2ItemById` the way that connection does.
848 ///
849 /// **`search`** is the board-scoped issue search for the same id as a quoted phrase in
850 /// the body, which is where this source mirrors the origin into its metadata slot. GitHub
851 /// indexes that comment, and the index catches up with a write in a second or two rather
852 /// than in minutes, so it finds a carrier another process wrote that the first read is
853 /// still behind on.
854 ///
855 /// Each connection pages at `$originFirst`, its own small size — see `ORIGIN_PAGE_SIZE`
856 /// — and resumes from its own cursor; a connection already walked to its end is resumed
857 /// from its last cursor, which answers an empty page. Every candidate either read returns
858 /// is confirmed against its own origin field before it is reported, so a token match of
859 /// the search or anything else the filter admits never is.
860 ///
861 /// The root is aliased `originItems` rather than `owner`, so nothing counting the board's
862 /// own whole reads counts this one among them.
863 pub const ORIGIN_LOOKUP: &str = concat!(
864 r#"query($owner:String!,$number:Int!,$filter:String!,$search:String!,$type:SearchType!,$originFirst:Int!,$itemsAfter:String,$searchAfter:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
865 originItems:repositoryOwner(login:$owner){
866 ... on ProjectV2Owner{projectV2(number:$number){
867 items(first:$originFirst,after:$itemsAfter,query:$filter){nodes{id "#,
868 board_item_values!(),
869 board_item_content!(),
870 r#"} pageInfo{hasNextPage endCursor}}
871 }}
872 }
873 search(query:$search,type:$type,first:$originFirst,after:$searchAfter){
874 pageInfo{hasNextPage endCursor}
875 nodes{__typename ...BoardIssue}
876 }
877 }"#,
878 board_issue!()
879 );
880
881 /// The board's own id and field definitions, and not one of its items.
882 ///
883 /// What a write needs of the board when the item it writes does not say: the id a field
884 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
885 /// origin fields. It selects no `items`, so what it costs is the board's field list
886 /// however many items the board holds — and it decides nothing about which items those
887 /// are, which is the question a read of one item by its own id answers instead.
888 ///
889 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
890 /// board's item reads by their root counts this one among them.
891 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
892 boardFields:repositoryOwner(login:$owner){
893 ... on ProjectV2Owner{projectV2(number:$number){id
894 fields(first:$nestedFirst){nodes{
895 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
896 ... on ProjectV2Field{__typename id name}
897 }pageInfo{hasNextPage}}
898 }}
899 }
900 }"#;
901
902 /// One board draft by its own node id, with the board item it sits in.
903 ///
904 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
905 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
906 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
907 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
908 /// board listing hands it to, and nothing has to list the board to find one.
909 pub const DRAFT: &str = concat!(
910 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
911 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
912 projectV2Items(first:$boardItems){nodes{id project{id number}
913 "#,
914 board_item_values!(),
915 r#"}pageInfo{hasNextPage endCursor}}}}
916 }"#
917 );
918
919 /// One issue's board memberships alone, walked past the page a read of it carried.
920 ///
921 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
922 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
923 /// boards than that page holds may have this board's entry past its end. This asks that
924 /// one issue for its memberships and nothing else — the caller already holds the issue —
925 /// so an answer of "this board does not hold it" is only ever given about a connection
926 /// read to exhaustion.
927 ///
928 /// It selects the board item's id, its project number and the same
929 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
930 /// very same resolver: an issue recovered this way reports the same title, the same
931 /// status, the same labels and the same qualified id as one whose entry was on the
932 /// page.
933 ///
934 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
935 /// multiplies through it and the membership connection can be walked at
936 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
937 /// further request for any issue a person really keeps.
938 pub const ISSUE_BOARD_ITEMS: &str = concat!(
939 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
940 node(id:$id){
941 ... on Issue{projectItems(first:$first,after:$after){
942 nodes{id project{id number}
943 "#,
944 board_item_values!(),
945 r#"}
946 pageInfo{hasNextPage endCursor}}}
947 }
948 }"#
949 );
950 /// Resolves the configured repository's node id, which creating an issue requires.
951 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
952 /// Reads both dependency directions for one issue, with each far end's own kind — and
953 /// the issue's own body, which is where an edge to another source is recorded, so that
954 /// half of a dependency read needs no second read of the issue or of the board.
955 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
956 ... on Issue{body
957 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
958 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
959 }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
960 /// Creates one issue in the configured repository.
961 pub const CREATE_ISSUE: &str =
962 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
963 /// Puts an existing issue on the configured board.
964 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
965 /// Updates an issue's visible fields and its open or closed state in one call.
966 pub const UPDATE_ISSUE: &str =
967 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
968 /// Updates an existing draft's user-visible fields.
969 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
970 /// Updates a text or single-select value on one project item.
971 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}}}}}}}}"#;
972 /// Writes up to three board fields and an optional clear in one ordered mutation.
973 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}}}"#;
974 /// Clears one project item's value of one field, which is what a `none` priority is.
975 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}}}}}}}}"#;
976 /// Creates one single-select field with its options. Only the guarded field setup may use
977 /// this document, and only for a field the board lacks.
978 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
979 /// Replaces a single-select field's options. Only the guarded field setup — the
980 /// `status-options` and `fields` operations — may use this document, because GitHub
981 /// treats the input as the complete option list.
982 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
983 /// A fresh snapshot of the Status field and every board item's assignment.
984 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}}}}}}"#;
985 /// Files one issue under another as a sub-issue, which is what project membership is.
986 pub const ADD_SUB_ISSUE: &str =
987 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
988 /// Takes one issue back out of its parent.
989 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
990 /// Adds GitHub's native issue blocked-by relationship.
991 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
992 /// Removes one native issue blocked-by relationship.
993 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
994 /// Deletes one issue, which takes its board item with it.
995 ///
996 /// The engine sends this in one situation only: undoing a copy that could not finish,
997 /// over the items that same copy created. Deleting the issue removes the board item
998 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
999 pub const DELETE_ISSUE: &str =
1000 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
1001
1002 /// Everything this source reads about one issue comment, wherever it reaches one.
1003 ///
1004 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
1005 /// and a comment just edited are handed to one mapper, so they are selected by one
1006 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
1007 /// longer exists, and `login` is the one member every kind of actor carries.
1008 macro_rules! issue_comment {
1009 () => {
1010 "id author{login} createdAt updatedAt body url"
1011 };
1012 }
1013
1014 /// One task's comments: a page of its issue's own `comments` connection.
1015 ///
1016 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
1017 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
1018 /// list every time somebody edited it; left unordered the connection answers in the order
1019 /// the comments were written, which is the order GitHub documents for the same collection
1020 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
1021 /// node count and the caller's own page size is pushed straight down.
1022 pub const ISSUE_COMMENTS: &str = concat!(
1023 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
1024 issue_comment!(),
1025 r#"}pageInfo{hasNextPage endCursor}}}}}"#
1026 );
1027 /// Which issue one comment is on, read before that comment is edited or removed.
1028 ///
1029 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
1030 /// comment id given against the wrong task would change a comment on another issue.
1031 pub const COMMENT_ISSUE: &str =
1032 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
1033 /// Adds one comment to an issue, signed as the account the token belongs to.
1034 pub const ADD_COMMENT: &str = concat!(
1035 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
1036 issue_comment!(),
1037 r#"}}}}"#
1038 );
1039 /// Replaces the body of one issue comment.
1040 pub const UPDATE_COMMENT: &str = concat!(
1041 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
1042 issue_comment!(),
1043 r#"}}}"#
1044 );
1045 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
1046 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
1047
1048 /// Every document above, with what this source is doing when it sends one.
1049 ///
1050 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
1051 /// name the call that was refused, and a `match` with a catch-all arm would answer a
1052 /// document added later with "talking to GitHub" and never say so.
1053 ///
1054 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
1055 /// const` here that this list omits, so the two cannot part — which is the same guard
1056 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
1057 pub const DOCUMENTS: [(&str, &str); 30] = [
1058 (SEARCH_ISSUES, "searching this board's issues"),
1059 (ISSUE, "reading one issue"),
1060 (
1061 ISSUE_BOARD_ITEMS,
1062 "reading one issue's board memberships past the page it came with",
1063 ),
1064 (SUB_ISSUES, "reading a project's tasks"),
1065 (BOARD, "reading the board"),
1066 (ORIGIN_LOOKUP, "looking up the items copied from one origin"),
1067 (BOARD_FIELDS, "reading the board's fields"),
1068 (DRAFT, "reading one draft"),
1069 (REPOSITORY, "reading the destination repository"),
1070 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
1071 (CREATE_ISSUE, "creating an issue"),
1072 (ADD_TO_BOARD, "adding an issue to the board"),
1073 (UPDATE_ISSUE, "updating an issue"),
1074 (UPDATE_DRAFT, "updating a draft item"),
1075 (UPDATE_FIELD, "writing a board field"),
1076 (UPDATE_FIELDS, "writing board fields together"),
1077 (CLEAR_FIELD, "clearing a board field"),
1078 (
1079 CREATE_FIELD,
1080 "creating a board single-select field with its options",
1081 ),
1082 (
1083 STATUS_OPTIONS_SNAPSHOT,
1084 "snapshotting board Status options and assignments",
1085 ),
1086 (
1087 STATUS_OPTIONS_UPDATE,
1088 "safely replacing the board Status option list",
1089 ),
1090 (ADD_SUB_ISSUE, "filing an issue under its project"),
1091 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
1092 (ADD_BLOCKED_BY, "recording a dependency"),
1093 (REMOVE_BLOCKED_BY, "removing a dependency"),
1094 (DELETE_ISSUE, "deleting an issue"),
1095 (ISSUE_COMMENTS, "reading a task's comments"),
1096 (COMMENT_ISSUE, "reading which issue a comment is on"),
1097 (ADD_COMMENT, "adding a comment"),
1098 (UPDATE_COMMENT, "editing a comment"),
1099 (DELETE_COMMENT, "deleting a comment"),
1100 ];
1101}
1102
1103/// Which of GitHub's two rate limiters refused a request.
1104///
1105/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
1106/// secondary one — so an operator told the wrong one takes the wrong next step, which is
1107/// the whole reason this is carried rather than collapsed into "rate limited".
1108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1109enum Limiter {
1110 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
1111 Primary,
1112 /// The burst limiter over content-generating requests, which nothing reports.
1113 Secondary,
1114}
1115
1116/// The wordings GitHub answers a secondary rate limit with.
1117///
1118/// It sends them under a forbidden status, under a too-many-requests status, and inside
1119/// the `errors` of a *successful* response, which is why the text is what this matches on
1120/// rather than the status. `abuse detection` is the wording GitHub used before the
1121/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
1122/// what a burst of content creation is refused with.
1123///
1124/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
1125/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
1126/// when, and the drift gate reconciles the two lists both ways. Public for that gate
1127/// alone — a caller has no use for it, and matching on a refusal is this source's job.
1128pub const SECONDARY_WORDINGS: [&str; 5] = [
1129 "secondary rate limit",
1130 "temporarily blocked from content creation",
1131 "abuse detection",
1132 "submitted too quickly",
1133 "exceeded a secondary",
1134];
1135
1136/// The wordings GitHub answers an exhausted primary budget with.
1137///
1138/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
1139/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
1140/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
1141/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
1142/// two phrases is a substring of it, so without it that answer read as a refusal that will
1143/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
1144/// one reason.
1145pub const PRIMARY_WORDINGS: [&str; 4] = [
1146 "api rate limit exceeded",
1147 "api rate limit already exceeded",
1148 "rate limit exceeded",
1149 "rate_limited",
1150];
1151
1152/// What a response *says about itself*, which is the only place a refusal can be read.
1153///
1154/// Deliberately not the whole response body. A board is a place people write about their
1155/// own work, and a task on it titled "the secondary rate limit" would, matched across the
1156/// raw text, turn a perfectly good answer into a refusal this source then waited out and
1157/// reported. So the item data is never read: what is read is GitHub's own REST-style
1158/// `message` envelope, which is what a forbidden status carries, and the `message` and
1159/// `type` of each GraphQL error, which is where a *successful* response says it.
1160///
1161/// A body that is not JSON at all has nothing structured to read, so only a failing
1162/// response's own text is taken — a successful response that is not JSON is malformed
1163/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1164fn refusal_wording(status: StatusCode, body: &str) -> String {
1165 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1166 return if status.is_success() {
1167 String::new()
1168 } else {
1169 body.to_owned()
1170 };
1171 };
1172 let mut said: Vec<&str> = parsed
1173 .get("message")
1174 .and_then(Value::as_str)
1175 .into_iter()
1176 .collect();
1177 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1178 for error in errors {
1179 said.extend(
1180 ["message", "type"]
1181 .into_iter()
1182 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1183 );
1184 }
1185 }
1186 said.join("; ")
1187}
1188
1189impl Limiter {
1190 /// Which limiter refused this response, or `None` when none of them did.
1191 ///
1192 /// The wording is read first and the status only decides what carries none of it,
1193 /// because GitHub answers a secondary limit with a forbidden status far more often
1194 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1195 /// really is a credential this token lacks.
1196 ///
1197 /// A response is a refusal because of its status or its own wording. A spent budget
1198 /// only ever explains one; it never turns an answer into a refusal.
1199 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1200 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1201 if SECONDARY_WORDINGS
1202 .iter()
1203 .any(|wording| normalized.contains(wording))
1204 {
1205 return Some(Self::Secondary);
1206 }
1207 if status == StatusCode::TOO_MANY_REQUESTS {
1208 return Some(Self::Primary);
1209 }
1210 // An exhausted budget *explains* a response that failed; it does not make one that
1211 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1212 // request the budget allowed as well as on the ones it then refuses, so reading
1213 // the header alone threw away a good answer — and, once refusals were retried,
1214 // replayed a request that had already taken effect.
1215 if !status.is_success() && budget_exhausted {
1216 return Some(Self::Primary);
1217 }
1218 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1219 // `errors` of an HTTP 200, where nothing about the status says so at all.
1220 if status.is_success()
1221 && PRIMARY_WORDINGS
1222 .iter()
1223 .any(|wording| normalized.contains(wording))
1224 {
1225 return Some(Self::Primary);
1226 }
1227 None
1228 }
1229
1230 /// What this limiter is called where an operator can look it up.
1231 const fn name(self) -> &'static str {
1232 match self {
1233 Self::Primary => "GitHub's primary API rate limit",
1234 Self::Secondary => "GitHub's secondary rate limit",
1235 }
1236 }
1237
1238 /// What the endpoint an operator would go and check says about this limiter.
1239 const fn where_to_look(self) -> &'static str {
1240 match self {
1241 Self::Primary => {
1242 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1243 comes back."
1244 }
1245 Self::Secondary => {
1246 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1247 primary budget and does not report this one, so budget showing there says \
1248 nothing about this refusal, and every further attempt extends it."
1249 }
1250 }
1251 }
1252
1253 /// The next step this limiter actually calls for.
1254 const fn what_to_do(self) -> &'static str {
1255 match self {
1256 Self::Primary => {
1257 "wait for the reset `gh api rate_limit` reports, then run the command again."
1258 }
1259 Self::Secondary => {
1260 "leave this board alone for a few minutes, then run the command again — or \
1261 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1262 }
1263 }
1264 }
1265}
1266
1267/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1268#[derive(Debug, Clone, Copy)]
1269struct Limited {
1270 limiter: Limiter,
1271 hint: Option<u64>,
1272}
1273
1274impl Limited {
1275 /// What the caller is told once this source has waited as long as it may.
1276 ///
1277 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1278 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1279 /// about *which* limiter it was makes it a different kind of failure. What differs is
1280 /// the operator's next step, and that is what the message carries — a secondary
1281 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1282 /// budget looks fine, and then back to retry the very burst that was refused.
1283 fn exhausted(
1284 self,
1285 doing: &str,
1286 waits: u32,
1287 waited: Duration,
1288 needed: Duration,
1289 budget: Duration,
1290 ) -> SourceError {
1291 SourceError::RateLimited {
1292 retry_after_seconds: self.hint,
1293 message: Some(format!(
1294 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1295 again, and the next wait of {} would take it past the {} one call may spend \
1296 waiting. {} next: {}",
1297 self.limiter.name(),
1298 plural(waits, "refusal"),
1299 seconds(waited),
1300 seconds(needed),
1301 seconds(budget),
1302 self.limiter.where_to_look(),
1303 self.limiter.what_to_do(),
1304 )),
1305 }
1306 }
1307}
1308
1309/// One HTTP attempt's result, with what its response said about the rate limit.
1310///
1311/// The two travel together so the record and the outcome are written from the same place:
1312/// what a response said about the budget is only readable while that response is in hand,
1313/// and what the attempt *meant* is only decidable once its body has been read.
1314struct Attempted {
1315 result: Result<Value, Attempt>,
1316 limits: accounting::RateLimit,
1317 /// GitHub's own reported cost for this call, for a document that asked for it.
1318 reported_cost: Option<u64>,
1319}
1320
1321/// One attempt's outcome: an error to report, or a rate limit to wait out.
1322enum Attempt {
1323 Failed(SourceError),
1324 Limited(Limited),
1325}
1326
1327fn plural(count: u32, thing: &str) -> String {
1328 if count == 1 {
1329 format!("{count} {thing}")
1330 } else {
1331 format!("{count} {thing}s")
1332 }
1333}
1334
1335fn seconds(duration: Duration) -> String {
1336 format!("{:.1}s", duration.as_secs_f64())
1337}
1338
1339/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1340///
1341/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1342/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1343/// header, and neither is what makes a response a refusal — so the whole cost of one this
1344/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1345/// instead. Refusing the response over the header would turn a readable refusal into an
1346/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1347fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1348 value
1349 .and_then(|value| value.to_str().ok())
1350 .and_then(|value| value.trim().parse::<u64>().ok())
1351}
1352
1353/// Every mutation this source sends creates content — an issue, a board item, a field of
1354/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1355/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1356/// and what the keyword says are the same set. That is what makes the keyword a sound test
1357/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1358/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1359fn is_mutation(query: &str) -> bool {
1360 query.trim_start().starts_with("mutation")
1361}
1362
1363/// What this source was doing, for a diagnostic that has to say so.
1364///
1365/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1366/// a document added without a description is caught by that list's own gate instead of
1367/// falling through to the vague arm below.
1368fn operation_description(query: &str) -> &'static str {
1369 graphql::DOCUMENTS
1370 .iter()
1371 .find(|(document, _)| *document == query)
1372 .map_or("talking to GitHub", |(_, doing)| *doing)
1373}
1374
1375/// GitHub's published ceiling on content-generating requests, per minute.
1376///
1377/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1378/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1379/// from it, so a pacing value checked only against itself cannot go stale here.
1380pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1381/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1382/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1383/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1384pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1385/// Shortest interval between two content-creating mutations, in milliseconds.
1386///
1387/// GitHub documents two secondary limits on content-generating requests:
1388/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1389/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1390/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1391/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1392/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1393/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1394/// deliberately *not* what this paces at. An installation that wants the hourly bound
1395/// honoured for a long sequence of copies says so through
1396/// `pacing.min_mutation_interval_ms`.
1397pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1398/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1399///
1400/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1401/// own advice for a secondary limit — wait, and wait longer each time — without spending
1402/// the first minute of a transient refusal doing nothing.
1403pub const RETRY_BACKOFF_MS: u64 = 1_000;
1404/// Total time one call may spend waiting out rate limits before it reports a failure.
1405///
1406/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1407/// short enough that a command an operator is watching returns. The bound is what makes
1408/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1409/// the limiter, not in a process nobody can tell from a wedged one.
1410pub const RETRY_BUDGET_MS: u64 = 120_000;
1411
1412fn default_token_env() -> String {
1413 "GH_PROJECTS_TOKEN".to_owned()
1414}
1415fn default_endpoint() -> String {
1416 "https://api.github.com/graphql".to_owned()
1417}
1418
1419/// Where one status category lands on this board.
1420///
1421/// `null` — an absent value — disables the category for this instance, and using a
1422/// disabled status is a refusal naming the status and the instance.
1423#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1424#[serde(untagged)]
1425pub enum StatusTargetConfig {
1426 /// The name of a `Status` single-select option already on the board.
1427 Column(ColumnName),
1428}
1429
1430/// The name of a `Status` single-select option on the board.
1431///
1432/// Validated on the way in rather than checked later, so a blank option name — which
1433/// nothing on a board can be — is a state this type cannot hold.
1434#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1435#[serde(try_from = "String")]
1436#[schemars(extend("minLength" = 1))]
1437pub struct ColumnName(String);
1438
1439impl ColumnName {
1440 /// The option name, as the board spells it.
1441 fn as_str(&self) -> &str {
1442 &self.0
1443 }
1444}
1445
1446impl TryFrom<String> for ColumnName {
1447 type Error = String;
1448
1449 fn try_from(name: String) -> Result<Self, Self::Error> {
1450 if name.trim().is_empty() {
1451 return Err("a status_mapping option name cannot be blank".to_owned());
1452 }
1453 Ok(Self(name))
1454 }
1455}
1456
1457/// The two closed states this product can mean.
1458///
1459/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1460/// work nor abandoned work, so nothing here ever writes it.
1461#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1462#[serde(rename_all = "kebab-case")]
1463pub enum ClosedState {
1464 /// `COMPLETED` — precisely done.
1465 Completed,
1466 /// `NOT_PLANNED` — precisely cancelled.
1467 NotPlanned,
1468}
1469
1470impl ClosedState {
1471 const fn reason(self) -> &'static str {
1472 match self {
1473 Self::Completed => "COMPLETED",
1474 Self::NotPlanned => "NOT_PLANNED",
1475 }
1476 }
1477}
1478
1479/// Configuration for one GitHub Projects v2 board.
1480#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1481#[serde(default, deny_unknown_fields)]
1482pub struct GitHubProjectsConfig {
1483 /// Login of the user or organization which owns the board.
1484 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1485 /// The project number shown in the board's GitHub URL.
1486 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1487 // 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.
1488 /// `owner/name` of the repository this source creates an issue in when the item's own
1489 /// `repositories` field does not decide it.
1490 ///
1491 /// An item naming exactly one repository is created there; a task or a document naming
1492 /// none or several is created in its parent project's repository; and a project, or a
1493 /// task or document with no parent, naming none or several is created here. A board
1494 /// has no repository of its own and `createIssue` requires one, so a write without
1495 /// this is refused naming the field. Reads never need it.
1496 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1497 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1498 /// Environment variable containing a fine-grained token with Projects and Issues
1499 /// read/write plus Pull requests read-only access for every repository represented on
1500 /// the board.
1501 #[serde(default = "default_token_env")]
1502 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1503 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1504 #[serde(default = "default_endpoint")]
1505 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1506 /// Per-instance mapping from a status category to where it lands on this board.
1507 ///
1508 /// A category this does not mention keeps its shipped default: `backlog` to
1509 /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1510 /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1511 /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1512 /// board option; every unknown word then lands on that option and reads back as
1513 /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1514 /// word because it never creates board options.
1515 #[serde(default)]
1516 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.
1517 /// Per-instance mapping from a task's priority to an option of this board's
1518 /// single-select field named `Priority`.
1519 ///
1520 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1521 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1522 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1523 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1524 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1525 /// no two levels may name one option. Reads and writes never create the field or an
1526 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1527 /// the board lacks is refused pointing there.
1528 #[serde(default)]
1529 pub priority_mapping: Option<PriorityMappingConfig>,
1530 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1531 ///
1532 /// Every field keeps its shipped default when it is absent, and the defaults are
1533 /// GitHub's own published limits rather than taste. See [`Pacing`].
1534 #[serde(default)]
1535 pub pacing: PacingConfig,
1536}
1537
1538/// Which option of the board's `Priority` field each priority lands on.
1539///
1540/// One member per level rather than a map, so a key that is not a level is refused where
1541/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1542/// value in the field, not an option of it.
1543#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1544#[serde(default, deny_unknown_fields)]
1545pub struct PriorityMappingConfig {
1546 /// The option `urgent` lands on; `Urgent` when absent.
1547 pub urgent: Option<PriorityOptionName>,
1548 /// The option `high` lands on; `High` when absent.
1549 pub high: Option<PriorityOptionName>,
1550 /// The option `medium` lands on; `Medium` when absent.
1551 pub medium: Option<PriorityOptionName>,
1552 /// The option `low` lands on; `Low` when absent.
1553 pub low: Option<PriorityOptionName>,
1554}
1555
1556/// The name of an option of the board's `Priority` single-select field.
1557///
1558/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1559/// blank name.
1560#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1561#[serde(try_from = "String")]
1562#[schemars(extend("minLength" = 1))]
1563pub struct PriorityOptionName(String);
1564
1565impl PriorityOptionName {
1566 /// The option name, as the board spells it.
1567 fn as_str(&self) -> &str {
1568 &self.0
1569 }
1570}
1571
1572impl TryFrom<String> for PriorityOptionName {
1573 type Error = String;
1574
1575 fn try_from(name: String) -> Result<Self, Self::Error> {
1576 if name.trim().is_empty() {
1577 return Err("a priority_mapping option name cannot be blank".to_owned());
1578 }
1579 Ok(Self(name))
1580 }
1581}
1582
1583/// The name of the board field a priority is held in.
1584pub const PRIORITY_FIELD: &str = "Priority";
1585
1586/// The four priorities a board option can hold, in the order a new `Priority` field lists
1587/// them. `none` is not among them: it is the field holding no value.
1588///
1589/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1590/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1591/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1592/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1593pub const PRIORITY_LEVELS: [Priority; 4] = [
1594 Priority::Urgent,
1595 Priority::High,
1596 Priority::Medium,
1597 Priority::Low,
1598];
1599
1600/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1601/// see that list for what this pins.
1602#[must_use]
1603pub const fn level_position(priority: Priority) -> Option<usize> {
1604 match priority {
1605 Priority::None => None,
1606 Priority::Urgent => Some(0),
1607 Priority::High => Some(1),
1608 Priority::Medium => Some(2),
1609 Priority::Low => Some(3),
1610 }
1611}
1612
1613/// This instance's complete priority-to-option mapping, read in both directions.
1614///
1615/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1616/// two levels name one option.
1617#[derive(Debug, Clone)]
1618struct PriorityMapping {
1619 options: [PriorityOptionName; 4],
1620}
1621
1622impl PriorityMapping {
1623 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1624 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1625 let mapping = Self {
1626 options: [
1627 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1628 config.high.unwrap_or_else(|| shipped("High")),
1629 config.medium.unwrap_or_else(|| shipped("Medium")),
1630 config.low.unwrap_or_else(|| shipped("Low")),
1631 ],
1632 };
1633 for (index, option) in mapping.options.iter().enumerate() {
1634 if let Some(earlier) = mapping.options[..index]
1635 .iter()
1636 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1637 {
1638 return Err(SourceError::Config {
1639 message: format!(
1640 "priority_mapping of source {instance} sends both {} and {} to the board \
1641 option {:?}; one option cannot read back as two priorities",
1642 PRIORITY_LEVELS[earlier],
1643 PRIORITY_LEVELS[index],
1644 option.as_str()
1645 ),
1646 });
1647 }
1648 }
1649 Ok(mapping)
1650 }
1651
1652 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1653 fn option(&self, priority: Priority) -> Option<&str> {
1654 level_position(priority).map(|index| self.options[index].as_str())
1655 }
1656
1657 /// The priority a board option name reports, or `None` when nothing maps to it.
1658 fn priority_of(&self, option: &str) -> Option<Priority> {
1659 self.options
1660 .iter()
1661 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1662 .map(|index| PRIORITY_LEVELS[index])
1663 }
1664
1665 /// Every mapped option name, in the order a new `Priority` field lists them.
1666 fn names(&self) -> impl Iterator<Item = &str> {
1667 self.options.iter().map(PriorityOptionName::as_str)
1668 }
1669}
1670
1671/// What one item's `Priority` field says, read through this instance's mapping.
1672#[derive(Debug, Clone, PartialEq, Eq)]
1673enum HeldPriority {
1674 /// A priority this source reports: an option the mapping names, or no value (`none`).
1675 Read(Priority),
1676 /// An option the mapping does not name, which is never read as a level or as `none`.
1677 Unmapped(String),
1678}
1679
1680/// How fast this source writes, and how long it waits out a rate-limit refusal.
1681///
1682/// Configurable because a GitHub Enterprise installation sets its own limits and an
1683/// operator who has already been refused may want to go slower still — not because the
1684/// defaults are guesses.
1685#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1686#[serde(default, deny_unknown_fields)]
1687pub struct PacingConfig {
1688 /// Shortest interval between two content-creating mutations, in milliseconds.
1689 ///
1690 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1691 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1692 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.
1693 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1694 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1695 /// zero while there is a budget to spend, because a schedule of zero-length waits
1696 /// consumes none of it and so never ends.
1697 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.
1698 /// Total time one call may spend waiting out rate limits, in milliseconds.
1699 ///
1700 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1701 /// the bound is what makes this a wait rather than a hang.
1702 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.
1703}
1704
1705/// The largest any pacing setting may be, in milliseconds.
1706///
1707/// One hour. GitHub's own harshest published bound on content-generating requests works
1708/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1709/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1710/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1711/// and an interval beyond it is a command that never sends its second mutation. It also
1712/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1713/// what an `Instant` can hold on every platform.
1714pub const MAX_PACING_MS: u64 = 3_600_000;
1715
1716/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1717/// source holds.
1718#[derive(Debug, Clone, Copy)]
1719struct Pacing {
1720 min_mutation_interval: Duration,
1721 retry_backoff: Duration,
1722 retry_budget: Duration,
1723}
1724
1725impl Pacing {
1726 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1727 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1728 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1729 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1730 message: format!(
1731 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1732 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1733 GitHub's own harshest published limit"
1734 ),
1735 }),
1736 Some(value) => Ok(Duration::from_millis(value)),
1737 None => Ok(Duration::from_millis(default)),
1738 };
1739 let retry_backoff = bounded(
1740 config.retry_backoff_ms,
1741 RETRY_BACKOFF_MS,
1742 "retry_backoff_ms",
1743 )?;
1744 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1745 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1746 return Err(SourceError::Config {
1747 message: format!(
1748 "pacing.retry_backoff_ms of source {instance} is 0 while \
1749 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1750 none of that budget, so it would retry a refusal forever. Set a backoff of \
1751 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1752 waiting at all",
1753 retry_budget.as_millis()
1754 ),
1755 });
1756 }
1757 Ok(Self {
1758 min_mutation_interval: bounded(
1759 config.min_mutation_interval_ms,
1760 MIN_MUTATION_INTERVAL_MS,
1761 "min_mutation_interval_ms",
1762 )?,
1763 retry_backoff,
1764 retry_budget,
1765 })
1766 }
1767}
1768
1769/// Factory for [`GitHubProjectsSource`].
1770#[derive(Debug, Clone, Copy, Default)]
1771pub struct Plugin;
1772
1773impl SourcePlugin for Plugin {
1774 fn kind(&self) -> &'static str {
1775 KIND
1776 }
1777 fn config_schema(&self) -> Schema {
1778 schema_for!(GitHubProjectsConfig)
1779 }
1780 fn build(
1781 &self,
1782 name: &SourceName,
1783 config: &Value,
1784 secrets: &dyn SecretResolver,
1785 ) -> Result<Box<dyn TaskSource>, SourceError> {
1786 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1787 }
1788}
1789
1790impl Plugin {
1791 /// Build a source recording every request it sends into an accounting the caller holds.
1792 ///
1793 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
1794 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
1795 /// session total rather than two — see [`accounting`] and
1796 /// [`GitHubProjectsSource::recording_into`].
1797 ///
1798 /// # Errors
1799 ///
1800 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
1801 /// [`SourceError::Config`] for configuration this plugin cannot use and
1802 /// [`SourceError::Auth`] for a credential it cannot find.
1803 pub fn build_recording_into(
1804 &self,
1805 name: &SourceName,
1806 config: &Value,
1807 secrets: &dyn SecretResolver,
1808 ledger: Arc<Accounting>,
1809 ) -> Result<Box<dyn TaskSource>, SourceError> {
1810 let config: GitHubProjectsConfig =
1811 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1812 message: format!("source {name}: {e}"),
1813 })?;
1814 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
1815 |error| match error {
1816 SourceError::Config { message } => SourceError::Config {
1817 message: format!("source {name}: {message}"),
1818 },
1819 SourceError::Auth { message } => SourceError::Auth {
1820 message: format!("source {name}: {message}"),
1821 },
1822 other => other,
1823 },
1824 )?;
1825 Ok(Box::new(source))
1826 }
1827}
1828
1829/// Where a status category lands on this board, once configuration is resolved.
1830#[derive(Debug, Clone, PartialEq, Eq)]
1831enum StatusTarget {
1832 /// Not usable against this instance.
1833 Disabled,
1834 /// The board's `Status` option of this name.
1835 Column(ColumnName),
1836 /// A closed issue, with both its board option and the reason that says which closed it means.
1837 // 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.
1838 Terminal(ColumnName, ClosedState),
1839}
1840
1841/// Every status category, in the order the vocabulary declares them.
1842///
1843/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
1844/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
1845/// added to the shared vocabulary fails to compile until it is named there, and this
1846/// crate's suite reconciles this list against that enum's own derived schema, which is
1847/// generated from the variants rather than written beside them. The schema is what
1848/// catches a list left one short — a list checking only the positions it already holds
1849/// would pass while every mapping indexed by the new position panicked.
1850pub const CATEGORIES: [StatusCategory; 8] = [
1851 StatusCategory::Draft,
1852 StatusCategory::Backlog,
1853 StatusCategory::Todo,
1854 StatusCategory::Queued,
1855 StatusCategory::InProgress,
1856 StatusCategory::Done,
1857 StatusCategory::Cancelled,
1858 StatusCategory::Unknown,
1859];
1860
1861/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
1862#[must_use]
1863pub const fn category_position(category: StatusCategory) -> usize {
1864 match category {
1865 StatusCategory::Draft => 0,
1866 StatusCategory::Backlog => 1,
1867 StatusCategory::Todo => 2,
1868 StatusCategory::Queued => 3,
1869 StatusCategory::InProgress => 4,
1870 StatusCategory::Done => 5,
1871 StatusCategory::Cancelled => 6,
1872 StatusCategory::Unknown => 7,
1873 }
1874}
1875
1876/// The spelling a status category is configured and reported under.
1877fn category_name(category: StatusCategory) -> &'static str {
1878 match category {
1879 StatusCategory::Draft => "draft",
1880 StatusCategory::Backlog => "backlog",
1881 StatusCategory::Todo => "todo",
1882 StatusCategory::Queued => "queued",
1883 StatusCategory::InProgress => "in-progress",
1884 StatusCategory::Done => "done",
1885 StatusCategory::Cancelled => "cancelled",
1886 StatusCategory::Unknown => "unknown",
1887 }
1888}
1889
1890/// A shipped default's option name.
1891///
1892/// The literals below are this file's own and non-blank, and they are validated by the
1893/// one constructor a configured name goes through rather than beside it.
1894fn shipped_column(name: &'static str) -> ColumnName {
1895 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
1896}
1897
1898/// The shipped default for one category, before this instance's configuration.
1899fn shipped_default(category: StatusCategory) -> StatusTarget {
1900 match category {
1901 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
1902 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
1903 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
1904 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
1905 StatusCategory::Done => {
1906 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
1907 }
1908 StatusCategory::Cancelled => {
1909 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
1910 }
1911 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
1912 }
1913}
1914
1915/// This instance's complete category-to-target mapping, read in both directions.
1916///
1917/// One target per category, held at that category's own [`category_position`], so a
1918/// category missing from the mapping, named twice in it, or filed out of order is a
1919/// state this type cannot hold rather than one [`Self::target`] has to defend against.
1920#[derive(Debug, Clone)]
1921struct StatusMapping {
1922 targets: [StatusTarget; CATEGORIES.len()],
1923}
1924
1925impl StatusMapping {
1926 fn resolve(
1927 configured: BTreeMap<String, Option<StatusTargetConfig>>,
1928 instance: &SourceName,
1929 ) -> Result<Self, SourceError> {
1930 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
1931 for (key, value) in configured {
1932 let category = CATEGORIES
1933 .iter()
1934 .find(|category| category_name(**category) == key)
1935 .ok_or_else(|| SourceError::Config {
1936 message: format!(
1937 "status_mapping names {key:?}, which is not a status category of source \
1938 {instance}; the categories are {}",
1939 CATEGORIES
1940 .iter()
1941 .map(|category| category_name(*category))
1942 .collect::<Vec<_>>()
1943 .join(", ")
1944 ),
1945 })?;
1946 overrides.insert(category_name(*category), value);
1947 }
1948 // `CATEGORIES[position] == category` for every category — the crate's suite
1949 // asserts it — so mapping the list in order fills each category's own slot.
1950 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
1951 None => shipped_default(category),
1952 Some(None) => StatusTarget::Disabled,
1953 Some(Some(StatusTargetConfig::Column(option))) => match category {
1954 StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
1955 StatusCategory::Cancelled => {
1956 StatusTarget::Terminal(option, ClosedState::NotPlanned)
1957 }
1958 _ => StatusTarget::Column(option),
1959 },
1960 });
1961 let mapping = Self { targets };
1962 for (index, category) in CATEGORIES.into_iter().enumerate() {
1963 let option = match mapping.target(category) {
1964 StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
1965 StatusTarget::Disabled => continue,
1966 };
1967 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
1968 matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1969 if name.as_str().eq_ignore_ascii_case(option.as_str()))
1970 }) {
1971 return Err(SourceError::Config {
1972 message: format!(
1973 "status_mapping of source {instance} sends both {} and {} to the board \
1974 option {:?}; one option cannot read back as two categories",
1975 category_name(*other),
1976 category_name(category),
1977 option.as_str()
1978 ),
1979 });
1980 }
1981 }
1982 Ok(mapping)
1983 }
1984
1985 fn target(&self, category: StatusCategory) -> &StatusTarget {
1986 &self.targets[category_position(category)]
1987 }
1988
1989 /// The category a board option name reports, or `None` when nothing maps to it.
1990 fn category_of(&self, option: &str) -> Option<StatusCategory> {
1991 CATEGORIES.into_iter().find(|category| {
1992 matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1993 if name.as_str().eq_ignore_ascii_case(option))
1994 })
1995 }
1996
1997 /// The status an item reports, from the three things a read of it says: its board
1998 /// `Status` option, whether its issue is closed, and the reason it was closed with.
1999 ///
2000 /// The closed state decides the category and the `Status` option decides the name, so
2001 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
2002 /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
2003 /// duplicate is not finished work, and calling it done is a lie the next copy would
2004 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
2005 /// it is read permissively rather than refused — reads are faithful, and refusals
2006 /// belong on writes.
2007 ///
2008 /// One function of those three rather than of a response, so a narrow status write can
2009 /// answer what a re-read would report by applying it to the state it has just written.
2010 fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
2011 if closed {
2012 let category = match reason {
2013 None | Some("COMPLETED") => StatusCategory::Done,
2014 Some("NOT_PLANNED") => StatusCategory::Cancelled,
2015 Some(_) => StatusCategory::Unknown,
2016 };
2017 let fallback = match category {
2018 StatusCategory::Done => "Done",
2019 StatusCategory::Cancelled => "Cancelled",
2020 _ => "Closed",
2021 };
2022 return Status {
2023 category,
2024 name: option.unwrap_or(fallback).to_owned(),
2025 };
2026 }
2027 let name = option.unwrap_or("Open").to_owned();
2028 Status {
2029 category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
2030 name,
2031 }
2032 }
2033}
2034
2035// 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.
2036/// One repository this source can create an issue in, as `owner/name`.
2037///
2038/// Every `createIssue` this source sends names one of these: the item's own single
2039/// `repositories` entry, else its parent project issue's repository, else the configured
2040/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
2041/// that choice and says what it refuses before `createIssue`.
2042// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
2043#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2044struct RepositoryTarget {
2045 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2046 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
2047}
2048
2049impl RepositoryTarget {
2050 fn parse(value: &str) -> Result<Self, SourceError> {
2051 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
2052 message: format!(
2053 "repository must be spelled owner/name; {value:?} names no repository"
2054 ),
2055 })?;
2056 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
2057 return Err(SourceError::Config {
2058 message: format!(
2059 "repository must be spelled owner/name with a GitHub login and one \
2060 repository name; {value:?} is not"
2061 ),
2062 });
2063 }
2064 Ok(Self {
2065 owner: owner.to_owned(),
2066 name: name.to_owned(),
2067 })
2068 }
2069
2070 /// The one host whose repositories this source creates issues in, spelled once: it is
2071 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
2072 const HOST: &str = "github.com";
2073
2074 fn origin(&self) -> String {
2075 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
2076 }
2077
2078 /// The repository a normalized origin names, or why it is none this source can create
2079 /// an issue in: another host, or more or fewer than `owner/name` under this one.
2080 fn from_origin(origin: &Repository) -> Result<Self, String> {
2081 let not_here = || {
2082 format!(
2083 "{} is not a {}/owner/name repository",
2084 origin.as_str(),
2085 Self::HOST
2086 )
2087 };
2088 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
2089 if host != Self::HOST {
2090 return Err(not_here());
2091 }
2092 Self::parse(rest).map_err(|_| not_here())
2093 }
2094
2095 fn slug(&self) -> String {
2096 format!("{}/{}", self.owner, self.name)
2097 }
2098}
2099
2100/// A source which reads GitHub afresh for every operation.
2101pub struct GitHubProjectsSource {
2102 /// This source's configured name, used both to tell a far end naming this source
2103 /// from one naming a system it knows nothing about, and to name the instance a
2104 /// status refusal is about.
2105 name: SourceName,
2106 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
2107 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
2108 repository: Option<RepositoryTarget>,
2109 endpoint: Url,
2110 token: SecretString,
2111 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
2112 statuses: StatusMapping,
2113 /// Where each priority lands on this board, or `None` when this instance holds none.
2114 priorities: Option<PriorityMapping>,
2115 client: Client,
2116 /// Every item this source has created since it was built, in the order it created
2117 /// them.
2118 ///
2119 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
2120 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
2121 /// a copy resolving a dependency on an item it had just created refused it as not
2122 /// found. A board read is completed from this — an item remembered here and absent from
2123 /// the read is added back, because the board really does hold it and only the read is
2124 /// behind.
2125 ///
2126 /// It is not a cache of a user's work: nothing is remembered that this process did not
2127 /// itself just write, it lives and dies with the process, and it is never consulted for
2128 /// an item this source did not create.
2129 created: Mutex<Vec<Resolved>>,
2130 /// Every item that already existed and that this source has written since it was built,
2131 /// as it wrote it.
2132 ///
2133 /// The other half of [`Self::created`], held on the same terms and for the reason a
2134 /// narrowed read needs it: an answer from GitHub's search or from the board's own field
2135 /// filter is an index behind a write this process made moments ago, so a query matching
2136 /// what this process just wrote onto an existing item would otherwise miss it. Nothing
2137 /// is remembered that this process did not itself just write.
2138 updated: Mutex<Vec<Resolved>>,
2139 /// How fast this source writes, and how long it waits out a refusal.
2140 pacing: Pacing,
2141 /// When the last content-creating mutation finished, or the moment the furthest-out
2142 /// reserved slot releases the next one, whichever is later — so the one after it can be
2143 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
2144 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
2145 /// what it is measured from.
2146 last_mutation: Mutex<Option<Instant>>,
2147 /// The board as this process last read it, for the length of one command.
2148 ///
2149 /// A copy of a project used to re-read the whole board, paged, before writing each of
2150 /// its items, which is by far the largest part of a copy's request count and none of
2151 /// its work. Nothing else changes this board while a command runs — this source's own
2152 /// writes are the only writer — so one read answers them all.
2153 ///
2154 /// It is not a store of a user's work and it is not the cache the no-persistence
2155 /// invariant forbids: it lives and dies with the process exactly as `created` does,
2156 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
2157 /// an item this command created and then depends on resolves whether or not GitHub's
2158 /// own eventually-consistent read has caught up. A write to an item already on the
2159 /// board updates the entry here too, so what this holds is the last read plus this
2160 /// process's own writes rather than a snapshot taken before them.
2161 board_cache: Mutex<Option<Board>>,
2162 /// Every issue this board's own search reported, for the length of one command.
2163 ///
2164 /// The second half of a board read, and cached for the same reason and on the same
2165 /// terms as the first: it lives and dies with the process, nothing is written down, and
2166 /// a write this process makes updates the entry here exactly as it updates the one in
2167 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2168 /// that lists this board's projects and its tasks pays for one search rather than two.
2169 search_cache: Mutex<Option<Vec<Resolved>>>,
2170 /// What each narrowed question GitHub was asked answered, keyed by that question, for
2171 /// the length of one command.
2172 ///
2173 /// The narrowed counterpart of [`Self::search_cache`], held on the same terms: it lives
2174 /// and dies with the process, nothing is written down, a write this process makes
2175 /// updates the entry here as it updates the other two, and every answer is completed
2176 /// with this process's own writes each time it is given. A command that asks the same
2177 /// narrowed question twice — a wait polling for its own items, a listing repeated after a
2178 /// write — pays for it once, which is what the whole-board read it replaced gave it.
2179 narrowed_cache: Mutex<BTreeMap<String, Vec<Resolved>>>,
2180 search_next: Mutex<BTreeMap<String, Option<String>>>,
2181 /// Records already resolved in this source instance, reused by writes and
2182 /// for comment identity. Explicit item reads still reach GitHub. Nothing is persisted.
2183 resolved_cache: Mutex<BTreeMap<NativeId, Resolved>>,
2184 /// The board's own id and field definitions as this process last read them on their
2185 /// own, for the length of one command.
2186 ///
2187 /// What a write needs of the board and its item does not say, read once per command
2188 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2189 /// lives and dies with the process and nothing is written down. It holds no item and so
2190 /// can answer no question about one — see [`Self::board_fields`].
2191 fields_cache: Mutex<Option<BoardFields>>,
2192 /// Each destination repository's node id, resolved once per repository
2193 /// rather than per issue created.
2194 ///
2195 /// A repository's node id does not change, and re-reading it for every issue of a copy
2196 /// spent one request per item on an answer this source already had. It is a map rather
2197 /// than one entry because a copy files each item in the repository its own
2198 /// `repositories` field names, so a plan across five repositories asks GitHub five
2199 /// times and not once per item.
2200 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2201 /// What every request this source sends is recorded into.
2202 ///
2203 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2204 /// a request leaves this crate, so nothing has to be switched on for a session to be
2205 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2206 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2207 /// source's reads and writes — adds up one accounting instead of two. See
2208 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2209 ledger: Arc<Accounting>,
2210}
2211
2212/// GitHub's closed single-select color vocabulary.
2213#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2214#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2215pub enum StatusOptionColor {
2216 /// Gray.
2217 Gray,
2218 /// Blue.
2219 Blue,
2220 /// Green.
2221 Green,
2222 /// Yellow.
2223 Yellow,
2224 /// Purple.
2225 Purple,
2226 /// Red.
2227 Red,
2228 /// Orange.
2229 Orange,
2230 /// Pink.
2231 Pink,
2232}
2233
2234/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2235/// applies its additions.
2236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2237pub enum SetupMode {
2238 /// Read without mutation.
2239 Plan,
2240 /// Apply and verify.
2241 Apply,
2242}
2243
2244/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2245/// against it goes on compiling.
2246pub type StatusOptionsMode = SetupMode;
2247
2248/// The explicit result of the requested operation.
2249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2250#[serde(rename_all = "kebab-case")]
2251pub enum StatusOptionsOutcome {
2252 /// A read-only plan.
2253 Planned,
2254 /// Apply found nothing missing.
2255 Unchanged,
2256 /// Additions were applied and verified.
2257 Applied,
2258}
2259
2260/// A GitHub single-select option's opaque GraphQL node identifier.
2261#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2262#[serde(transparent)]
2263pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2264
2265impl TryFrom<String> for StatusOptionId {
2266 type Error = String;
2267
2268 fn try_from(id: String) -> Result<Self, Self::Error> {
2269 if id.trim().is_empty() {
2270 return Err("a GitHub Status option id cannot be blank".to_owned());
2271 }
2272 Ok(Self(id))
2273 }
2274}
2275
2276/// One existing or proposed option in a guarded Status-field update.
2277#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2278pub struct StatusOption {
2279 /// GitHub's stable id.
2280 pub id: StatusOptionId,
2281 /// The visible option name.
2282 pub name: ColumnName,
2283 /// GitHub's single-select color token.
2284 pub color: StatusOptionColor,
2285 /// The option description, including an empty one.
2286 pub description: String,
2287}
2288
2289/// One board item's Status assignment, retained as recovery data.
2290#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2291pub struct StatusAssignment {
2292 /// The project item id whose assignment this is.
2293 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2294 // carried verbatim as operator recovery data; introducing a semantic type would claim
2295 // validation rules GitHub does not publish and no operation here interprets.
2296 pub item_id: String,
2297 /// The selected option, absent when the item has no status.
2298 #[serde(skip_serializing_if = "Option::is_none")]
2299 pub option: Option<AssignedStatusOption>,
2300}
2301
2302/// The inseparable id and name of an assigned option.
2303#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2304pub struct AssignedStatusOption {
2305 /// GitHub's stable id.
2306 pub id: StatusOptionId,
2307 /// The visible name.
2308 pub name: ColumnName,
2309}
2310
2311/// The plan and verified outcome of reconciling configured Status options.
2312#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2313pub struct StatusOptionsReport {
2314 /// The configured source name.
2315 pub source: SourceName,
2316 /// Configured option names absent before the operation.
2317 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2318 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2319 // serialized string here preserves the report's intentionally simple public contract.
2320 pub missing: Vec<String>,
2321 /// What the requested operation did.
2322 pub outcome: StatusOptionsOutcome,
2323 /// The complete option list observed before any mutation.
2324 pub existing: Vec<StatusOption>,
2325}
2326
2327#[derive(Debug, Clone, PartialEq, Eq)]
2328struct StatusSnapshot {
2329 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2330 // passed back as the mutation's project identity; a newtype could enforce no stronger
2331 // invariant because GitHub publishes no grammar for it.
2332 board_id: String,
2333 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2334 // passed back as the mutation's field identity; a newtype could enforce no stronger
2335 // invariant because GitHub publishes no grammar for it.
2336 field_id: String,
2337 options: Vec<StatusOption>,
2338 assignments: Vec<StatusAssignment>,
2339}
2340
2341/// The name of the board field a status is held in.
2342const STATUS_FIELD: &str = "Status";
2343
2344/// Every item's value of each field `report` names, as it stood before the setup wrote
2345/// anything — what a person puts back when the setup is refused part way.
2346fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2347 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2348 .fields
2349 .iter()
2350 .map(|field| (field.field.name(), before.assignments(field.field)))
2351 .collect();
2352 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2353 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2354 })
2355}
2356
2357/// One board field the guarded setup reads and writes — every one it reads, and the only
2358/// ones it writes.
2359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2360pub enum BoardField {
2361 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2362 Status,
2363 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2364 Priority,
2365}
2366
2367impl BoardField {
2368 /// The field's name on the board.
2369 #[must_use]
2370 pub const fn name(self) -> &'static str {
2371 match self {
2372 Self::Status => STATUS_FIELD,
2373 Self::Priority => PRIORITY_FIELD,
2374 }
2375 }
2376
2377 /// The field a board calls `name`, or `None` for one this setup does not own.
2378 fn named(name: &str) -> Option<Self> {
2379 [Self::Status, Self::Priority]
2380 .into_iter()
2381 .find(|field| field.name() == name)
2382 }
2383}
2384
2385/// What the guarded setup did to one field.
2386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2387#[serde(rename_all = "kebab-case")]
2388pub enum FieldOutcome {
2389 /// A read-only plan.
2390 Planned,
2391 /// Apply found the field there with every configured option.
2392 Unchanged,
2393 /// Missing options were added to the field that was there, and verified.
2394 Applied,
2395 /// The field was not there; it was created holding the configured options, and verified.
2396 Created,
2397}
2398
2399/// One field's plan, or its verified outcome.
2400#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2401pub struct FieldReport {
2402 /// Which field.
2403 pub field: BoardField,
2404 /// Whether the board had the field before the operation.
2405 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2406 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2407 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2408 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2409 // one constructor, and it derives `outcome` from `exists` in one match.
2410 pub exists: bool,
2411 /// Configured option names the field lacked before the operation — every one of them,
2412 /// in the order a new field lists them, when the field was not there at all.
2413 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2414 // mapping name and has therefore already passed its nonblank validation; the serialized
2415 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2416 pub missing: Vec<String>,
2417 /// What the requested operation did.
2418 pub outcome: FieldOutcome,
2419 /// The field's complete option list observed before any mutation; empty when the field
2420 /// was not there.
2421 pub existing: Vec<StatusOption>,
2422}
2423
2424/// The plan and verified outcome of setting up every field a source's configuration names.
2425#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2426pub struct FieldsReport {
2427 /// The configured source name.
2428 pub source: SourceName,
2429 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2430 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2431 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2432 // per field would change a published JSON shape. The states the list could hold and the
2433 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2434 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2435 pub fields: Vec<FieldReport>,
2436}
2437
2438/// Which options one field is configured with, in the order a new field would list them.
2439struct FieldPlan {
2440 field: BoardField,
2441 wanted: Vec<String>,
2442}
2443
2444/// One single-select field as the guarded setup snapshots it.
2445#[derive(Debug, Clone, PartialEq, Eq)]
2446struct SnapshotField {
2447 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2448 // passed back as the mutation's field identity; a newtype could enforce no stronger
2449 // invariant because GitHub publishes no grammar for it.
2450 field_id: String,
2451 options: Vec<StatusOption>,
2452}
2453
2454/// Every single-select field of a board and every item's value of each.
2455#[derive(Debug, Clone, PartialEq, Eq)]
2456struct BoardSnapshot {
2457 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2458 // passed back as the mutation's project identity; a newtype could enforce no stronger
2459 // invariant because GitHub publishes no grammar for it.
2460 board_id: String,
2461 fields: BTreeMap<BoardField, SnapshotField>,
2462 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2463 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2464}
2465
2466impl BoardSnapshot {
2467 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2468 /// carries.
2469 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2470 self.items
2471 .iter()
2472 .map(|(item_id, values)| StatusAssignment {
2473 item_id: item_id.clone(),
2474 option: values.get(&field).cloned(),
2475 })
2476 .collect()
2477 }
2478}
2479
2480impl GitHubProjectsSource {
2481 /// Report missing configured Status options and, when `apply` is true, add them with
2482 /// a whole-list mutation that preserves every existing id and verifies the result.
2483 ///
2484 /// # Errors
2485 ///
2486 /// Refuses a board without a single-select `Status` field. A post-write difference in
2487 /// any pre-existing option id or item assignment is refused with the complete pre-write
2488 /// assignment snapshot in the diagnostic for recovery.
2489 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2490 // successful mutation, both drift refusals, source selection, missing Status, casing,
2491 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2492 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2493 // responses from entering the defensive malformed-response branches below.
2494 pub async fn status_options(
2495 &self,
2496 mode: StatusOptionsMode,
2497 ) -> Result<StatusOptionsReport, SourceError> {
2498 let before = self.status_snapshot().await?;
2499 let configured = self
2500 .statuses
2501 .targets
2502 .iter()
2503 // A terminal category's option is as configured as an open one's: a terminal
2504 // write validates it before closing and refuses when the board lacks it.
2505 .filter_map(|target| match target {
2506 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2507 Some(name.as_str().to_owned())
2508 }
2509 StatusTarget::Disabled => None,
2510 });
2511 let missing = configured
2512 .filter(|wanted| {
2513 !before
2514 .options
2515 .iter()
2516 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2517 })
2518 .collect::<Vec<_>>();
2519 let report = StatusOptionsReport {
2520 source: self.name.clone(),
2521 missing: missing.clone(),
2522 outcome: match (mode, missing.is_empty()) {
2523 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2524 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2525 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2526 },
2527 existing: before.options.clone(),
2528 };
2529 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2530 return Ok(report);
2531 }
2532 let mut options = before
2533 .options
2534 .iter()
2535 .map(|option| {
2536 json!({
2537 "id": option.id, "name": option.name, "color": option.color,
2538 "description": option.description,
2539 })
2540 })
2541 .collect::<Vec<_>>();
2542 options.extend(missing.iter().map(|name| {
2543 json!({
2544 "name": name, "color": "GRAY", "description": ""
2545 })
2546 }));
2547 self.graphql(
2548 graphql::STATUS_OPTIONS_UPDATE,
2549 json!({"input": {
2550 "projectId": before.board_id, "fieldId": before.field_id,
2551 "singleSelectOptions": options,
2552 }}),
2553 )
2554 .await?;
2555 let after = self.status_snapshot().await?;
2556 let options_preserved = before
2557 .options
2558 .iter()
2559 .all(|old| after.options.iter().any(|new| new == old));
2560 let additions_present = missing.iter().all(|wanted| {
2561 after
2562 .options
2563 .iter()
2564 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2565 });
2566 if !options_preserved || !additions_present || after.assignments != before.assignments {
2567 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2568 SourceError::Malformed {
2569 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2570 }
2571 })?;
2572 return Err(SourceError::Refused {
2573 message: format!(
2574 "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}"
2575 ),
2576 });
2577 }
2578 Ok(report)
2579 }
2580
2581 /// A fresh snapshot of the Status field and every board item's assignment of it.
2582 ///
2583 /// # Errors
2584 ///
2585 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2586 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2587 // Status alone, as this operation has always read it: a `Priority` field is another
2588 // operation's, so nothing about it can refuse this one.
2589 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2590 let field = board
2591 .fields
2592 .remove(&BoardField::Status)
2593 .ok_or_else(|| self.no_status_field())?;
2594 Ok(StatusSnapshot {
2595 assignments: board.assignments(BoardField::Status),
2596 board_id: board.board_id,
2597 field_id: field.field_id,
2598 options: field.options,
2599 })
2600 }
2601
2602 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2603 fn no_status_field(&self) -> SourceError {
2604 SourceError::Refused {
2605 message: format!("source {} board has no Status field", self.name),
2606 }
2607 }
2608
2609 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2610 // the real CLI loopback journey, including pagination. The individual malformed guards
2611 // are defensive validation of a schema-pinned third-party response, not separate user
2612 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2613 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2614 /// every board item's value of each, walked to the end of the board's items. A field not
2615 /// in `owned` is read past whatever it holds.
2616 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2617 let mut after: Option<String> = None;
2618 let mut snapshot: Option<BoardSnapshot> = None;
2619 loop {
2620 let data = self
2621 .graphql(
2622 graphql::STATUS_OPTIONS_SNAPSHOT,
2623 json!({
2624 "owner": self.owner, "number": self.project_number,
2625 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2626 }),
2627 )
2628 .await?;
2629 let board = data
2630 .pointer("/owner/projectV2")
2631 .filter(|board| board.is_object())
2632 .ok_or_else(|| SourceError::Refused {
2633 message: format!(
2634 "source {} has no accessible GitHub Projects board",
2635 self.name
2636 ),
2637 })?;
2638 if board
2639 .pointer("/fields/pageInfo/hasNextPage")
2640 .and_then(Value::as_bool)
2641 != Some(false)
2642 {
2643 return Err(SourceError::Malformed {
2644 message:
2645 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2646 .into(),
2647 });
2648 }
2649 let mut fields = BTreeMap::new();
2650 // Only the fields this setup owns, by name: a node the single-select fragment did not
2651 // match carries no name, and a person's own single-select field — a `Size`, a
2652 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2653 // `Status` or `Priority` field without its options is malformed, not absent.
2654 // 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.
2655 for (owned, field) in board
2656 .pointer("/fields/nodes")
2657 .and_then(Value::as_array)
2658 .ok_or_else(|| SourceError::Malformed {
2659 message: "GitHub project fields.nodes is not an array".into(),
2660 })?
2661 .iter()
2662 .filter_map(|field| {
2663 let named = BoardField::named(field.get("name")?.as_str()?)?;
2664 owned.contains(&named).then_some((named, field))
2665 })
2666 {
2667 let options = field
2668 .get("options")
2669 .and_then(Value::as_array)
2670 .ok_or_else(|| SourceError::Malformed {
2671 message: "GitHub single-select field options is not an array".into(),
2672 })?
2673 .iter()
2674 .map(|option| {
2675 Ok(StatusOption {
2676 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2677 .map_err(|message| SourceError::Malformed { message })?,
2678 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2679 .map_err(|message| SourceError::Malformed {
2680 message: format!(
2681 "GitHub single-select option name is invalid: {message}"
2682 ),
2683 })?,
2684 color: serde_json::from_value(
2685 option.get("color").cloned().unwrap_or(Value::Null),
2686 )
2687 .map_err(|error| {
2688 SourceError::Malformed {
2689 message: format!(
2690 "GitHub single-select option color is invalid: {error}"
2691 ),
2692 }
2693 })?,
2694 description: optional_str(option, "description")?
2695 .unwrap_or_default()
2696 .to_owned(),
2697 })
2698 })
2699 .collect::<Result<Vec<_>, SourceError>>()?;
2700 let snapshot = SnapshotField {
2701 field_id: required_nonblank_str(field, "id")?.to_owned(),
2702 options,
2703 };
2704 // A board's field names are unique, so a second one is an answer that cannot
2705 // say which field the setup would act on — refused rather than one chosen.
2706 if fields.insert(owned, snapshot).is_some() {
2707 return Err(SourceError::Malformed {
2708 message: format!(
2709 "GitHub answered two {} fields for this board",
2710 owned.name()
2711 ),
2712 });
2713 }
2714 }
2715 let board_id = required_nonblank_str(board, "id")?.to_owned();
2716 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2717 board_id,
2718 fields,
2719 items: Vec::new(),
2720 });
2721 let items = board
2722 .pointer("/items/nodes")
2723 .and_then(Value::as_array)
2724 .ok_or_else(|| SourceError::Malformed {
2725 message: "GitHub project items.nodes is not an array".into(),
2726 })?;
2727 for item in items {
2728 let field_values =
2729 item.get("fieldValues")
2730 .ok_or_else(|| SourceError::Malformed {
2731 message: "GitHub project item is missing fieldValues".into(),
2732 })?;
2733 if field_values
2734 .pointer("/pageInfo/hasNextPage")
2735 .and_then(Value::as_bool)
2736 != Some(false)
2737 {
2738 return Err(SourceError::Malformed {
2739 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2740 });
2741 }
2742 let values = item
2743 .pointer("/fieldValues/nodes")
2744 .and_then(Value::as_array)
2745 .ok_or_else(|| SourceError::Malformed {
2746 message: "GitHub project item fieldValues.nodes is not an array".into(),
2747 })?;
2748 let item_id = required_nonblank_str(item, "id")?;
2749 let mut assigned = BTreeMap::new();
2750 for value in values {
2751 let Some(field) = value
2752 .pointer("/field/name")
2753 .and_then(Value::as_str)
2754 .and_then(BoardField::named)
2755 .filter(|field| owned.contains(field))
2756 else {
2757 continue;
2758 };
2759 let held = assigned.insert(
2760 field,
2761 AssignedStatusOption {
2762 id: StatusOptionId::try_from(
2763 required_str(value, "optionId")?.to_owned(),
2764 )
2765 .map_err(|message| SourceError::Malformed { message })?,
2766 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2767 .map_err(|message| SourceError::Malformed {
2768 message: format!(
2769 "GitHub assigned {} name is invalid: {message}",
2770 field.name()
2771 ),
2772 })?,
2773 },
2774 );
2775 // An item holds one value of a field, so a second one leaves no way to
2776 // tell which it holds — and a verification or recovery built on either
2777 // could restore the wrong one.
2778 if held.is_some() {
2779 return Err(SourceError::Malformed {
2780 message: format!(
2781 "GitHub answered two {} values for board item {item_id}",
2782 field.name()
2783 ),
2784 });
2785 }
2786 }
2787 current.items.push((item_id.to_owned(), assigned));
2788 }
2789 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2790 message: "GitHub project is missing items".into(),
2791 })?;
2792 let has_next = page
2793 .pointer("/pageInfo/hasNextPage")
2794 .and_then(Value::as_bool)
2795 .ok_or_else(|| SourceError::Malformed {
2796 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
2797 })?;
2798 if !has_next {
2799 break;
2800 }
2801 let next =
2802 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
2803 validate_cursor_progress(after.as_deref(), next)?;
2804 after = Some(next.to_owned());
2805 }
2806 snapshot.ok_or_else(|| SourceError::Malformed {
2807 message: "GitHub returned no board field snapshot".into(),
2808 })
2809 }
2810 // llmlint: ignore-end[changed_behavior_has_e2e]
2811
2812 /// Report every board field this source's configuration names and, with
2813 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
2814 /// the `Priority` field when the board has none.
2815 ///
2816 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
2817 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
2818 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
2819 /// color and description: the whole option list goes back with every existing id, because
2820 /// a re-minted id clears every item's value.
2821 ///
2822 /// # Errors
2823 ///
2824 /// Refuses a board without a single-select `Status` field. After an apply the board is
2825 /// read again, and a pre-existing option or any item's value of either field that moved is
2826 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
2827 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
2828 // unchanged apply, a created field, an added option to each field, drift refusal, a board
2829 // with no Status field and a non-github-projects source through the compiled CLI against
2830 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
2831 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
2832 let owned: Vec<BoardField> = if self.priorities.is_some() {
2833 vec![BoardField::Status, BoardField::Priority]
2834 } else {
2835 vec![BoardField::Status]
2836 };
2837 let before = self.board_snapshot(&owned).await?;
2838 let mut plans = vec![FieldPlan {
2839 field: BoardField::Status,
2840 wanted: self
2841 .statuses
2842 .targets
2843 .iter()
2844 .filter_map(|target| match target {
2845 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2846 Some(name.as_str().to_owned())
2847 }
2848 StatusTarget::Disabled => None,
2849 })
2850 .collect(),
2851 }];
2852 if !before.fields.contains_key(&BoardField::Status) {
2853 return Err(self.no_status_field());
2854 }
2855 if let Some(mapping) = &self.priorities {
2856 plans.push(FieldPlan {
2857 field: BoardField::Priority,
2858 wanted: mapping.names().map(str::to_owned).collect(),
2859 });
2860 }
2861 // The snapshot reads single-select fields alone, so a field it did not find may still
2862 // be on the board under the name, of another type: creating one beside it would fail
2863 // part way, or leave two fields of one name. Asked of the board's own field list, and
2864 // only when a field is missing.
2865 if plans
2866 .iter()
2867 .any(|plan| !before.fields.contains_key(&plan.field))
2868 {
2869 let board = self.board_fields().await?;
2870 for plan in plans
2871 .iter()
2872 .filter(|plan| !before.fields.contains_key(&plan.field))
2873 {
2874 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
2875 return Err(SourceError::Refused {
2876 message: format!(
2877 "source {}'s board has a {} field that is not a single-select field \
2878 (it is a {}), so it cannot hold this source's options; next: rename \
2879 or remove that field, then run this again",
2880 self.name,
2881 plan.field.name(),
2882 optional_str(field, "__typename")?.unwrap_or("field of another type")
2883 ),
2884 });
2885 }
2886 }
2887 }
2888 let mut reports = Vec::new();
2889 for plan in &plans {
2890 let held = before.fields.get(&plan.field);
2891 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
2892 let mut missing: Vec<String> = Vec::new();
2893 for wanted in &plan.wanted {
2894 let present = existing
2895 .iter()
2896 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2897 || missing
2898 .iter()
2899 .any(|named| named.eq_ignore_ascii_case(wanted));
2900 if !present {
2901 missing.push(wanted.clone());
2902 }
2903 }
2904 reports.push(FieldReport {
2905 field: plan.field,
2906 exists: held.is_some(),
2907 outcome: match (mode, held.is_some(), missing.is_empty()) {
2908 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
2909 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
2910 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
2911 (SetupMode::Apply, false, _) => FieldOutcome::Created,
2912 },
2913 missing,
2914 existing,
2915 });
2916 }
2917 let report = FieldsReport {
2918 source: self.name.clone(),
2919 fields: reports,
2920 };
2921 let writes: Vec<&FieldReport> = report
2922 .fields
2923 .iter()
2924 .filter(|field| !field.missing.is_empty() || !field.exists)
2925 .collect();
2926 if mode == SetupMode::Plan || writes.is_empty() {
2927 return Ok(report);
2928 }
2929 let mut landed: Vec<&str> = Vec::new();
2930 for field in &writes {
2931 let added = field
2932 .missing
2933 .iter()
2934 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
2935 let sent = match before.fields.get(&field.field) {
2936 Some(held) => {
2937 let mut options = held
2938 .options
2939 .iter()
2940 .map(|option| {
2941 json!({
2942 "id": option.id, "name": option.name, "color": option.color,
2943 "description": option.description,
2944 })
2945 })
2946 .collect::<Vec<_>>();
2947 options.extend(added);
2948 self.graphql(
2949 graphql::STATUS_OPTIONS_UPDATE,
2950 json!({"input": {
2951 "projectId": before.board_id, "fieldId": held.field_id,
2952 "singleSelectOptions": options,
2953 }}),
2954 )
2955 .await
2956 }
2957 None => {
2958 self.graphql(
2959 graphql::CREATE_FIELD,
2960 json!({"input": {
2961 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
2962 "name": field.field.name(),
2963 "singleSelectOptions": added.collect::<Vec<_>>(),
2964 }}),
2965 )
2966 .await
2967 }
2968 };
2969 // A mutation that failed does not establish that GitHub left its field as it was,
2970 // so every failure from here on carries the recovery data a drift refusal does.
2971 match sent {
2972 Ok(_) => landed.push(field.field.name()),
2973 Err(error) => {
2974 let changed = if landed.is_empty() {
2975 String::new()
2976 } else {
2977 format!("changed the {} field and then ", landed.join(" and "))
2978 };
2979 return Err(SourceError::Refused {
2980 message: format!(
2981 "the guarded field setup {changed}failed on the {} field, which it may \
2982 have changed part way: {error}; the pre-write item assignments \
2983 are:\n{}",
2984 field.field.name(),
2985 recovery(&report, &before)?
2986 ),
2987 });
2988 }
2989 }
2990 }
2991 // The board has been written, so a verification read that fails leaves it unverified
2992 // rather than unchanged, and says what to put back.
2993 let after = match self.board_snapshot(&owned).await {
2994 Ok(after) => after,
2995 Err(error) => {
2996 return Err(SourceError::Refused {
2997 message: format!(
2998 "the guarded field setup changed the {} field and then could not read the \
2999 board back to verify it: {error}; the pre-write item assignments are:\n{}",
3000 landed.join(" and "),
3001 recovery(&report, &before)?
3002 ),
3003 });
3004 }
3005 };
3006 let mut moved = Vec::new();
3007 for field in &report.fields {
3008 let name = field.field.name();
3009 let now = after
3010 .fields
3011 .get(&field.field)
3012 .map(|held| held.options.as_slice())
3013 .unwrap_or_default();
3014 if !field.existing.iter().all(|old| now.contains(old)) {
3015 moved.push(format!(
3016 "a pre-existing {name} option id, name, color or description"
3017 ));
3018 }
3019 if !field.missing.iter().all(|wanted| {
3020 now.iter()
3021 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
3022 }) {
3023 moved.push(format!("an added {name} option"));
3024 }
3025 if after.assignments(field.field) != before.assignments(field.field) {
3026 moved.push(format!("an item's {name} value"));
3027 }
3028 }
3029 if !moved.is_empty() {
3030 return Err(SourceError::Refused {
3031 message: format!(
3032 "GitHub changed {} after the guarded field setup; the pre-write item \
3033 assignments are:\n{}",
3034 moved.join(", "),
3035 recovery(&report, &before)?
3036 ),
3037 });
3038 }
3039 Ok(report)
3040 }
3041
3042 /// Validate configuration and capture the named credential without exposing it.
3043 ///
3044 /// # Errors
3045 ///
3046 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
3047 /// [`SourceError::Auth`] when the named credential is missing or empty.
3048 pub fn new(
3049 name: &SourceName,
3050 config: GitHubProjectsConfig,
3051 secrets: &dyn SecretResolver,
3052 ) -> Result<Self, SourceError> {
3053 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
3054 }
3055
3056 /// The same, recording every request it sends into an accounting the caller holds too.
3057 ///
3058 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
3059 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
3060 /// up — passes the one it records those into, so the session total accounts for the
3061 /// whole session rather than for this source's share of it.
3062 ///
3063 /// # Errors
3064 ///
3065 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
3066 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
3067 pub fn recording_into(
3068 name: &SourceName,
3069 config: GitHubProjectsConfig,
3070 secrets: &dyn SecretResolver,
3071 ledger: Arc<Accounting>,
3072 ) -> Result<Self, SourceError> {
3073 if !valid_github_owner(&config.owner) {
3074 return Err(SourceError::Config {
3075 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
3076 });
3077 }
3078 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
3079 return Err(SourceError::Config {
3080 message: format!("project_number must be between 1 and {}", i32::MAX),
3081 });
3082 }
3083 if !valid_environment_name(&config.token_env) {
3084 return Err(SourceError::Config {
3085 message: "token_env must be a valid environment-variable name".into(),
3086 });
3087 }
3088 let repository = config
3089 .repository
3090 .as_deref()
3091 .map(RepositoryTarget::parse)
3092 .transpose()?;
3093 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
3094 message: format!("endpoint is not a valid URL: {e}"),
3095 })?;
3096 if endpoint.scheme() != "https"
3097 && !(endpoint.scheme() == "http"
3098 && endpoint
3099 .host_str()
3100 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
3101 {
3102 return Err(SourceError::Config {
3103 message:
3104 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
3105 .into(),
3106 });
3107 }
3108 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
3109 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),
3110 })?;
3111 Ok(Self {
3112 name: name.clone(),
3113 owner: config.owner,
3114 project_number: config.project_number,
3115 repository,
3116 endpoint,
3117 token,
3118 credential_name: config.token_env,
3119 statuses: StatusMapping::resolve(config.status_mapping, name)?,
3120 priorities: config
3121 .priority_mapping
3122 .map(|mapping| PriorityMapping::resolve(mapping, name))
3123 .transpose()?,
3124 client: Client::builder()
3125 .user_agent("onetaskgraph")
3126 .build()
3127 .map_err(|e| SourceError::Config {
3128 message: format!("cannot build HTTP client: {e}"),
3129 })?,
3130 created: Mutex::new(Vec::new()),
3131 updated: Mutex::new(Vec::new()),
3132 pacing: Pacing::resolve(config.pacing, name)?,
3133 last_mutation: Mutex::new(None),
3134 board_cache: Mutex::new(None),
3135 search_cache: Mutex::new(None),
3136 narrowed_cache: Mutex::new(BTreeMap::new()),
3137 resolved_cache: Mutex::new(BTreeMap::new()),
3138 search_next: Mutex::new(BTreeMap::new()),
3139 fields_cache: Mutex::new(None),
3140 repository_cache: Mutex::new(BTreeMap::new()),
3141 ledger,
3142 })
3143 }
3144
3145 /// A snapshot of every request this source has sent, and what each cost.
3146 ///
3147 /// A value to hold and compare rather than a borrow of the accounting itself, so two
3148 /// of them can sit side by side. When this source was built with
3149 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
3150 /// point of building it that way.
3151 #[must_use]
3152 pub fn accounting(&self) -> accounting::Session {
3153 self.ledger.snapshot()
3154 }
3155
3156 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
3157 /// rate limit rather than handing it straight back as an error.
3158 ///
3159 /// Retrying is safe for every document here, including the mutations, and the reason
3160 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
3161 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
3162 /// this replays has already taken effect. An outcome this source cannot know — the
3163 /// send failed, or the body could not be read, so the mutation may well have landed —
3164 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
3165 /// attempt. A duplicate write would come from replaying one of those, and none is
3166 /// replayed.
3167 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
3168 if is_mutation(query)
3169 && ![
3170 graphql::ADD_COMMENT,
3171 graphql::UPDATE_COMMENT,
3172 graphql::DELETE_COMMENT,
3173 ]
3174 .contains(&query)
3175 {
3176 let mut cache = self.resolved_cache()?;
3177 for argument in ["input", "second", "third", "clear"] {
3178 if let Some(input) = variables.get(argument) {
3179 cache.retain(|id, item| {
3180 !["id", "issueId", "subjectId", "itemId"].iter().any(|key| {
3181 input
3182 .get(key)
3183 .and_then(Value::as_str)
3184 .is_some_and(|value| value == id.0 || value == item.item_id)
3185 })
3186 });
3187 }
3188 }
3189 }
3190 let doing = operation_description(query);
3191 let mut waited = Duration::ZERO;
3192 let mut waits = 0_u32;
3193 let mut backoff = self.pacing.retry_backoff;
3194 loop {
3195 if is_mutation(query) {
3196 let spacing = self.reserve_mutation_slot();
3197 if !spacing.is_zero() {
3198 tokio::time::sleep(spacing).await;
3199 }
3200 }
3201 let attempt = self.send_once(query, &variables).await;
3202 if is_mutation(query) {
3203 self.finish_mutation();
3204 }
3205 let limited = match attempt {
3206 Ok(data) => return Ok(data),
3207 Err(Attempt::Failed(error)) => return Err(error),
3208 Err(Attempt::Limited(limited)) => limited,
3209 };
3210 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3211 // move that extends a secondary limit, so a hint below the schedule's own next
3212 // wait is raised to it.
3213 let wait = match limited.hint {
3214 Some(hint) => Duration::from_secs(hint).max(backoff),
3215 None => backoff,
3216 };
3217 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3218 // A wait of nothing spends none of the budget, so it is exhaustion rather
3219 // than a retry. `Pacing::resolve` rules out every way of configuring one
3220 // except a budget of zero, where reporting the first refusal is the ask.
3221 if wait.is_zero() || wait > remaining {
3222 return Err(limited.exhausted(
3223 doing,
3224 waits,
3225 waited,
3226 wait,
3227 self.pacing.retry_budget,
3228 ));
3229 }
3230 tokio::time::sleep(wait).await;
3231 waited += wait;
3232 waits += 1;
3233 backoff = backoff.saturating_mul(2);
3234 }
3235 }
3236
3237 /// The next moment a content-creating mutation may leave this source, as a wait from
3238 /// now.
3239 ///
3240 /// The slot is reserved under the lock and the waiting happens outside it, so two
3241 /// callers take two slots rather than the same one — and no lock is held across an
3242 /// await.
3243 ///
3244 /// The moment it is spaced from is the previous mutation's *completion*, which
3245 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3246 /// own is the wrong thing to measure from.
3247 fn reserve_mutation_slot(&self) -> Duration {
3248 if self.pacing.min_mutation_interval.is_zero() {
3249 return Duration::ZERO;
3250 }
3251 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3252 // it would turn an earlier failure into a second one for no gain.
3253 let mut last = self
3254 .last_mutation
3255 .lock()
3256 .unwrap_or_else(std::sync::PoisonError::into_inner);
3257 let now = Instant::now();
3258 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3259 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3260 let at = last.map_or(now, |previous| {
3261 previous
3262 .checked_add(self.pacing.min_mutation_interval)
3263 .map_or(now, |earliest| earliest.max(now))
3264 });
3265 *last = Some(at);
3266 at.saturating_duration_since(now)
3267 }
3268
3269 /// Record that a content-creating mutation has finished, so the next one is spaced
3270 /// from here rather than from the moment this one was released.
3271 ///
3272 /// This source can only choose when a request *departs*; the limiter counts when it
3273 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3274 /// departure from the last therefore hands the limiter a gap of the interval less that
3275 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3276 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3277 /// slower machine while passing on a quick one.
3278 ///
3279 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3280 /// previous request had already arrived before its response came back, so its arrival
3281 /// is no later than this moment, and the next mutation is released at least the
3282 /// interval after this moment and arrives no earlier than it is released: the gap the
3283 /// limiter measures is therefore at least the interval, whatever transit costs and on
3284 /// whatever platform. The price is that a mutation's own round trip no longer counts
3285 /// towards its spacing, which makes this source slightly slower than the configured
3286 /// rate rather than slightly faster — the safe side of a limit that punishes being
3287 /// wrong by refusing reads for the next fifty minutes.
3288 ///
3289 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3290 /// and one that never left costs only a wait nobody needed.
3291 fn finish_mutation(&self) {
3292 if self.pacing.min_mutation_interval.is_zero() {
3293 return;
3294 }
3295 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3296 let mut last = self
3297 .last_mutation
3298 .lock()
3299 .unwrap_or_else(std::sync::PoisonError::into_inner);
3300 let now = Instant::now();
3301 // `max` rather than an assignment: a concurrent caller may already have reserved a
3302 // slot further out, and completing this request must never pull that slot back in.
3303 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3304 }
3305
3306 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3307 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3308 ///
3309 /// This is the one place a request leaves this crate, which is why the accounting is
3310 /// here rather than at each of the callers: a read path added later is counted without
3311 /// anybody remembering to count it, and
3312 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3313 /// when one is not.
3314 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3315 let Attempted {
3316 result,
3317 limits,
3318 reported_cost,
3319 } = self.attempt(query, variables).await;
3320 // No `otherwise` name: every document this source sends is one of its own, and the
3321 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3322 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3323 let outcome = match &result {
3324 Ok(_) => accounting::Outcome::Answered,
3325 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3326 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3327 };
3328 self.ledger.record(sending.finished(outcome, limits));
3329 result
3330 }
3331
3332 /// The attempt itself, with what its response said about the rate limit alongside.
3333 ///
3334 /// The two are returned together rather than recorded here because every one of the
3335 /// early exits below is a different outcome, and a record written at each of them is a
3336 /// record one of them can be added without.
3337 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3338 let mut limits = accounting::RateLimit::default();
3339 let mut reported_cost = None;
3340 let result = self
3341 .attempted(query, variables, &mut limits, &mut reported_cost)
3342 .await;
3343 Attempted {
3344 result,
3345 limits,
3346 reported_cost,
3347 }
3348 }
3349
3350 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3351 async fn attempted(
3352 &self,
3353 query: &str,
3354 variables: &Value,
3355 limits: &mut accounting::RateLimit,
3356 reported_cost: &mut Option<u64>,
3357 ) -> Result<Value, Attempt> {
3358 let response = self
3359 .client
3360 .post(self.endpoint.clone())
3361 .bearer_auth(self.token.expose_secret())
3362 .json(&json!({"query": query, "variables": variables}))
3363 .send()
3364 .await
3365 .map_err(|e| {
3366 Attempt::Failed(SourceError::Unavailable {
3367 message: format!("GitHub GraphQL request failed: {e}"),
3368 })
3369 })?;
3370 let status = response.status();
3371 let header = |name: &str| whole_seconds(response.headers().get(name));
3372 *limits = accounting::RateLimit::read(|name| {
3373 response
3374 .headers()
3375 .get(name)
3376 .and_then(|value| value.to_str().ok())
3377 .map(str::to_owned)
3378 });
3379 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3380 // that are not text at all — is "not known to be exhausted". This never makes a
3381 // response a refusal on its own: it says which limiter a refusal is attributed to
3382 // and where its hint comes from, so a value this cannot read costs a hint rather
3383 // than an answer.
3384 let exhausted = response
3385 .headers()
3386 .get("x-ratelimit-remaining")
3387 .and_then(|value| value.to_str().ok())
3388 == Some("0");
3389 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3390 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3391 // which is the same question answered as an absolute time. Nothing else here is a
3392 // hint, and a schedule is what answers a refusal that carries none.
3393 let hint = header("retry-after").or_else(|| {
3394 exhausted
3395 .then(|| header("x-ratelimit-reset"))
3396 .flatten()
3397 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3398 });
3399 // Read before it is parsed, because the evidence which tells a secondary rate
3400 // limit from a rejected credential is in the body of a response whose status says
3401 // only "forbidden" — and a non-success response was never parsed at all.
3402 let body = response.text().await.map_err(|e| {
3403 Attempt::Failed(SourceError::Unavailable {
3404 message: format!("GitHub GraphQL response could not be read: {e}"),
3405 })
3406 })?;
3407 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3408 return Err(Attempt::Limited(Limited { limiter, hint }));
3409 }
3410 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3411 return Err(Attempt::Failed(SourceError::Auth {
3412 message: format!(
3413 "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"
3414 ),
3415 }));
3416 }
3417 if !status.is_success() {
3418 return Err(Attempt::Failed(SourceError::Unavailable {
3419 message: format!("GitHub GraphQL returned HTTP {status}"),
3420 }));
3421 }
3422 // GitHub reports what a call cost only when the document asked it to, and no
3423 // document this source sends does — so this is `None` here and carries the figure
3424 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3425 // pick up is a `dryRun` probe's cost, which is some other document's.
3426 *reported_cost = serde_json::from_str::<Value>(&body)
3427 .ok()
3428 .as_ref()
3429 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3430 .and_then(Value::as_u64);
3431 self.answer(&body).map_err(Attempt::Failed)
3432 }
3433
3434 /// What one successful HTTP response says, once its GraphQL errors are read.
3435 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3436 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3437 message: format!("GitHub returned invalid JSON: {e}"),
3438 })?;
3439 let errors = body
3440 .get("errors")
3441 .map(|value| {
3442 value.as_array().ok_or_else(|| SourceError::Malformed {
3443 message: "GitHub response errors is not an array".into(),
3444 })
3445 })
3446 .transpose()?;
3447 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3448 let messages = errors
3449 .iter()
3450 .filter_map(|e| e.get("message").and_then(Value::as_str))
3451 .collect::<Vec<_>>()
3452 .join("; ");
3453 let message = if messages.is_empty() {
3454 "GitHub returned GraphQL errors".into()
3455 } else {
3456 messages
3457 };
3458 let normalized = message.to_ascii_lowercase();
3459 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3460 return Err(SourceError::Auth {
3461 message: format!(
3462 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3463 self.credential_name
3464 ),
3465 });
3466 }
3467 return Err(SourceError::Refused { message });
3468 }
3469 body.get("data")
3470 .filter(|data| data.is_object())
3471 .cloned()
3472 .ok_or_else(|| SourceError::Malformed {
3473 message: "GitHub response has no data object".into(),
3474 })
3475 }
3476
3477 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3478 // GraphQL cannot independently page them inside the outer item page. This source page is
3479 // deliberately bounded at that published maximum; the live drift journey exercises it.
3480 async fn board_page(
3481 &self,
3482 items_after: Option<&str>,
3483 items_first: u32,
3484 ) -> Result<Value, SourceError> {
3485 let data = self
3486 .graphql(
3487 graphql::BOARD,
3488 json!({"owner":self.owner,"number":self.project_number,
3489 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3490 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3491 )
3492 .await?;
3493 data.pointer("/owner/projectV2")
3494 .filter(|v| !v.is_null())
3495 .cloned()
3496 .ok_or_else(|| SourceError::Refused {
3497 message: format!(
3498 "GitHub project {}/{} was not found or is not visible to the token",
3499 self.owner, self.project_number
3500 ),
3501 })
3502 }
3503
3504 /// The search that finds the issues of this board, narrowed by `also` when it is
3505 /// given.
3506 ///
3507 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3508 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3509 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3510 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3511 /// from a task by the `parent` field each issue carries rather than by the search.
3512 fn board_search(&self, also: Option<&str>) -> String {
3513 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3514 match also {
3515 Some(also) => format!("{scope} {also}"),
3516 None => scope,
3517 }
3518 }
3519
3520 /// One issue this source reached directly, as the board item a read of the board would
3521 /// have produced — or `None` when this board does not hold it.
3522 ///
3523 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3524 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3525 /// item's own id, that item's field values, and the issue as its content. One resolver
3526 /// for both routes is what makes an issue read through a search, through its own node
3527 /// id, or through its project's sub-issues report the same title, the same status, the
3528 /// same labels and the same qualified id.
3529 ///
3530 /// An issue with no entry for *this* board is not this source's to report, which is
3531 /// what keeps an id naming some other repository's issue from being answered as an item
3532 /// of this board. That answer is given about an **exhausted** connection and never
3533 /// about an unread page: the entry is looked for on the page in hand, and only if that
3534 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3535 /// rest of it.
3536 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3537 if optional_str(issue, "__typename")? != Some("Issue") {
3538 return Ok(None);
3539 }
3540 let memberships = issue
3541 .get("projectItems")
3542 .ok_or_else(|| SourceError::Malformed {
3543 message: "GitHub issue is missing projectItems".into(),
3544 })?;
3545 let nodes = memberships
3546 .get("nodes")
3547 .and_then(Value::as_array)
3548 .ok_or_else(|| SourceError::Malformed {
3549 message: "GitHub issue projectItems.nodes is not an array".into(),
3550 })?;
3551 let held = match self.board_entry(nodes) {
3552 Some(held) => held.clone(),
3553 None => {
3554 let info = memberships
3555 .get("pageInfo")
3556 .ok_or_else(|| SourceError::Malformed {
3557 message: "GitHub issue projectItems has no pageInfo".into(),
3558 })?;
3559 // The page held no entry for this board. Whether that means the issue is
3560 // not on it is a question about the rest of the connection, and only a
3561 // connection with no rest answers it here.
3562 if !required_bool(info, "hasNextPage")? {
3563 return Ok(None);
3564 }
3565 let cursor = required_str(info, "endCursor")?;
3566 validate_cursor_progress(None, cursor)?;
3567 let issue_id = required_str(issue, "id")?;
3568 match self.board_membership(issue_id, cursor).await? {
3569 Some(held) => held,
3570 None => return Ok(None),
3571 }
3572 }
3573 };
3574 let item = json!({
3575 "id": required_str(&held, "id")?,
3576 "project": held.get("project"),
3577 "fieldValues": held.get("fieldValues"),
3578 "content": issue,
3579 });
3580 self.resolve(&item)
3581 }
3582
3583 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3584 ///
3585 /// One spelling of *which membership is this board's*, so the page a read carries and
3586 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3587 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3588 nodes.iter().find(|node| {
3589 node.pointer("/project/number").and_then(Value::as_u64)
3590 == Some(u64::from(self.project_number))
3591 })
3592 }
3593
3594 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3595 ///
3596 /// The recovery read: a page of memberships that holds no entry for this board says
3597 /// nothing about the memberships past it, so the connection is walked to exhaustion
3598 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3599 /// positive answer — the whole connection was read and no entry named this board —
3600 /// rather than a failure, and the walk is held to
3601 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3602 /// with a cursor that does not advance is refused instead of spun on.
3603 async fn board_membership(
3604 &self,
3605 issue: &str,
3606 after: &str,
3607 ) -> Result<Option<Value>, SourceError> {
3608 let mut after = after.to_owned();
3609 loop {
3610 let data = self
3611 .graphql(
3612 graphql::ISSUE_BOARD_ITEMS,
3613 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3614 "nestedFirst":NESTED_PAGE_SIZE}),
3615 )
3616 .await?;
3617 let Some(connection) = data
3618 .pointer("/node/projectItems")
3619 .filter(|value| !value.is_null())
3620 else {
3621 // The id resolved to nothing, or to something with no memberships to walk —
3622 // which is the same answer as a connection holding no entry for this board.
3623 return Ok(None);
3624 };
3625 let nodes = connection
3626 .get("nodes")
3627 .and_then(Value::as_array)
3628 .ok_or_else(|| SourceError::Malformed {
3629 message: "GitHub issue projectItems.nodes is not an array".into(),
3630 })?;
3631 if let Some(held) = self.board_entry(nodes) {
3632 return Ok(Some(held.clone()));
3633 }
3634 let info = connection
3635 .get("pageInfo")
3636 .ok_or_else(|| SourceError::Malformed {
3637 message: "GitHub issue projectItems has no pageInfo".into(),
3638 })?;
3639 let next = required_bool(info, "hasNextPage")?
3640 .then(|| required_str(info, "endCursor"))
3641 .transpose()?;
3642 match next {
3643 Some(next) => {
3644 validate_cursor_progress(Some(&after), next)?;
3645 after = next.to_owned();
3646 }
3647 None => return Ok(None),
3648 }
3649 }
3650 }
3651
3652 /// One page of a board-scoped issue search, and where the next page resumes.
3653 async fn search_page(
3654 &self,
3655 search: &str,
3656 first: u32,
3657 after: Option<&str>,
3658 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3659 let data = self
3660 .graphql(
3661 graphql::SEARCH_ISSUES,
3662 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3663 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3664 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3665 )
3666 .await?;
3667 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3668 message: "GitHub search response has no search connection".into(),
3669 })?;
3670 let mut found = Vec::new();
3671 for node in connection
3672 .get("nodes")
3673 .and_then(Value::as_array)
3674 .ok_or_else(|| SourceError::Malformed {
3675 message: "GitHub search nodes is not an array".into(),
3676 })?
3677 {
3678 if let Some(resolved) = self.resolve_issue(node).await? {
3679 found.push(resolved);
3680 }
3681 }
3682 let info = connection
3683 .get("pageInfo")
3684 .ok_or_else(|| SourceError::Malformed {
3685 message: "GitHub search connection has no pageInfo".into(),
3686 })?;
3687 let next = required_bool(info, "hasNextPage")?
3688 .then(|| required_str(info, "endCursor"))
3689 .transpose()?
3690 .map(str::to_owned);
3691 if let Some(next) = &next {
3692 validate_cursor_progress(after, next)?;
3693 }
3694 Ok((found, next))
3695 }
3696
3697 /// Every issue this board holds, completed with what this run wrote.
3698 ///
3699 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3700 /// is an index and is eventually consistent, so an issue this run created seconds ago
3701 /// can be absent from it, and a project listed straight after being written would
3702 /// otherwise be missing from its own board. What is added back is only what this
3703 /// process itself wrote, out of [`Self::created`], which lives and dies with the
3704 /// process.
3705 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3706 let found = self.searched_issues().await?;
3707 self.completed_with_written(found, |_| true)
3708 }
3709
3710 /// Every issue this board's own search reports, walked to exhaustion, read once per
3711 /// source.
3712 ///
3713 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3714 /// needs it too and the two would otherwise walk the same search twice in one command.
3715 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3716 /// is.
3717 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3718 let cached = self.search_cache()?.clone();
3719 if let Some(held) = cached {
3720 return Ok(held);
3721 }
3722 let mut after: Option<String> = None;
3723 let mut found = Vec::new();
3724 let search = self.board_search(None);
3725 loop {
3726 let (page, next) = self
3727 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3728 .await?;
3729 found.extend(page);
3730 match next {
3731 Some(next) => after = Some(next),
3732 None => break,
3733 }
3734 }
3735 *self.search_cache()? = Some(found.clone());
3736 Ok(found)
3737 }
3738
3739 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3740 fn search_cache(
3741 &self,
3742 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3743 self.search_cache
3744 .lock()
3745 .map_err(|_| SourceError::Unavailable {
3746 message: "this source's view of the board's issues was left inconsistent by an \
3747 earlier failure; next: run the command again"
3748 .into(),
3749 })
3750 }
3751
3752 /// `found`, with everything this run wrote that `keep` accepts and the read did not
3753 /// report.
3754 ///
3755 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3756 /// at all: the search index is behind, and a node read of an item filed moments ago can
3757 /// be too.
3758 fn completed_with_written(
3759 &self,
3760 mut found: Vec<Resolved>,
3761 keep: impl Fn(&Resolved) -> bool,
3762 ) -> Result<Vec<Resolved>, SourceError> {
3763 for own in self.created()?.iter().filter(|own| keep(own)) {
3764 if !found.iter().any(|item| item.id == own.id) {
3765 found.push(own.clone());
3766 }
3767 }
3768 Ok(found)
3769 }
3770
3771 /// What resolving one node id reached.
3772 ///
3773 /// Three answers rather than an `Option`, because a board *draft* is none of the other
3774 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3775 /// is completed by a read of the draft itself rather than reported as nothing.
3776 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3777 let asked = self
3778 .graphql(
3779 graphql::ISSUE,
3780 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3781 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3782 )
3783 .await;
3784 let data = match asked {
3785 Ok(data) => data,
3786 // A string that is not a node id at all is not a failure to report: it is an id
3787 // this board does not hold, which is what every read of one already answers.
3788 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3789 Err(error) => return Err(error),
3790 };
3791 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3792 return Ok(Reached::Nothing);
3793 };
3794 if optional_str(node, "__typename")? == Some("DraftIssue") {
3795 return Ok(Reached::Draft);
3796 }
3797 Ok(match self.resolve_issue(node).await? {
3798 Some(item) => Reached::Held(Box::new(item)),
3799 None => Reached::Nothing,
3800 })
3801 }
3802
3803 /// One item of this board by its own id, whatever kind it is.
3804 ///
3805 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
3806 /// run wrote is read first, because a node read of an item created moments ago can
3807 /// still be behind the board field values written onto it — see [`Self::created`].
3808 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3809 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
3810 return Ok(Some(own.clone()));
3811 }
3812 match self.reach(id).await? {
3813 Reached::Held(item) => Ok(Some(*item)),
3814 Reached::Nothing => Ok(None),
3815 Reached::Draft => self.draft_by_id(id).await,
3816 }
3817 }
3818
3819 fn resolved_cache(
3820 &self,
3821 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<NativeId, Resolved>>, SourceError> {
3822 self.resolved_cache
3823 .lock()
3824 .map_err(|_| SourceError::Unavailable {
3825 message: "resolved item records were left inconsistent; run the command again"
3826 .into(),
3827 })
3828 }
3829
3830 /// Reuse a record this invocation already resolved. The mutation sender invalidates
3831 /// it before writing, so a partial failure cannot leave a pre-write binding behind.
3832 async fn bound_item(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3833 let cached = self.resolved_cache()?.get(id).cloned();
3834 match cached {
3835 Some(item) => Ok(Some(item)),
3836 None => self.item_by_id(id).await,
3837 }
3838 }
3839
3840 /// One board draft by its own id, with the board item it sits in — or `None` when no
3841 /// item of this board is that draft's.
3842 ///
3843 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
3844 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
3845 /// links a draft to one board item, so the page this read carries is the whole of that
3846 /// connection, and a page that reports more than it holds is refused rather than read
3847 /// as an answer about memberships nobody read.
3848 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3849 let data = self
3850 .graphql(
3851 graphql::DRAFT,
3852 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3853 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
3854 )
3855 .await?;
3856 // Gone between the two reads is an answer — the draft is no longer there. Anything
3857 // else than the draft [`Self::reach`] was just told this id is, is not one.
3858 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
3859 return Ok(None);
3860 };
3861 if optional_str(draft, "__typename")? != Some("DraftIssue") {
3862 return Err(SourceError::Malformed {
3863 message: format!(
3864 "GitHub answered {} as a draft and then as something else",
3865 id.0
3866 ),
3867 });
3868 }
3869 if required_str(draft, "id")? != id.0 {
3870 return Err(SourceError::Malformed {
3871 message: format!("GitHub answered a different draft for {}", id.0),
3872 });
3873 }
3874 let memberships = draft
3875 .get("projectV2Items")
3876 .ok_or_else(|| SourceError::Malformed {
3877 message: format!("GitHub draft {} is missing projectV2Items", id.0),
3878 })?;
3879 let nodes = memberships
3880 .get("nodes")
3881 .and_then(Value::as_array)
3882 .ok_or_else(|| SourceError::Malformed {
3883 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
3884 })?;
3885 let info = memberships
3886 .get("pageInfo")
3887 .ok_or_else(|| SourceError::Malformed {
3888 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
3889 })?;
3890 // Read whether or not this board's entry is on the page: a page claiming more than
3891 // the one item GitHub links a draft to is a malformed answer either way.
3892 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
3893 return Err(SourceError::Malformed {
3894 message: format!(
3895 "GitHub draft {} reports more board items than the one GitHub links a draft \
3896 to",
3897 id.0
3898 ),
3899 });
3900 }
3901 if let Some(node) = nodes.first()
3902 && node
3903 .pointer("/project/number")
3904 .and_then(Value::as_u64)
3905 .is_none()
3906 {
3907 return Err(SourceError::Malformed {
3908 message: format!(
3909 "GitHub draft {} board item has no numeric project number",
3910 id.0
3911 ),
3912 });
3913 }
3914 let Some(held) = self.board_entry(nodes) else {
3915 return Ok(None);
3916 };
3917 if required_str(
3918 held.get("project").ok_or_else(|| SourceError::Malformed {
3919 message: format!("GitHub draft {} board item has no project", id.0),
3920 })?,
3921 "id",
3922 )? != self.board_fields().await?.id.as_str()
3923 {
3924 return Ok(None);
3925 }
3926 let item = json!({
3927 "id": required_str(held, "id")?,
3928 "project": held.get("project"),
3929 "fieldValues": held.get("fieldValues"),
3930 "content": draft,
3931 });
3932 self.resolve(&item)
3933 }
3934
3935 /// The board's own id and field definitions, for a write whose item does not carry
3936 /// them — never its items.
3937 ///
3938 /// A board this command has already listed supplies them, since it read them beside its
3939 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
3940 /// is consulted about which items the board holds: see the module documentation for
3941 /// why a question about one known item is answered by reading that item.
3942 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
3943 if let Some(board) = self.board_cache()?.as_ref() {
3944 return Ok(BoardFields {
3945 id: BoardId::parse(&board.id)?,
3946 fields: board.fields.clone(),
3947 });
3948 }
3949 if let Some(held) = self.fields_cache()?.clone() {
3950 return Ok(held);
3951 }
3952 let data = self
3953 .graphql(
3954 graphql::BOARD_FIELDS,
3955 json!({"owner":self.owner,"number":self.project_number,
3956 "nestedFirst":NESTED_PAGE_SIZE}),
3957 )
3958 .await?;
3959 let board = data
3960 .pointer("/boardFields/projectV2")
3961 .filter(|value| !value.is_null())
3962 .ok_or_else(|| SourceError::Refused {
3963 message: format!(
3964 "GitHub project {}/{} was not found or is not visible to the token",
3965 self.owner, self.project_number
3966 ),
3967 })?;
3968 let read = BoardFields {
3969 id: BoardId::parse(required_str(board, "id")?)?,
3970 fields: board.get("fields").cloned().unwrap_or(Value::Null),
3971 };
3972 *self.fields_cache()? = Some(read.clone());
3973 Ok(read)
3974 }
3975
3976 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
3977 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
3978 self.fields_cache
3979 .lock()
3980 .map_err(|_| SourceError::Unavailable {
3981 message: "this source's view of the board's fields was left inconsistent by an \
3982 earlier failure; next: run the command again"
3983 .into(),
3984 })
3985 }
3986
3987 /// What a write to `item` needs of the board, read off that item when it says enough and
3988 /// off [`Self::board_fields`] when it does not.
3989 ///
3990 /// A node read of an item names its board and carries the definition of every field it
3991 /// holds a value of — so an item naming its board, holding a value of the origin field,
3992 /// and, when the write carries a status, holding a `Status` value, needs no read of the
3993 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
3994 /// of may still be on the board, and a view reading it as absent would refuse a write the
3995 /// board can take or skip a field write the board needs, so such an item — and a create,
3996 /// which has no item yet — takes the board's fields from their own read instead.
3997 async fn fields_for(
3998 &self,
3999 item: Option<&Resolved>,
4000 writes_status: bool,
4001 selects_priority: bool,
4002 ) -> Result<BoardFields, SourceError> {
4003 if let Some(item) = item
4004 && let Some(board_id) = item.named_board()
4005 && item.defines(ORIGIN_FIELD)
4006 && (!writes_status || item.defines("Status"))
4007 && (!selects_priority || item.defines(PRIORITY_FIELD))
4008 {
4009 return Ok(BoardFields {
4010 id: board_id,
4011 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4012 });
4013 }
4014 self.board_fields().await
4015 }
4016
4017 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
4018 /// when that id names nothing here with a sub-issue relationship to walk.
4019 ///
4020 /// `None` and an empty answer are different: `None` is *this is not an issue of this
4021 /// GitHub*, which is what sends a project selector on to be read as a name, and an
4022 /// empty vector is a project that holds nothing.
4023 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
4024 let mut after: Option<String> = None;
4025 let mut children = Vec::new();
4026 loop {
4027 let asked = self
4028 .graphql(
4029 graphql::SUB_ISSUES,
4030 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
4031 "nestedFirst":NESTED_PAGE_SIZE,
4032 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
4033 )
4034 .await;
4035 let data = match asked {
4036 Ok(data) => data,
4037 // A string that is not a node id at all is not a failure to report: it is
4038 // the ordinary answer to a selector naming a project by its name.
4039 Err(error) if unresolvable_node(&error) => return Ok(None),
4040 Err(error) => return Err(error),
4041 };
4042 let Some(connection) = data
4043 .pointer("/node/subIssues")
4044 .filter(|value| !value.is_null())
4045 else {
4046 // No such node, or one with no sub-issue relationship — a board draft is
4047 // the one this board can really hold.
4048 return Ok(None);
4049 };
4050 for node in connection
4051 .get("nodes")
4052 .and_then(Value::as_array)
4053 .ok_or_else(|| SourceError::Malformed {
4054 message: "GitHub subIssues.nodes is not an array".into(),
4055 })?
4056 {
4057 if let Some(resolved) = self.resolve_issue(node).await? {
4058 children.push(resolved);
4059 }
4060 }
4061 let info = connection
4062 .get("pageInfo")
4063 .ok_or_else(|| SourceError::Malformed {
4064 message: "GitHub subIssues connection has no pageInfo".into(),
4065 })?;
4066 let next = required_bool(info, "hasNextPage")?
4067 .then(|| required_str(info, "endCursor"))
4068 .transpose()?;
4069 match next {
4070 Some(next) => {
4071 validate_cursor_progress(after.as_deref(), next)?;
4072 after = Some(next.to_owned());
4073 }
4074 None => return Ok(Some(children)),
4075 }
4076 }
4077 }
4078
4079 /// Which issue of this board a project *name* is, or `None` when none is.
4080 ///
4081 /// One bounded query which filters on that name at the server, rather than a walk of
4082 /// every issue the board holds. The name is compared again here: the qualifier narrows
4083 /// what GitHub sends, and this source decides what it names.
4084 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
4085 let search = self.board_search(Some(&title_qualifier(name)));
4086 let mut after = None;
4087 loop {
4088 let (candidates, next) = self
4089 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4090 .await?;
4091 if let Some(item) = candidates.into_iter().find(|item| {
4092 item.kind == BoardKind::Work(ItemKind::Project)
4093 && item.title.eq_ignore_ascii_case(name)
4094 }) {
4095 return Ok(Some(item.id));
4096 }
4097 match next {
4098 Some(next) => after = Some(next),
4099 None => return Ok(None),
4100 }
4101 }
4102 }
4103
4104 /// Everything filed under one project of this board: the sub-issues of the issue that
4105 /// project is.
4106 ///
4107 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
4108 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
4109 /// gains projects, or as another project gains tasks.
4110 ///
4111 /// A qualified id names the issue and is asked for its sub-issues directly: one
4112 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
4113 /// read as a project *name*, which costs the one bounded search
4114 /// [`Self::project_by_name`] makes.
4115 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
4116 let (project, children) = match self.sub_issues(selector).await? {
4117 Some(children) => (selector.clone(), children),
4118 None => match self.project_by_name(&selector.0).await? {
4119 Some(project) => {
4120 let children = self.sub_issues(&project).await?.unwrap_or_default();
4121 (project, children)
4122 }
4123 None => return Ok(Vec::new()),
4124 },
4125 };
4126 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
4127 }
4128
4129 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
4130 /// completed with what this run wrote — the candidates a comment-activity read confirms.
4131 ///
4132 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
4133 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
4134 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
4135 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
4136 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
4137 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
4138 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
4139 /// rather than silently narrowing a caller's answer.
4140 ///
4141 /// The instant is written to the second, rounded down, which can only widen what the
4142 /// search returns; confirmation against each candidate's own comments is what makes the
4143 /// answer exact. The search is an index that lags a write by a second or two — the module
4144 /// documentation records it — so a caller that asks again from its last instant should
4145 /// overlap the two by more than that.
4146 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
4147 let found = self.searched(&updated_qualifier(since)).await?;
4148 self.completed_with_written(found, |_| true)
4149 }
4150
4151 /// Every issue of this board GitHub's issue search reports for the board-scoped search
4152 /// narrowed by `also`, in pages of [`SEARCH_PAGE_SIZE`].
4153 ///
4154 /// Uncompleted: what this process wrote is added by the caller, which knows whether its
4155 /// own record is the fresher of the two.
4156 async fn searched(&self, also: &str) -> Result<Vec<Resolved>, SourceError> {
4157 let search = self.board_search(Some(also));
4158 let mut after: Option<String> = None;
4159 let mut found = Vec::new();
4160 loop {
4161 let (page, next) = self
4162 .search_page(&search, SEARCH_PAGE_SIZE, after.as_deref())
4163 .await?;
4164 found.extend(page);
4165 match next {
4166 Some(next) => after = Some(next),
4167 None => return Ok(found),
4168 }
4169 }
4170 }
4171
4172 /// A bounded task answer; the versioned cursor carries the connection position, how
4173 /// many rows of the page starting there were already handed out, and the own-write ids
4174 /// already observed, including across a new source instance.
4175 ///
4176 /// Every page is sent at [`SEARCH_PAGE_SIZE`] whatever the caller's limit, and a limit is
4177 /// sliced from the pages it needs; why is the module documentation's paging contract.
4178 async fn search_tasks(
4179 &self,
4180 query: &TaskQuery,
4181 page: &PageRequest,
4182 also: &str,
4183 ) -> Result<Page<Task>, SourceError> {
4184 let mut position = match &page.cursor {
4185 None => SearchPosition::default(),
4186 Some(cursor) => serde_json::from_str::<SearchPosition>(&cursor.0)
4187 .ok()
4188 .filter(|position| {
4189 position.version == SEARCH_CURSOR_VERSION
4190 && position.connection.valid_resume(position.offset)
4191 })
4192 .ok_or_else(|| SourceError::Config {
4193 message: "page cursor is invalid".into(),
4194 })?,
4195 };
4196 let search = self.board_search(Some(also));
4197 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
4198 let own = self.with_own_writes(Vec::new())?;
4199 for item in &own {
4200 if !position.own.contains(&item.id) {
4201 position.own.push(item.id.clone());
4202 }
4203 }
4204 let mut tasks = Vec::new();
4205 while !position.connection.exhausted() && tasks.len() < limit {
4206 let first = SEARCH_PAGE_SIZE;
4207 // Page size is part of the key: a short cached answer cannot answer a wider ask.
4208 let key =
4209 serde_json::to_string(&("page", &search, &position.connection.after(), first))
4210 .expect("search page key is serializable");
4211 let cached = if query.commented_since.is_none() {
4212 self.narrowed_cache()?.get(&key).cloned()
4213 } else {
4214 None
4215 };
4216 let (found, next) = match cached {
4217 Some(found) => {
4218 let next = self
4219 .search_next
4220 .lock()
4221 .map_err(|_| SourceError::Unavailable {
4222 message:
4223 "search pagination was left inconsistent; run the command again"
4224 .into(),
4225 })?
4226 .get(&key)
4227 .cloned()
4228 .flatten();
4229 (found, next)
4230 }
4231 None => {
4232 let (found, next) = self
4233 .search_page(&search, first, position.connection.after())
4234 .await?;
4235 if query.commented_since.is_none() {
4236 self.search_next
4237 .lock()
4238 .map_err(|_| SourceError::Unavailable {
4239 message:
4240 "search pagination was left inconsistent; run the command again"
4241 .into(),
4242 })?
4243 .insert(key.clone(), next.clone());
4244 self.narrowed_cache()?.insert(key, found.clone());
4245 }
4246 (found, next)
4247 }
4248 };
4249 let rows = found.len();
4250 for mut item in found.into_iter().skip(position.offset) {
4251 if tasks.len() == limit {
4252 break;
4253 }
4254 position.offset += 1;
4255 if position.own.contains(&item.id) {
4256 if position.seen.contains(&item.id) {
4257 continue;
4258 }
4259 position.seen.push(item.id.clone());
4260 let updated_at = item.updated_at;
4261 let Some(written) = self.search_written(&own, &item.id).await? else {
4262 continue;
4263 };
4264 item = written;
4265 item.updated_at = item.updated_at.max(updated_at);
4266 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4267 }
4268 if item.kind == BoardKind::Work(ItemKind::Task) {
4269 let task = item.task()?;
4270 if task_matches(&task, query, &query.project)
4271 && self.commented_since(&item, query.commented_since).await?
4272 {
4273 tasks.push(task);
4274 }
4275 }
4276 }
4277 if position.offset < rows {
4278 continue;
4279 }
4280 position.offset = 0;
4281 position.connection = match next {
4282 Some(after) => SearchConnection::Continuing {
4283 after: Cursor(after),
4284 },
4285 None => SearchConnection::Exhausted {},
4286 };
4287 }
4288 if position.connection.exhausted() {
4289 for id in position.own.clone() {
4290 if position.seen.contains(&id) {
4291 continue;
4292 }
4293 if tasks.len() == limit {
4294 break;
4295 }
4296 position.seen.push(id.clone());
4297 let Some(item) = self.search_written(&own, &id).await? else {
4298 continue;
4299 };
4300 if item.kind == BoardKind::Work(ItemKind::Task) {
4301 let task = item.task()?;
4302 if task_matches(&task, query, &query.project)
4303 && self.commented_since(&item, query.commented_since).await?
4304 {
4305 tasks.push(task);
4306 }
4307 }
4308 }
4309 }
4310 let more = !position.connection.exhausted()
4311 || position.own.iter().any(|id| !position.seen.contains(id));
4312 Ok(Page {
4313 items: tasks,
4314 next: more.then(|| {
4315 Cursor(serde_json::to_string(&position).expect("search position is serializable"))
4316 }),
4317 })
4318 }
4319
4320 /// A resumed process has the ids but no write records; resolve only a record the
4321 /// current page needs, by its uncached node read rather than the lagging search index.
4322 async fn search_written(
4323 &self,
4324 own: &[Resolved],
4325 id: &NativeId,
4326 ) -> Result<Option<Resolved>, SourceError> {
4327 match own.iter().find(|item| item.id == *id) {
4328 Some(item) => Ok(Some(item.clone())),
4329 None => self.item_by_id(id).await,
4330 }
4331 }
4332
4333 /// The candidates for a task query carrying a text, metadata or origin predicate, read
4334 /// without enumerating the board — or `None` for a query carrying none of the three, which
4335 /// keeps the reads it always had.
4336 ///
4337 /// An origin is answered by [`Self::origin_candidates`], whatever else the query carries,
4338 /// because it names at most a handful of items. Text and metadata are answered by one
4339 /// board-scoped issue search carrying every term — see [`narrowing_qualifiers`] — narrowed
4340 /// further by `updated:>=` when the query also asks for comment activity, since both
4341 /// qualifiers must hold of an issue the answer keeps. Every candidate is confirmed in
4342 /// process afterwards by the same predicates [`task_matches`] applies to every read.
4343 ///
4344 /// Completed with what this process wrote, its own record winning over the index's copy
4345 /// of the same item: see [`Self::with_own_writes`].
4346 async fn narrowed(&self, query: &TaskQuery) -> Result<Option<Vec<Resolved>>, SourceError> {
4347 let asked = match (&query.origin, narrowing_qualifiers(query)) {
4348 (Some(origin), _) => Narrowing::Origin(origin.clone()),
4349 (None, Some(qualifiers)) => Narrowing::Search(match query.commented_since {
4350 Some(since) => format!("{} {qualifiers}", updated_qualifier(since)),
4351 None => qualifiers,
4352 }),
4353 (None, None) => return Ok(None),
4354 };
4355 // A question about comment activity is asked afresh every time, as it always was: it
4356 // is the one a caller polls from one source while waiting for the index, and an
4357 // answer held from the first poll would be the answer to every later one.
4358 let key = query.commented_since.is_none().then(|| asked.key());
4359 let cached = match &key {
4360 Some(key) => self.narrowed_cache()?.get(key).cloned(),
4361 None => None,
4362 };
4363 let found = match cached {
4364 Some(found) => found,
4365 None => {
4366 let found = match &asked {
4367 Narrowing::Origin(origin) => self.origin_candidates(origin).await?,
4368 Narrowing::Search(also) => self.searched(also).await?,
4369 };
4370 if let Some(key) = key {
4371 self.narrowed_cache()?.insert(key, found.clone());
4372 }
4373 found
4374 }
4375 };
4376 self.with_own_writes(found).map(Some)
4377 }
4378
4379 /// Every item of this board that may carry `origin` — a superset of those that do — found
4380 /// by [`graphql::ORIGIN_LOOKUP`] and never by enumerating the board.
4381 ///
4382 /// The union of the board's own field filter over the `onetaskgraph.origin` text field —
4383 /// which reads the field every carrier holds, whichever release wrote it — and the
4384 /// board-scoped issue search for the same id as a phrase in the body, where this source
4385 /// mirrors it. The caller adds the third read, this process's own writes. Candidates are
4386 /// returned unconfirmed; [`task_matches`] compares each one's own origin field with the
4387 /// query's, exactly.
4388 ///
4389 /// Both connections are walked to exhaustion, each from its own cursor. One that has
4390 /// already ended is sent its last cursor again, which answers an empty page, so the one
4391 /// document serves every page of either. What the two leave is stated in the module
4392 /// documentation: a carrier another process added within the last second or two, before
4393 /// either index has it.
4394 async fn origin_candidates(&self, origin: &str) -> Result<Vec<Resolved>, SourceError> {
4395 let filter = format!("{ORIGIN_FIELD}:{}", quoted(origin));
4396 let search = self.board_search(Some(&format!("in:body {}", quoted(&as_stored(origin)))));
4397 let mut items_after: Option<String> = None;
4398 let mut search_after: Option<String> = None;
4399 let mut found: Vec<Resolved> = Vec::new();
4400 let keep = |resolved: Resolved, found: &mut Vec<Resolved>| {
4401 if !found.iter().any(|held| held.id == resolved.id) {
4402 found.push(resolved);
4403 }
4404 };
4405 loop {
4406 let data = self
4407 .graphql(
4408 graphql::ORIGIN_LOOKUP,
4409 json!({"owner":self.owner,"number":self.project_number,"filter":filter,
4410 "search":search,"type":"ISSUE","originFirst":ORIGIN_PAGE_SIZE,
4411 "itemsAfter":items_after,"searchAfter":search_after,
4412 "nestedFirst":NESTED_PAGE_SIZE,"boardItems":BOARD_ITEMS_PAGE_SIZE,
4413 "duplicates":true}),
4414 )
4415 .await?;
4416 let items = data
4417 .pointer("/originItems/projectV2/items")
4418 .filter(|value| !value.is_null())
4419 .ok_or_else(|| SourceError::Refused {
4420 message: format!(
4421 "GitHub project {}/{} was not found or is not visible to the token",
4422 self.owner, self.project_number
4423 ),
4424 })?;
4425 for item in optional_nodes(Some(items), "project items")?
4426 .into_iter()
4427 .flatten()
4428 {
4429 // The board's own items list its drafts too, and a draft is not an issue: no
4430 // narrowed read answers with one, whatever its origin field holds.
4431 if let Some(resolved) = self.resolve(item)?
4432 && resolved.content_kind == ContentKind::Issue
4433 {
4434 keep(resolved, &mut found);
4435 }
4436 }
4437 let searched = data.get("search").ok_or_else(|| SourceError::Malformed {
4438 message: "GitHub search response has no search connection".into(),
4439 })?;
4440 for node in optional_nodes(Some(searched), "search")?
4441 .into_iter()
4442 .flatten()
4443 {
4444 if let Some(resolved) = self.resolve_issue(node).await? {
4445 keep(resolved, &mut found);
4446 }
4447 }
4448 let items_next = resumed(items, items_after.as_deref())?;
4449 let search_next = resumed(searched, search_after.as_deref())?;
4450 if !items_next.has_more() && !search_next.has_more() {
4451 return Ok(found);
4452 }
4453 items_after = items_next.cursor();
4454 search_after = search_next.cursor();
4455 }
4456 }
4457
4458 /// `found`, with every item this process created or wrote in its place, and every one of
4459 /// them the read did not report added.
4460 ///
4461 /// This process's own record wins over the read's copy of the same item, because a read
4462 /// of an item written moments ago can still be behind what was written onto it — the
4463 /// origin field included, which is the one a narrowed read is confirmed against — and a
4464 /// read that still names an item under a predicate this process's write moved it out of
4465 /// must not return it. The one thing the read knows that the record cannot is when GitHub
4466 /// last saw the item change, which is what a comment-activity read rules a candidate out
4467 /// by, so the read's `updatedAt` is kept when the record has none of its own. See
4468 /// [`Self::created`] and [`Self::updated`](GitHubProjectsSource::updated).
4469 fn with_own_writes(&self, mut found: Vec<Resolved>) -> Result<Vec<Resolved>, SourceError> {
4470 // A board draft is not an issue, so no narrowed read returns one, and this process
4471 // having written one does not make it an answer either.
4472 let own: Vec<Resolved> = self
4473 .created()?
4474 .iter()
4475 .chain(self.updated()?.iter())
4476 .filter(|own| own.content_kind == ContentKind::Issue)
4477 .cloned()
4478 .collect();
4479 for mut own in own {
4480 self.resolved_cache()?.insert(own.id.clone(), own.clone());
4481 match found.iter_mut().find(|read| read.id == own.id) {
4482 Some(read) => {
4483 own.updated_at = own.updated_at.max(read.updated_at);
4484 *read = own;
4485 }
4486 None => found.push(own),
4487 }
4488 }
4489 Ok(found)
4490 }
4491
4492 /// Whether `item` has a comment created or last edited at or after `since` — always, when
4493 /// there is no instant to hold it to.
4494 ///
4495 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
4496 /// or after the instant moved it there: an issue not updated since holds no such comment,
4497 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
4498 /// only as far as the first that matches. A board draft is not an issue and has no
4499 /// comments, so it never matches.
4500 async fn commented_since(
4501 &self,
4502 item: &Resolved,
4503 since: Option<DateTime<Utc>>,
4504 ) -> Result<bool, SourceError> {
4505 let Some(since) = since else {
4506 return Ok(true);
4507 };
4508 if item.content_kind == ContentKind::DraftIssue
4509 || item.updated_at.is_some_and(|updated| updated < since)
4510 {
4511 return Ok(false);
4512 }
4513 let query = TaskQuery {
4514 commented_since: Some(since),
4515 ..TaskQuery::default()
4516 };
4517 let mut after: Option<String> = None;
4518 loop {
4519 let data = self
4520 .graphql(
4521 graphql::ISSUE_COMMENTS,
4522 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
4523 )
4524 .await?;
4525 let Some(connection) = data
4526 .get("node")
4527 .filter(|value| !value.is_null())
4528 .and_then(|node| node.get("comments"))
4529 .filter(|value| !value.is_null())
4530 else {
4531 // Removed since the search reported it: no longer an issue with comments.
4532 return Ok(false);
4533 };
4534 let comments = optional_nodes(Some(connection), "issue comments")?
4535 .into_iter()
4536 .flatten()
4537 .map(comment_from)
4538 .collect::<Result<Vec<_>, _>>()?;
4539 if query.comments_match(&comments) {
4540 return Ok(true);
4541 }
4542 match next_cursor(connection)? {
4543 Some(next) => {
4544 validate_cursor_progress(after.as_deref(), &next.0)?;
4545 after = Some(next.0);
4546 }
4547 None => return Ok(false),
4548 }
4549 }
4550 }
4551
4552 /// Every item on the board: the union of both enumerations GitHub offers of one.
4553 ///
4554 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
4555 /// board **draft** and reads the board's own fields beside its items, and only the search
4556 /// reports an item that connection is behind on. The module documentation is where the lag and the
4557 /// measurements behind it are written down.
4558 ///
4559 /// A search result is admitted on the same terms as any other issue this source reaches
4560 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
4561 /// names *this* board — so an issue the index still believes is here after it was taken
4562 /// off is refused rather than reported.
4563 ///
4564 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4565 /// which is what the cache could otherwise have broken.
4566 async fn board(&self) -> Result<Board, SourceError> {
4567 let cached = self.board_cache()?.clone();
4568 let mut board = match cached {
4569 Some(board) => board,
4570 None => {
4571 let read = self.read_board().await?;
4572 *self.board_cache()? = Some(read.clone());
4573 read
4574 }
4575 };
4576 for held in self.searched_issues().await? {
4577 if !board.items.iter().any(|item| item.id == held.id) {
4578 board.items.push(held);
4579 }
4580 }
4581 for own in self.created()?.iter() {
4582 if !board.items.iter().any(|item| item.id == own.id) {
4583 board.items.push(own.clone());
4584 }
4585 }
4586 Ok(board)
4587 }
4588
4589 /// This process's own view of the board, or the refusal a poisoned lock is.
4590 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4591 self.board_cache
4592 .lock()
4593 .map_err(|_| SourceError::Unavailable {
4594 message: "this source's view of the board was left inconsistent by an earlier \
4595 failure; next: run the command again"
4596 .into(),
4597 })
4598 }
4599
4600 /// Bring this process's own view of the board up to an item it has just written.
4601 ///
4602 /// A created item goes to `created`, which is what completes a board read GitHub's own
4603 /// eventual consistency has left behind. An item that was already there is replaced
4604 /// where it sits, so a second write of it in the same command reads its real parent
4605 /// rather than the one it had before the first write.
4606 ///
4607 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4608 /// that wins: an item this same run created is held in `created` and not in the cached
4609 /// board, and `board` completes the cached board *from* `created`, so replacing only
4610 /// the cached copy of such an item replaces nothing and the read still reports the
4611 /// title it was created with. The search is the third, and it is the one an item the
4612 /// board's own projection is behind on sits in *alone* — which is exactly the item this
4613 /// source is least able to re-read, so leaving it out would put the stale title back on
4614 /// the only items the completion in [`Self::board`] exists for.
4615 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4616 self.resolved_cache()?.insert(item.id.clone(), item.clone());
4617 if created {
4618 self.created()?.push(item);
4619 return Ok(());
4620 }
4621 {
4622 let mut own = self.created()?;
4623 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4624 *held = item;
4625 return Ok(());
4626 }
4627 }
4628 {
4629 let mut own = self.updated()?;
4630 match own.iter_mut().find(|held| held.id == item.id) {
4631 Some(held) => *held = item.clone(),
4632 None => own.push(item.clone()),
4633 }
4634 }
4635 if let Some(board) = self.board_cache()?.as_mut()
4636 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4637 {
4638 *held = item.clone();
4639 }
4640 if let Some(found) = self.search_cache()?.as_mut()
4641 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4642 {
4643 *held = item.clone();
4644 }
4645 for found in self.narrowed_cache()?.values_mut() {
4646 if let Some(held) = found.iter_mut().find(|held| held.id == item.id) {
4647 *held = item.clone();
4648 }
4649 }
4650 Ok(())
4651 }
4652
4653 /// Forget one item this process has just deleted, from every half of its own view.
4654 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4655 self.resolved_cache()?.remove(id);
4656 self.created()?.retain(|own| own.id != *id);
4657 self.updated()?.retain(|own| own.id != *id);
4658 if let Some(board) = self.board_cache()?.as_mut() {
4659 board.items.retain(|item| item.id != *id);
4660 }
4661 if let Some(found) = self.search_cache()?.as_mut() {
4662 found.retain(|item| item.id != *id);
4663 }
4664 for found in self.narrowed_cache()?.values_mut() {
4665 found.retain(|item| item.id != *id);
4666 }
4667 Ok(())
4668 }
4669
4670 /// This process's own record of each narrowed answer, or the refusal a poisoned lock is.
4671 fn narrowed_cache(
4672 &self,
4673 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Vec<Resolved>>>, SourceError> {
4674 self.narrowed_cache
4675 .lock()
4676 .map_err(|_| SourceError::Unavailable {
4677 message: "this source's view of a narrowed read was left inconsistent by an \
4678 earlier failure; next: run the command again"
4679 .into(),
4680 })
4681 }
4682
4683 /// Every page of the board, read from GitHub.
4684 async fn read_board(&self) -> Result<Board, SourceError> {
4685 let mut after: Option<String> = None;
4686 let mut items = Vec::new();
4687 let mut board;
4688 loop {
4689 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
4690 for item in page
4691 .pointer("/items/nodes")
4692 .and_then(Value::as_array)
4693 .ok_or_else(|| SourceError::Malformed {
4694 message: "GitHub project items.nodes is not an array".into(),
4695 })?
4696 {
4697 if let Some(resolved) = self.resolve(item)? {
4698 items.push(resolved);
4699 }
4700 }
4701 let info = page
4702 .pointer("/items/pageInfo")
4703 .ok_or_else(|| SourceError::Malformed {
4704 message: "GitHub project items have no pageInfo".into(),
4705 })?;
4706 let has_next = required_bool(info, "hasNextPage")?;
4707 let next = has_next
4708 .then(|| required_str(info, "endCursor"))
4709 .transpose()?;
4710 board = page.clone();
4711 match next {
4712 Some(next) => {
4713 validate_cursor_progress(after.as_deref(), next)?;
4714 after = Some(next.to_owned());
4715 }
4716 None => break,
4717 }
4718 }
4719 Ok(Board {
4720 id: required_str(&board, "id")?.to_owned(),
4721 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4722 items,
4723 })
4724 }
4725
4726 /// The existing items this source has written, for completing a narrowed read that is
4727 /// behind; see [`Self::updated`](GitHubProjectsSource::updated).
4728 fn updated(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4729 self.updated.lock().map_err(|_| SourceError::Unavailable {
4730 message: "this source's record of what it wrote in this run was left inconsistent \
4731 by an earlier failure; next: run the command again"
4732 .into(),
4733 })
4734 }
4735
4736 /// The items this source has created, for completing a board read that is behind.
4737 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4738 self.created.lock().map_err(|_| SourceError::Unavailable {
4739 message: "this source's record of what it created in this run was left \
4740 inconsistent by an earlier failure; next: run the command again"
4741 .into(),
4742 })
4743 }
4744
4745 /// One board item as this source reports it, or `None` for content it ignores.
4746 ///
4747 /// A pull request is neither a project nor a task — it is somebody's change, not a
4748 /// unit of plan — and an item whose content the token cannot see has nothing to
4749 /// report at all.
4750 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
4751 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
4752 message: "GitHub project item is missing content".into(),
4753 })?;
4754 if content.is_null() {
4755 return Ok(None);
4756 }
4757 let content_kind = match required_str(content, "__typename")? {
4758 "Issue" => ContentKind::Issue,
4759 "DraftIssue" => ContentKind::DraftIssue,
4760 _ => return Ok(None),
4761 };
4762 let field_values = item
4763 .get("fieldValues")
4764 .ok_or_else(|| SourceError::Malformed {
4765 message: "GitHub project item is missing fieldValues".into(),
4766 })?;
4767 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
4768 let nodes = field_values
4769 .get("nodes")
4770 .and_then(Value::as_array)
4771 .ok_or_else(|| SourceError::Malformed {
4772 message: "GitHub project item fieldValues.nodes is not an array".into(),
4773 })?;
4774 if let Some(labels) = content.get("labels") {
4775 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
4776 }
4777 let raw_body = optional_str(content, "body")?.map(str::to_owned);
4778 let (body, slot) = metadata_body(raw_body.clone())?;
4779 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
4780 .map(|id| NativeId(id.to_owned()));
4781 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
4782 // to read one from; it is a task, and never a project.
4783 let sub_issues = match content_kind {
4784 ContentKind::Issue => sub_issue_total(content)?,
4785 ContentKind::DraftIssue => 0,
4786 };
4787 let content_id = required_str(content, "id")?;
4788 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
4789 message: format!("GitHub issue {content_id}: {message}"),
4790 })?;
4791 let raw_title = required_str(content, "title")?;
4792 // The design prefix is read *first*, before either of the two rules that separate
4793 // a project from a task. A document is not work whatever sub-issues it has and
4794 // whatever marker it carries, and reading the prefix later would make a design
4795 // issue with none of either an empty project.
4796 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
4797 BoardKind::Document
4798 } else if parent.is_some() {
4799 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
4800 // under a project is that project's task even when it has sub-issues of its
4801 // own.
4802 BoardKind::Work(ItemKind::Task)
4803 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
4804 BoardKind::Work(ItemKind::Project)
4805 } else {
4806 BoardKind::Work(ItemKind::Task)
4807 };
4808 // The title a person wrote, which for a document is the one without the prefix —
4809 // the same way `content` above is the body without this source's metadata slot.
4810 let title = match kind {
4811 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
4812 BoardKind::Work(_) => raw_title.to_owned(),
4813 };
4814 let own_repository = content
4815 .pointer("/repository/nameWithOwner")
4816 .and_then(Value::as_str)
4817 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
4818 .transpose()
4819 .map_err(|message| SourceError::Malformed { message })?;
4820 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
4821 Repository::from_metadata(&slot)
4822 .map_err(|message| SourceError::Malformed { message })?
4823 } else {
4824 own_repository.clone().into_iter().collect()
4825 };
4826 let id = NativeId(content_id.to_owned());
4827 // Read only for a task, because only a task has either list: a project or a
4828 // document holding one of these keys holds nothing this source reports, and the
4829 // keys are left out of its caller-visible metadata all the same.
4830 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
4831 let listed = |key: &str| {
4832 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
4833 .map_err(|message| SourceError::Malformed { message })
4834 };
4835 (
4836 listed(TaskRef::DELIVERS_KEY)?,
4837 listed(TaskRef::DELIVERED_BY_KEY)?,
4838 )
4839 } else {
4840 (Vec::new(), Vec::new())
4841 };
4842 let (option, closed, reason) = Self::status_parts(nodes, content)?;
4843 let priority = self.held_priority(nodes)?;
4844 let resolved = Resolved {
4845 item_id: required_str(item, "id")?.to_owned(),
4846 id,
4847 content_kind,
4848 kind,
4849 title,
4850 body: body.filter(|value| !value.is_empty()),
4851 raw_body,
4852 status: self.statuses.status(option, closed, reason),
4853 option: option.map(str::to_owned),
4854 priority,
4855 closed,
4856 delivers,
4857 delivered_by,
4858 labels: labels(content)?,
4859 parent,
4860 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
4861 number: match content_kind {
4862 ContentKind::Issue => Some(issue_number(content)?),
4863 // A draft is filed in no repository, so nothing ever numbered it:
4864 // `DraftIssue` declares no `number` at all, exactly as it declares no
4865 // `subIssuesSummary` the branch above reads.
4866 ContentKind::DraftIssue => None,
4867 },
4868 url: optional_str(content, "url")?.map(str::to_owned),
4869 created_at: optional_time(content, "createdAt")?,
4870 updated_at: optional_time(content, "updatedAt")?,
4871 own_repository,
4872 repositories,
4873 slot,
4874 // Present when the item was reached through its own issue, whose board entry
4875 // names the board; a read of the board's own items has the board already. An
4876 // empty id names nothing a field write could address, so it is read as absent and
4877 // the write goes back to reading the board.
4878 board_id: item
4879 .pointer("/project/id")
4880 .and_then(Value::as_str)
4881 .filter(|id| !id.is_empty())
4882 .map(str::to_owned),
4883 fields: field_definitions(nodes),
4884 };
4885 self.resolved_cache()?
4886 .insert(resolved.id.clone(), resolved.clone());
4887 Ok(Some(resolved))
4888 }
4889
4890 /// What one board item's `Priority` field says, through this instance's mapping.
4891 ///
4892 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
4893 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
4894 /// option the mapping does not name is kept as itself — never read as a level or as
4895 /// `none` — for a read of the task to report by name.
4896 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
4897 let Some(mapping) = &self.priorities else {
4898 return Ok(HeldPriority::Read(Priority::None));
4899 };
4900 // A value of the field that names no option — a text field someone called `Priority` —
4901 // is malformed rather than `none`: reading it as no priority would let the next copy
4902 // clear one a person set.
4903 let Some(option) = field_values
4904 .iter()
4905 .find(|value| {
4906 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
4907 })
4908 .map(|value| required_str(value, "name"))
4909 .transpose()?
4910 else {
4911 return Ok(HeldPriority::Read(Priority::None));
4912 };
4913 Ok(mapping.priority_of(option).map_or_else(
4914 || HeldPriority::Unmapped(option.to_owned()),
4915 HeldPriority::Read,
4916 ))
4917 }
4918
4919 /// What one board item's status is read from: its `Status` option, whether its issue
4920 /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
4921 /// three into the status it reports.
4922 fn status_parts<'a>(
4923 field_values: &'a [Value],
4924 content: &'a Value,
4925 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
4926 let option = field_values
4927 .iter()
4928 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
4929 .map(|value| required_str(value, "name"))
4930 .transpose()?;
4931 let closed = optional_str(content, "state")? == Some("CLOSED");
4932 Ok((option, closed, optional_str(content, "stateReason")?))
4933 }
4934
4935 /// The board Status option this write selects, or the refusal that says why not.
4936 ///
4937 /// The mapped option is required for both open and terminal targets. A terminal write
4938 /// validates it before changing either representation, so it can never fall back to
4939 /// closing an issue whose board cannot display the matching status.
4940 ///
4941 /// Answers the field's id, the option's id, and the option's name as the board spells
4942 /// it — which is the name a read of the item reports once it sits there.
4943 fn column_for(
4944 &self,
4945 fields: &Value,
4946 status: &Status,
4947 target: &StatusTarget,
4948 ) -> Result<Option<(String, String, String)>, SourceError> {
4949 let wanted = match target {
4950 StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
4951 StatusTarget::Disabled => return Ok(None),
4952 };
4953 let missing = |detail: &str| SourceError::Refused {
4954 message: format!(
4955 "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",
4956 category_name(status.category),
4957 self.name,
4958 category_name(status.category)
4959 ),
4960 };
4961 let Some(field) = Board::field(fields, "Status")? else {
4962 return Err(missing("this board has no Status field"));
4963 };
4964 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4965 return Err(missing(
4966 "this board's Status field is not a single-select field",
4967 ));
4968 }
4969 let option = field
4970 .get("options")
4971 .and_then(Value::as_array)
4972 .and_then(|options| {
4973 options.iter().find(|option| {
4974 option
4975 .get("name")
4976 .and_then(Value::as_str)
4977 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4978 })
4979 });
4980 match option {
4981 None => Err(missing("this board does not have it")),
4982 Some(option) => Ok(Some((
4983 required_str(field, "id")?.to_owned(),
4984 required_str(option, "id")?.to_owned(),
4985 required_str(option, "name")?.to_owned(),
4986 ))),
4987 }
4988 }
4989
4990 /// The refusal a status that closes an issue is answered with over a board draft.
4991 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
4992 SourceError::Refused {
4993 message: format!(
4994 "status {} of source {} closes the item's issue, and GitHub draft items have \
4995 no open or closed state",
4996 category_name(category),
4997 self.name
4998 ),
4999 }
5000 }
5001
5002 /// What a status write to one item needs of the board: the board's id and the
5003 /// definition of its `Status` field, read off the item when the item says both.
5004 ///
5005 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
5006 /// and its `Status` value carries that field's definition, options and all. An item that
5007 /// does not say — no board id, or no `Status` value to read the field off — takes them
5008 /// from [`Self::board_fields`], which reads no item.
5009 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
5010 if item.defines("Status")
5011 && let Some(board_id) = item.named_board()
5012 {
5013 return Ok(BoardFields {
5014 id: board_id,
5015 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5016 });
5017 }
5018 self.board_fields().await
5019 }
5020
5021 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
5022 async fn set_status(
5023 &self,
5024 id: &NativeId,
5025 category: StatusCategory,
5026 ) -> Result<Option<Status>, SourceError> {
5027 // Refused before anything is read, in the words a write of the same status is.
5028 let target = self.resolved_target(category)?;
5029 let Some(mut item) = self
5030 .bound_item(id)
5031 .await?
5032 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5033 else {
5034 return Ok(None);
5035 };
5036 let board = self.status_board(&item).await?;
5037 let wanted = Status {
5038 category,
5039 name: category_name(category).to_owned(),
5040 };
5041 let (field, option, name) = self
5042 .column_for(&board.fields, &wanted, &target)?
5043 .ok_or_else(|| SourceError::Malformed {
5044 message: format!(
5045 "status {} of source {} names no board Status option",
5046 category_name(category),
5047 self.name
5048 ),
5049 })?;
5050 if item.status.category == category && item.option.as_deref() == Some(&name) {
5051 return Ok(Some(item.status));
5052 }
5053 match &target {
5054 StatusTarget::Terminal(_, reason) => {
5055 if item.content_kind == ContentKind::DraftIssue {
5056 return Err(self.closes_a_draft(category));
5057 }
5058 self.set_item_field(
5059 board.id.as_str(),
5060 &item.item_id,
5061 &field,
5062 json!({"singleSelectOptionId": option}),
5063 )
5064 .await?;
5065 self.update_content(
5066 ContentKind::Issue,
5067 &item.id,
5068 json!({"stateInput": state_input(Some(&target))}),
5069 )
5070 .await?;
5071 item.closed = true;
5072 item.status = self
5073 .statuses
5074 .status(Some(&name), true, Some(reason.reason()));
5075 item.option = Some(name);
5076 }
5077 StatusTarget::Column(_) => {
5078 // An option is what an open item's status is, so a closed issue is reopened
5079 // first — sitting closed in the column, it would read back as closed. A draft has
5080 // no state to reopen.
5081 if item.content_kind == ContentKind::Issue && item.closed {
5082 self.update_content(
5083 ContentKind::Issue,
5084 &item.id,
5085 json!({"stateInput": state_input(Some(&target))}),
5086 )
5087 .await?;
5088 item.closed = false;
5089 }
5090 self.set_item_field(
5091 board.id.as_str(),
5092 &item.item_id,
5093 &field,
5094 json!({"singleSelectOptionId": option}),
5095 )
5096 .await?;
5097 item.status = self.statuses.status(Some(&name), false, None);
5098 item.option = Some(name);
5099 }
5100 StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
5101 }
5102 let status = item.status.clone();
5103 self.remember_written(item, false)?;
5104 Ok(Some(status))
5105 }
5106
5107 /// Replace one task's `delivered_by` and nothing else; see
5108 /// [`TaskSource::set_delivered_by`].
5109 ///
5110 /// One update of the body, which differs from the body GitHub holds only inside the
5111 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
5112 async fn replace_delivered_by(
5113 &self,
5114 id: &NativeId,
5115 delivered_by: &[TaskRef],
5116 ) -> Result<Option<()>, SourceError> {
5117 let entries = TaskRef::listed(
5118 TaskRef::DELIVERED_BY_KEY,
5119 id,
5120 Some(&self.name),
5121 delivered_by.to_vec(),
5122 )
5123 .map_err(|message| SourceError::Refused { message })?;
5124 let Some(mut item) = self
5125 .bound_item(id)
5126 .await?
5127 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5128 else {
5129 return Ok(None);
5130 };
5131 let mut slot = item.slot.clone();
5132 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
5133 self.write_slot(&mut item, &slot).await?;
5134 item.delivered_by = entries;
5135 self.remember_written(item, false)?;
5136 Ok(Some(()))
5137 }
5138
5139 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
5140 /// see [`TaskSource::set_task_metadata`].
5141 ///
5142 /// `None` when this board holds no item by that id, or holds one of another kind. The
5143 /// answer is the item as this source now reads it, so what a caller is told the key
5144 /// holds is what the slot holds.
5145 ///
5146 /// A key already holding the value is answered without a write, compared as JSON rather
5147 /// than as the body's bytes: a slot a person spelled with other whitespace would
5148 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
5149 async fn set_slot_key(
5150 &self,
5151 id: &NativeId,
5152 kind: BoardKind,
5153 key: &MetadataKey,
5154 value: &Value,
5155 ) -> Result<Option<Resolved>, SourceError> {
5156 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5157 return Ok(None);
5158 };
5159 if item.slot.get(key.as_str()) == Some(value) {
5160 return Ok(Some(item));
5161 }
5162 let mut slot = item.slot.clone();
5163 slot.insert(key.as_str().to_owned(), value.clone());
5164 self.write_slot(&mut item, &slot).await?;
5165 self.remember_written(item.clone(), false)?;
5166 Ok(Some(item))
5167 }
5168
5169 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
5170 /// `item` up to what that write left.
5171 ///
5172 /// The body sent differs from the body GitHub holds only inside the slot — see
5173 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
5174 /// the mutation the item's content takes, so a board draft's body is written with
5175 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
5176 async fn write_slot(
5177 &self,
5178 item: &mut Resolved,
5179 slot: &BTreeMap<String, Value>,
5180 ) -> Result<(), SourceError> {
5181 let held = item.raw_body.clone().unwrap_or_default();
5182 let body = with_slot(&held, slot)?;
5183 if body != held {
5184 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5185 .await?;
5186 }
5187 let (visible, slot) = metadata_body(Some(body.clone()))?;
5188 item.body = visible.filter(|value| !value.is_empty());
5189 item.raw_body = Some(body);
5190 item.slot = slot;
5191 Ok(())
5192 }
5193
5194 /// This instance's target for a category, refusing one it has disabled.
5195 ///
5196 /// Nothing here mutates the board's option set to make room for a status. GitHub
5197 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
5198 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
5199 /// field and every item's status.
5200 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
5201 let target = self.statuses.target(category).clone();
5202 if target != StatusTarget::Disabled {
5203 return Ok(target);
5204 }
5205 Err(SourceError::Refused {
5206 message: if category == StatusCategory::Draft {
5207 format!(
5208 "status draft is disabled for source {}: draft is incompatible with this \
5209 integration because GitHub draft issues cannot have sub-issues, and this \
5210 source stores a project's tasks as its issue's sub-issues",
5211 self.name
5212 )
5213 } else if category == StatusCategory::Unknown {
5214 format!(
5215 "status {} is disabled for source {}; set status_mapping.{} of this source \
5216 to one board Status option name; every word classified unknown is written \
5217 to that one option",
5218 category_name(category),
5219 self.name,
5220 category_name(category)
5221 )
5222 } else {
5223 format!(
5224 "status {} is disabled for source {}; set status_mapping.{} of this source \
5225 to a board Status option name",
5226 category_name(category),
5227 self.name,
5228 category_name(category)
5229 )
5230 },
5231 })
5232 }
5233
5234 /// What writing `priority` does to one item's `Priority` field on this board, or the
5235 /// refusal naming what the board lacks.
5236 ///
5237 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
5238 /// none already, or of an item not created yet. Every other priority selects the option
5239 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
5240 /// without that option, is refused rather than given one: reads and writes never create
5241 /// a field or an option.
5242 fn priority_write(
5243 &self,
5244 fields: &Value,
5245 existing: Option<&Resolved>,
5246 priority: Priority,
5247 ) -> Result<Option<PriorityWrite>, SourceError> {
5248 let Some(mapping) = &self.priorities else {
5249 return Err(self.holds_no_priority());
5250 };
5251 let Some(wanted) = mapping.option(priority) else {
5252 if !existing.is_some_and(Resolved::holds_priority) {
5253 return Ok(None);
5254 }
5255 let field =
5256 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
5257 message: format!(
5258 "an item holding a {PRIORITY_FIELD} value was read without that field"
5259 ),
5260 })?;
5261 return Ok(Some(PriorityWrite::Clear {
5262 field: required_str(field, "id")?.to_owned(),
5263 }));
5264 };
5265 let missing = |detail: &str| SourceError::Refused {
5266 message: format!(
5267 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
5268 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
5269 it, or point priority_mapping.{priority} of this source at an option the board \
5270 has",
5271 self.name, self.name
5272 ),
5273 };
5274 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
5275 return Err(missing(&format!(
5276 "this board has no {PRIORITY_FIELD} field"
5277 )));
5278 };
5279 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
5280 return Err(missing(&format!(
5281 "this board's {PRIORITY_FIELD} field is not a single-select field"
5282 )));
5283 }
5284 // An options list that is absent or not a list is an answer this source cannot read,
5285 // not a board lacking the option: `sources fields --apply` is no remedy for it.
5286 let option = field
5287 .get("options")
5288 .and_then(Value::as_array)
5289 .ok_or_else(|| SourceError::Malformed {
5290 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
5291 })?
5292 .iter()
5293 .find(|option| {
5294 option
5295 .get("name")
5296 .and_then(Value::as_str)
5297 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
5298 })
5299 .ok_or_else(|| missing("this board does not have it"))?;
5300 Ok(Some(PriorityWrite::Select {
5301 field: required_str(field, "id")?.to_owned(),
5302 option: required_str(option, "id")?.to_owned(),
5303 }))
5304 }
5305
5306 /// Apply one priority write to one board item.
5307 async fn write_priority(
5308 &self,
5309 board_id: &str,
5310 item_id: &str,
5311 write: &PriorityWrite,
5312 ) -> Result<(), SourceError> {
5313 match write {
5314 PriorityWrite::Select { field, option } => {
5315 self.set_item_field(
5316 board_id,
5317 item_id,
5318 field,
5319 json!({"singleSelectOptionId": option}),
5320 )
5321 .await
5322 }
5323 PriorityWrite::Clear { field } => {
5324 let data = self
5325 .graphql(
5326 graphql::CLEAR_FIELD,
5327 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field},
5328 "readPriority":false,"priorityName":PRIORITY_FIELD}),
5329 )
5330 .await?;
5331 let returned = data
5332 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
5333 .ok_or_else(|| SourceError::Malformed {
5334 message: "GitHub field clear returned no project item".into(),
5335 })?;
5336 if required_str(returned, "id")? != item_id {
5337 return Err(SourceError::Malformed {
5338 message: "GitHub field clear returned the wrong project item".into(),
5339 });
5340 }
5341 Ok(())
5342 }
5343 }
5344 }
5345
5346 /// The refusal a priority is answered with by an instance configured with no
5347 /// `priority_mapping`, which holds none.
5348 fn holds_no_priority(&self) -> SourceError {
5349 SourceError::Refused {
5350 message: format!(
5351 "source {} holds no task priority: its configuration sets no priority_mapping; \
5352 next: set priority_mapping on this source, then run `onetaskgraph sources \
5353 fields {} --apply` to set its board up",
5354 self.name, self.name
5355 ),
5356 }
5357 }
5358
5359 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
5360 ///
5361 /// One field write — a select, or a clear for `none` — and no title, body, label, state
5362 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
5363 async fn set_priority(
5364 &self,
5365 id: &NativeId,
5366 priority: Priority,
5367 ) -> Result<Option<Priority>, SourceError> {
5368 if self.priorities.is_none() {
5369 return Err(self.holds_no_priority());
5370 }
5371 let Some(mut item) = self
5372 .bound_item(id)
5373 .await?
5374 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5375 else {
5376 return Ok(None);
5377 };
5378 if priority == Priority::None && !item.holds_priority() {
5379 return Ok(Some(priority));
5380 }
5381 // The item's own read carries the field's definition whenever it holds a value of
5382 // it, which a clear always does; a select onto an item holding none reads the board.
5383 let board = match item.named_board() {
5384 Some(id) if item.defines(PRIORITY_FIELD) => BoardFields {
5385 id,
5386 fields: json!({"nodes": item.fields.clone(), "pageInfo": {"hasNextPage": false}}),
5387 },
5388 _ => self.board_fields().await?,
5389 };
5390 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
5391 return Ok(Some(priority));
5392 };
5393 let (document, root, input) = match write {
5394 PriorityWrite::Select { field, option } => (
5395 graphql::UPDATE_FIELD,
5396 "updateProjectV2ItemFieldValue",
5397 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field,"value":{"singleSelectOptionId":option}}),
5398 ),
5399 PriorityWrite::Clear { field } => (
5400 graphql::CLEAR_FIELD,
5401 "clearProjectV2ItemFieldValue",
5402 json!({"projectId":board.id.as_str(),"itemId":item.item_id,"fieldId":field}),
5403 ),
5404 };
5405 let data = self
5406 .graphql(
5407 document,
5408 json!({"input":input,"readPriority":true,"priorityName":PRIORITY_FIELD}),
5409 )
5410 .await?;
5411 let returned = data
5412 .get(root)
5413 .and_then(|value| value.get("projectV2Item"))
5414 .ok_or_else(|| SourceError::Malformed {
5415 message: "GitHub priority write returned no project item".into(),
5416 })?;
5417 if required_str(returned, "id")? != item.item_id {
5418 return Err(SourceError::Malformed {
5419 message: "GitHub priority write returned the wrong project item".into(),
5420 });
5421 }
5422 let value = returned
5423 .get("fieldValueByName")
5424 .ok_or_else(|| SourceError::Malformed {
5425 message: "GitHub priority write returned no priority read-back".into(),
5426 })?;
5427 if !value.is_null()
5428 && value.pointer("/field/name").and_then(Value::as_str) != Some(PRIORITY_FIELD)
5429 {
5430 return Err(SourceError::Malformed {
5431 message: "GitHub priority read-back is not a Priority field value".into(),
5432 });
5433 }
5434 let values = if value.is_null() {
5435 Vec::new()
5436 } else {
5437 vec![value.clone()]
5438 };
5439 item.priority = self.held_priority(&values)?;
5440 let answer = item.task()?.priority;
5441 self.remember_written(item, false)?;
5442 Ok(Some(answer))
5443 }
5444
5445 /// Replace one task's visible body and nothing else; see
5446 /// [`TaskSource::set_task_content`].
5447 ///
5448 /// One update of the body, which differs from the body GitHub holds only outside the
5449 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
5450 /// this source keeps there reads back as it was. A body that would not change is not
5451 /// sent at all.
5452 async fn replace_content(
5453 &self,
5454 id: &NativeId,
5455 content: &str,
5456 ) -> Result<Option<()>, SourceError> {
5457 let Some(mut item) = self
5458 .bound_item(id)
5459 .await?
5460 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5461 else {
5462 return Ok(None);
5463 };
5464 let held = item.raw_body.clone().unwrap_or_default();
5465 let body = with_content(&held, content)?;
5466 // Checked before anything is sent: content ending in what this source reads as its own
5467 // metadata slot would read back as metadata rather than as the content it was.
5468 let (visible, slot) = metadata_body(Some(body.clone()))?;
5469 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
5470 return Err(SourceError::Refused {
5471 message: format!(
5472 "this content ends in what source {} reads as its own metadata slot \
5473 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5474 as content; next: remove that trailing block from the content",
5475 self.name
5476 ),
5477 });
5478 }
5479 if body != held {
5480 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5481 .await?;
5482 }
5483 item.body = visible.filter(|value| !value.is_empty());
5484 item.raw_body = Some(body);
5485 item.slot = slot;
5486 self.remember_written(item, false)?;
5487 Ok(Some(()))
5488 }
5489
5490 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
5491 ///
5492 /// One read of the item, and then only what differs from it: at most one `updateIssue`
5493 /// carrying the title, the body — visible content and metadata slot together — and a
5494 /// state change, at most one `Status` option write and one `Priority` field write, and the
5495 /// `blockedBy` additions and removals the named edges differ by. A terminal status selects
5496 /// its option and then closes, as a whole write does; an open one reopens and then selects
5497 /// its option, as [`Self::set_status`] does. The origin field is never written: an update
5498 /// is of an item that already exists, whose origin is what it is.
5499 ///
5500 /// The task answered is the item as those writes left it, built from the read and what was
5501 /// sent rather than read again — the same record a later read in this run answers from.
5502 async fn targeted_update(
5503 &self,
5504 id: &NativeId,
5505 update: &TaskUpdate,
5506 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
5507 // Everything this source can refuse without reading the item is refused first, in the
5508 // words a whole write of the same fields is refused with.
5509 update.consistent()?;
5510 if update
5511 .title
5512 .as_deref()
5513 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
5514 {
5515 return Err(SourceError::Refused {
5516 message: format!(
5517 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5518 spells a document, so it would read back as one rather than as a task; \
5519 retitle it",
5520 self.name
5521 ),
5522 });
5523 }
5524 if let Some(delivers) = &update.delivers {
5525 TaskRef::listed(
5526 TaskRef::DELIVERS_KEY,
5527 id,
5528 Some(&self.name),
5529 delivers.clone(),
5530 )
5531 .map_err(|message| SourceError::Refused { message })?;
5532 }
5533 if self.priorities.is_none()
5534 && update
5535 .priority
5536 .is_some_and(|priority| priority != Priority::None)
5537 {
5538 return Err(self.holds_no_priority());
5539 }
5540 let target = update
5541 .status
5542 .as_ref()
5543 .map(|status| self.resolved_target(status.category))
5544 .transpose()?;
5545 let Some(mut item) = self
5546 .bound_item(id)
5547 .await?
5548 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5549 else {
5550 return Ok(None);
5551 };
5552 let before = item.task()?;
5553
5554 let mut status_move = None;
5555 if let (Some(status), Some(target)) = (&update.status, target) {
5556 let board = self.status_board(&item).await?;
5557 let (field, option, name) = self
5558 .column_for(&board.fields, status, &target)?
5559 .ok_or_else(|| SourceError::Malformed {
5560 message: format!(
5561 "status {} of source {} names no board Status option",
5562 category_name(status.category),
5563 self.name
5564 ),
5565 })?;
5566 let terminal = matches!(target, StatusTarget::Terminal(_, _));
5567 if terminal && item.content_kind == ContentKind::DraftIssue {
5568 return Err(self.closes_a_draft(status.category));
5569 }
5570 let landed = match &target {
5571 StatusTarget::Terminal(_, reason) => {
5572 self.statuses
5573 .status(Some(&name), true, Some(reason.reason()))
5574 }
5575 _ => self.statuses.status(Some(&name), false, None),
5576 };
5577 let option_moves = item
5578 .option
5579 .as_deref()
5580 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
5581 let state_moves = item.content_kind == ContentKind::Issue
5582 && (item.closed != terminal || (terminal && item.status != landed));
5583 if let Some(moves) = Moves::of(option_moves, state_moves) {
5584 status_move = Some(StatusMove {
5585 board: board.id,
5586 field,
5587 option,
5588 name,
5589 target,
5590 landed,
5591 moves,
5592 });
5593 }
5594 }
5595
5596 let mut priority_move = None;
5597 if let Some(priority) = update.priority
5598 && self.priorities.is_some()
5599 && item.priority != HeldPriority::Read(priority)
5600 {
5601 let board = match item.named_board() {
5602 Some(board) if item.defines(PRIORITY_FIELD) => BoardFields {
5603 id: board,
5604 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
5605 },
5606 _ => self.board_fields().await?,
5607 };
5608 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
5609 priority_move = Some((board.id, write, priority));
5610 }
5611 }
5612
5613 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
5614 // recorded in the slot, and the slot travels in the one body update below.
5615 let edges = match &update.depends_on {
5616 Some(edges) => Some(
5617 self.partition_edges(BoardKind::Work(ItemKind::Task), item.content_kind, edges)
5618 .await?,
5619 ),
5620 None => None,
5621 };
5622
5623 let mut slot = item.slot.clone();
5624 for (key, value) in &update.metadata_set {
5625 slot.insert(key.as_str().to_owned(), value.clone());
5626 }
5627 for key in &update.metadata_remove {
5628 slot.remove(key.as_str());
5629 }
5630 if let Some(delivers) = &update.delivers {
5631 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
5632 }
5633 if let Some((_, recorded)) = &edges {
5634 record_edges(&mut slot, recorded);
5635 }
5636 let held = item.raw_body.clone().unwrap_or_default();
5637 let content = match &update.content {
5638 Some(content) => with_content(&held, content)?,
5639 None => held.clone(),
5640 };
5641 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
5642 // the body's bytes, as a metadata write compares it: a slot a person spelled with
5643 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
5644 let body = if slot == item.slot {
5645 content
5646 } else {
5647 with_slot(&content, &slot)?
5648 };
5649 // Checked before anything is sent, as a content write checks it: content ending in
5650 // what this source reads as its own slot would read back as metadata.
5651 let (visible, read) = metadata_body(Some(body.clone()))?;
5652 let wanted = update.content.as_deref().or(item.body.as_deref());
5653 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
5654 return Err(SourceError::Refused {
5655 message: format!(
5656 "this content ends in what source {} reads as its own metadata slot \
5657 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5658 as content; next: remove that trailing block from the content",
5659 self.name
5660 ),
5661 });
5662 }
5663 let recorded_moves =
5664 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
5665
5666 // One `updateIssue` carries all three, because every mutation spends the secondary
5667 // limiter and the title, body and state are one mutation's inputs.
5668 let mut fields = serde_json::Map::new();
5669 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
5670 fields.insert("title".to_owned(), json!(title));
5671 }
5672 if body != held {
5673 fields.insert("body".to_owned(), json!(body));
5674 }
5675 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
5676 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
5677 }
5678 let terminal = status_move
5679 .as_ref()
5680 .is_some_and(|moving| matches!(moving.target, StatusTarget::Terminal(_, _)));
5681 // A terminal option is selected before the issue closes, so a close never lands on an
5682 // item whose board cannot show it; an open one after the issue reopens.
5683 if terminal {
5684 self.select_option(&item, status_move.as_ref()).await?;
5685 }
5686 if !fields.is_empty() {
5687 self.update_content(item.content_kind, &item.id, Value::Object(fields))
5688 .await?;
5689 }
5690 if !terminal {
5691 self.select_option(&item, status_move.as_ref()).await?;
5692 }
5693 if let Some((board, write, _)) = &priority_move {
5694 self.write_priority(board.as_str(), &item.item_id, write)
5695 .await?;
5696 }
5697 let mut blocked_by_moved = false;
5698 if let Some((native, _)) = &edges
5699 && item.content_kind == ContentKind::Issue
5700 {
5701 blocked_by_moved = self
5702 .reconcile_blocked_by(&item.id, native, Issue::Existing)
5703 .await?;
5704 }
5705
5706 if let Some(title) = &update.title {
5707 item.title.clone_from(title);
5708 }
5709 item.body = visible.filter(|value| !value.is_empty());
5710 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
5711 item.slot = slot;
5712 if let Some(delivers) = &update.delivers {
5713 item.delivers.clone_from(delivers);
5714 }
5715 if let Some(moving) = status_move {
5716 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
5717 && item.content_kind == ContentKind::Issue;
5718 item.status = moving.landed;
5719 item.option = Some(moving.name);
5720 }
5721 if let Some((_, _, priority)) = priority_move {
5722 item.priority = HeldPriority::Read(priority);
5723 }
5724 let task = item.task()?;
5725 let mut written = update.changed(&before, &task);
5726 if blocked_by_moved || recorded_moves {
5727 written.insert(UpdatedField::DependsOn);
5728 }
5729 self.remember_written(item, false)?;
5730 Ok(Some(TaskUpdateOutcome {
5731 task,
5732 written,
5733 delivers_before: before.delivers,
5734 }))
5735 }
5736
5737 /// Select the `Status` option one targeted update moves an item to, when it moves it.
5738 async fn select_option(
5739 &self,
5740 item: &Resolved,
5741 moving: Option<&StatusMove>,
5742 ) -> Result<(), SourceError> {
5743 let Some(moving) = moving.filter(|moving| moving.moves.option()) else {
5744 return Ok(());
5745 };
5746 self.set_item_field(
5747 moving.board.as_str(),
5748 &item.item_id,
5749 &moving.field,
5750 json!({"singleSelectOptionId": moving.option}),
5751 )
5752 .await
5753 }
5754
5755 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
5756 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
5757 ///
5758 /// One update of the body: the content outside the slot, and inside it that one entry,
5759 /// every other entry kept as it was. This source keeps no template answers — an issue has
5760 /// no room beside itself that is not its body, and answers written there would duplicate
5761 /// what the content already says and count against GitHub's body limit — so `answers`
5762 /// reaches nothing here. A body that would not change is not sent at all.
5763 async fn replace_rendering(
5764 &self,
5765 id: &NativeId,
5766 kind: BoardKind,
5767 content: &str,
5768 provenance: &Value,
5769 ) -> Result<Option<()>, SourceError> {
5770 let Some(mut item) = self.bound_item(id).await?.filter(|item| item.kind == kind) else {
5771 return Ok(None);
5772 };
5773 let held = item.raw_body.clone().unwrap_or_default();
5774 let mut slot = item.slot.clone();
5775 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
5776 let body = with_slot(&with_content(&held, content)?, &slot)?;
5777 // Checked before anything is sent, as a content write checks it.
5778 let (visible, read) = metadata_body(Some(body.clone()))?;
5779 if visible.as_deref().unwrap_or_default() != content || read != slot {
5780 return Err(SourceError::Refused {
5781 message: format!(
5782 "this content ends in what source {} reads as its own metadata slot \
5783 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5784 as content; next: remove that trailing block from the template",
5785 self.name
5786 ),
5787 });
5788 }
5789 if body != held {
5790 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5791 .await?;
5792 }
5793 item.body = visible.filter(|value| !value.is_empty());
5794 item.raw_body = Some(body);
5795 item.slot = read;
5796 self.remember_written(item, false)?;
5797 Ok(Some(()))
5798 }
5799
5800 async fn set_item_field(
5801 &self,
5802 board_id: &str,
5803 item_id: &str,
5804 field_id: &str,
5805 value: Value,
5806 ) -> Result<(), SourceError> {
5807 let data = self
5808 .graphql(
5809 graphql::UPDATE_FIELD,
5810 json!({"input":{
5811 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
5812 },"readPriority":false,"priorityName":PRIORITY_FIELD}),
5813 )
5814 .await?;
5815 let returned = data
5816 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
5817 .ok_or_else(|| SourceError::Malformed {
5818 message: "GitHub field update returned no project item".into(),
5819 })?;
5820 if required_str(returned, "id")? != item_id {
5821 return Err(SourceError::Malformed {
5822 message: "GitHub field update returned the wrong project item".into(),
5823 });
5824 }
5825 Ok(())
5826 }
5827
5828 /// GitHub accepts one value per field mutation; aliases combine those mutations in
5829 /// one request. Every returned item id is checked, including optional aliases.
5830 async fn set_item_fields(
5831 &self,
5832 board: &str,
5833 item: &str,
5834 fields: &[(String, Value)],
5835 clear: Option<&str>,
5836 ) -> Result<(), SourceError> {
5837 if fields.len() <= 1 && clear.is_none() {
5838 if let Some((field, value)) = fields.first() {
5839 self.set_item_field(board, item, field, value.clone())
5840 .await?;
5841 }
5842 return Ok(());
5843 }
5844 if fields.is_empty() {
5845 if let Some(field) = clear {
5846 self.write_priority(
5847 board,
5848 item,
5849 &PriorityWrite::Clear {
5850 field: field.to_owned(),
5851 },
5852 )
5853 .await?;
5854 }
5855 return Ok(());
5856 }
5857 let input = |index: usize| {
5858 let (field, value) = fields.get(index).unwrap_or(&fields[0]);
5859 json!({"projectId":board,"itemId":item,"fieldId":field,"value":value})
5860 };
5861 let data = self.graphql(graphql::UPDATE_FIELDS, json!({
5862 "input":input(0),"second":input(1),"third":input(2),
5863 "writeSecond":fields.len()>1,"writeThird":fields.len()>2,"writeClear":clear.is_some(),
5864 "clear":{"projectId":board,"itemId":item,"fieldId":clear.unwrap_or(&fields[0].0)}
5865 })).await?;
5866 for alias in [
5867 Some("updateProjectV2ItemFieldValue"),
5868 (fields.len() > 1).then_some("second"),
5869 (fields.len() > 2).then_some("third"),
5870 clear.map(|_| "cleared"),
5871 ]
5872 .into_iter()
5873 .flatten()
5874 {
5875 let returned = data
5876 .get(alias)
5877 .and_then(|value| value.get("projectV2Item"))
5878 .ok_or_else(|| SourceError::Malformed {
5879 message: format!("GitHub field update {alias} returned no project item"),
5880 })?;
5881 if required_str(returned, "id")? != item {
5882 return Err(SourceError::Malformed {
5883 message: format!("GitHub field update {alias} returned the wrong project item"),
5884 });
5885 }
5886 }
5887 Ok(())
5888 }
5889
5890 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
5891 let mut after: Option<String> = None;
5892 let mut ids = Vec::new();
5893 loop {
5894 let data = self
5895 .graphql(
5896 graphql::ISSUE_DEPENDENCIES,
5897 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
5898 )
5899 .await?;
5900 let connection =
5901 data.pointer("/node/blockedBy")
5902 .ok_or_else(|| SourceError::Malformed {
5903 message: "GitHub dependency response has no blockedBy connection".into(),
5904 })?;
5905 ids.extend(
5906 connection
5907 .get("nodes")
5908 .and_then(Value::as_array)
5909 .ok_or_else(|| SourceError::Malformed {
5910 message: "GitHub dependency response nodes is not an array".into(),
5911 })?
5912 .iter()
5913 .map(|value| required_str(value, "id").map(str::to_owned))
5914 .collect::<Result<Vec<_>, _>>()?,
5915 );
5916 let next = next_cursor(connection)?;
5917 if let Some(next) = &next {
5918 validate_cursor_progress(after.as_deref(), &next.0)?;
5919 }
5920 after = next.map(|cursor| cursor.0);
5921 if after.is_none() {
5922 return Ok(ids);
5923 }
5924 }
5925 }
5926
5927 async fn dependencies(
5928 &self,
5929 id: &NativeId,
5930 near_kind: ItemKind,
5931 direction: Direction,
5932 page: &PageRequest,
5933 ) -> Result<Page<DependencyEdge>, SourceError> {
5934 validate_page(page)?;
5935 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5936 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5937 let recorded = recorded_offset(cursor, direction)?;
5938 // Asked for even in the recorded phase, whose page reads nothing from the
5939 // connection: `__typename` is what says whether this item has a native
5940 // relationship at all, and that is what decides which far ends the reserved key is
5941 // allowed to hold.
5942 let data = self
5943 .graphql(
5944 graphql::ISSUE_DEPENDENCIES,
5945 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
5946 "after":if recorded.is_some() {None} else {cursor}}),
5947 )
5948 .await?;
5949 let node =
5950 data.get("node")
5951 .filter(|v| !v.is_null())
5952 .ok_or_else(|| SourceError::Refused {
5953 message: format!(
5954 "GitHub item {} was not found or does not support dependencies",
5955 id.0
5956 ),
5957 })?;
5958 let connection_name = match direction {
5959 Direction::DependsOn => "blockedBy",
5960 Direction::DependedOnBy => "blocking",
5961 };
5962 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
5963 // named natively and the reserved key may hold any far end. An issue's connections
5964 // hold issues, and this source reads them at the near item's own level.
5965 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
5966 if let Some(offset) = recorded {
5967 return Ok(recorded_page(
5968 self.recorded_edges(id, near_kind, direction, natively_names, node)
5969 .await?,
5970 offset,
5971 limit,
5972 ));
5973 }
5974 if natively_names.is_none() {
5975 return Ok(recorded_page(
5976 self.recorded_edges(id, near_kind, direction, natively_names, node)
5977 .await?,
5978 0,
5979 limit,
5980 ));
5981 }
5982 let connection = node
5983 .get(connection_name)
5984 .ok_or_else(|| SourceError::Malformed {
5985 message: "GitHub dependency response is missing its connection".into(),
5986 })?;
5987 let nodes = connection
5988 .get("nodes")
5989 .and_then(Value::as_array)
5990 .ok_or_else(|| SourceError::Malformed {
5991 message: "GitHub dependency response nodes is not an array".into(),
5992 })?;
5993 // `from` depends on `to`, always. GitHub spells the same relationship from either
5994 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
5995 // it — so the near item is `from` in one direction and `to` in the other.
5996 let items = nodes
5997 .iter()
5998 .map(|value| {
5999 let related = NativeId(required_str(value, "id")?.into());
6000 let related_kind = related_kind(value)?;
6001 let (from, to) = match direction {
6002 Direction::DependsOn => (
6003 DependencyEndpoint::from_native(id.clone(), near_kind),
6004 DependencyEndpoint::from_native(related, related_kind),
6005 ),
6006 Direction::DependedOnBy => (
6007 DependencyEndpoint::from_native(related, related_kind),
6008 DependencyEndpoint::from_native(id.clone(), near_kind),
6009 ),
6010 };
6011 Ok(DependencyEdge {
6012 from,
6013 to,
6014 kind: DependencyKind::Blocks,
6015 })
6016 })
6017 .collect::<Result<Vec<_>, SourceError>>()?;
6018 let mut next = next_cursor(connection)?;
6019 if let Some(next) = &next {
6020 validate_cursor_progress(cursor, &next.0)?;
6021 }
6022 if next.is_none()
6023 && !self
6024 .recorded_edges(id, near_kind, direction, natively_names, node)
6025 .await?
6026 .is_empty()
6027 {
6028 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
6029 }
6030 Ok(Page { items, next })
6031 }
6032
6033 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
6034 /// a far end in another source has to live: no GitHub issue relationship can name one.
6035 ///
6036 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
6037 /// source never writes one down.
6038 ///
6039 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
6040 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
6041 /// request beyond the read already made, and reading the board for them would be a
6042 /// walk of every item for one field of one. A draft has no body in that answer, because
6043 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
6044 /// listing of the board, which can be behind on the very item asked about.
6045 async fn recorded_edges(
6046 &self,
6047 id: &NativeId,
6048 near_kind: ItemKind,
6049 direction: Direction,
6050 natively_names: Option<ItemKind>,
6051 node: &Value,
6052 ) -> Result<Vec<DependencyEdge>, SourceError> {
6053 if direction != Direction::DependsOn {
6054 return Ok(Vec::new());
6055 }
6056 let slot = match node.get("body") {
6057 Some(body) if natively_names.is_some() => {
6058 metadata_body(body.as_str().map(str::to_owned))?.1
6059 }
6060 _ => {
6061 let Some(item) = self.bound_item(id).await? else {
6062 return Ok(Vec::new());
6063 };
6064 item.slot
6065 }
6066 };
6067 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
6068 .map_err(|message| SourceError::Malformed { message })
6069 }
6070
6071 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
6072 self.repository
6073 .as_ref()
6074 .ok_or_else(|| SourceError::Refused {
6075 message: format!(
6076 "source {} has no repository configured, and a GitHub Projects board has no \
6077 repository of its own to create an issue in; set repository: owner/name on \
6078 this source",
6079 self.name
6080 ),
6081 })
6082 }
6083
6084 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
6085 /// states.
6086 ///
6087 /// The fallback is demanded first, whichever arm answers: a write without a configured
6088 /// repository is refused naming the field exactly as it was before the rule existed,
6089 /// so a source that could not write before cannot write now, rather than writing for
6090 /// the one item whose own field happens to decide it.
6091 ///
6092 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
6093 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
6094 /// entry owned by someone other than the owner of the parent issue's repository —
6095 /// GitHub accepts a sub-issue from another repository of the same owner and from no
6096 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
6097 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
6098 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
6099 /// and is visible to the token is checked where its node id is resolved, still before
6100 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
6101 /// looked up in a listing of the board, which can be minutes behind an issue its own
6102 /// `projectItems` already places on it — and that read answers first from this process's
6103 /// own record, so a project created moments ago in this command answers though GitHub
6104 /// has not caught up.
6105 async fn creation_target(
6106 &self,
6107 incoming: &Incoming<'_>,
6108 ) -> Result<RepositoryTarget, SourceError> {
6109 let fallback = self.configured_repository()?;
6110 let what = |incoming: &Incoming<'_>| {
6111 format!(
6112 "{} {:?}",
6113 incoming.written.kind().describes(),
6114 incoming.title
6115 )
6116 };
6117 let parent = match incoming.parent {
6118 Some(parent) => Some(self.bound_item(parent).await?.ok_or_else(|| {
6119 SourceError::Refused {
6120 message: format!(
6121 "GitHub project issue {} was not found on the board of source {}, so {} \
6122 cannot be filed under it",
6123 parent.0,
6124 self.name,
6125 what(incoming)
6126 ),
6127 }
6128 })?),
6129 None => None,
6130 };
6131 let parents_repository = parent
6132 .as_ref()
6133 .map(|parent| {
6134 // A draft is on the board and so is found, but it has no repository to
6135 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
6136 // would refuse the task only once `createIssue` had made it.
6137 if parent.content_kind == ContentKind::DraftIssue {
6138 return Err(SourceError::Refused {
6139 message: format!(
6140 "GitHub project item {} on the board of source {} is a draft, \
6141 which cannot have sub-issues, so {} cannot be filed under it",
6142 parent.id.0,
6143 self.name,
6144 what(incoming)
6145 ),
6146 });
6147 }
6148 // An issue's repository is where a sub-issue is placed and whose owner it
6149 // is compared against, so a parent whose repository this source cannot
6150 // spell as `owner/name` — GitHub's login grammar is wider than this
6151 // source's floor — is one nothing can be filed under.
6152 parent
6153 .own_repository
6154 .as_ref()
6155 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
6156 .ok_or_else(|| SourceError::Malformed {
6157 message: format!(
6158 "GitHub project issue {} on the board of source {} is in {}, which \
6159 is not a {}/owner/name repository this source can place {} in",
6160 parent.id.0,
6161 self.name,
6162 parent
6163 .own_repository
6164 .as_ref()
6165 .map_or("no repository", Repository::as_str),
6166 RepositoryTarget::HOST,
6167 what(incoming)
6168 ),
6169 })
6170 })
6171 .transpose()?;
6172 match incoming.repositories {
6173 [named] => {
6174 let target =
6175 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
6176 message: format!(
6177 "{} names repository {}, which is not a {}/owner/name repository \
6178 source {} can create an issue in; name one that is, or name none",
6179 what(incoming),
6180 named.as_str(),
6181 RepositoryTarget::HOST,
6182 self.name
6183 ),
6184 })?;
6185 if let Some(parents) = &parents_repository
6186 && parents.owner != target.owner
6187 {
6188 return Err(SourceError::Refused {
6189 message: format!(
6190 "{} names repository {}, owned by {}, but its project's issue is in \
6191 {}, owned by {}, and GitHub files a sub-issue only in a repository \
6192 of the same owner as its parent issue; name a repository of {}, or \
6193 name none",
6194 what(incoming),
6195 target.slug(),
6196 target.owner,
6197 parents.slug(),
6198 parents.owner,
6199 parents.owner
6200 ),
6201 });
6202 }
6203 Ok(target)
6204 }
6205 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
6206 }
6207 }
6208
6209 /// The node id of the repository `incoming` is being created in, or the refusal naming
6210 /// the item and the repository the token cannot see.
6211 ///
6212 /// Resolved once per command per repository; see [`Self::repository_cache`].
6213 async fn repository_id(
6214 &self,
6215 repository: &RepositoryTarget,
6216 incoming: &Incoming<'_>,
6217 ) -> Result<String, SourceError> {
6218 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
6219 return Ok(id);
6220 }
6221 let data = self
6222 .graphql(
6223 graphql::REPOSITORY,
6224 json!({"owner":repository.owner,"name":repository.name}),
6225 )
6226 .await?;
6227 let node = data
6228 .get("repository")
6229 .filter(|value| !value.is_null())
6230 .ok_or_else(|| SourceError::Refused {
6231 message: format!(
6232 "GitHub repository {} was not found or is not visible to the token, so {} \
6233 {:?} cannot be created in it",
6234 repository.slug(),
6235 incoming.written.kind().describes(),
6236 incoming.title
6237 ),
6238 })?;
6239 let id = required_str(node, "id")?.to_owned();
6240 self.repository_cache()?
6241 .insert(repository.clone(), id.clone());
6242 Ok(id)
6243 }
6244
6245 fn repository_cache(
6246 &self,
6247 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
6248 self.repository_cache
6249 .lock()
6250 .map_err(|_| SourceError::Unavailable {
6251 message: "this source's record of the destination repository was left \
6252 inconsistent by an earlier failure; next: run the command again"
6253 .into(),
6254 })
6255 }
6256
6257 /// Create or update one board item, whichever kind it is.
6258 async fn write_item(
6259 &self,
6260 incoming: &Incoming<'_>,
6261 target: Option<&NativeId>,
6262 depends_on: &[DependencyEdge],
6263 ) -> Result<NativeId, SourceError> {
6264 // Refused before anything is read or written: a task or a project titled the way
6265 // this board spells a document would land as an issue this same source reads back
6266 // as a document, so the field this destination cannot carry is named rather than
6267 // written and silently reclassified.
6268 if let Written::Work(kind, _) = incoming.written
6269 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
6270 {
6271 return Err(SourceError::Refused {
6272 message: format!(
6273 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
6274 spells a document, so it would read back as one rather than as a {}; \
6275 retitle it, or copy it as a document",
6276 kind.marker(),
6277 self.name,
6278 kind.marker()
6279 ),
6280 });
6281 }
6282 // The destination is read by its own id, and whether this board holds it is decided
6283 // by that read — its own `projectItems` — rather than by whether a listing of the
6284 // board happens to include it yet. See the module documentation.
6285 let existing = match target {
6286 Some(target) => {
6287 Some(
6288 self.bound_item(target)
6289 .await?
6290 .ok_or_else(|| SourceError::Refused {
6291 message: format!("GitHub destination item {} was not found", target.0),
6292 })?,
6293 )
6294 }
6295 None => None,
6296 };
6297 let existing = existing.as_ref();
6298 let board = self
6299 .fields_for(
6300 existing,
6301 incoming.written.status().is_some(),
6302 incoming
6303 .priority
6304 .is_some_and(|priority| priority != Priority::None),
6305 )
6306 .await?;
6307 let status_target = incoming
6308 .written
6309 .status()
6310 .map(|status| self.resolved_target(status.category))
6311 .transpose()?;
6312 let column = match (incoming.written.status(), status_target.as_ref()) {
6313 (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
6314 _ => None,
6315 };
6316 // Resolved before anything is created, for the reason the column above is: a
6317 // priority this board has no option for is refused while nothing has been written.
6318 let priority_write = match incoming.priority {
6319 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
6320 None => None,
6321 };
6322 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
6323 if content_kind == ContentKind::DraftIssue {
6324 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
6325 (status_target.as_ref(), incoming.written.status())
6326 {
6327 return Err(self.closes_a_draft(status.category));
6328 }
6329 if incoming.parent.is_some() {
6330 return Err(SourceError::Refused {
6331 message: "GitHub draft items cannot be a project's sub-issue".into(),
6332 });
6333 }
6334 }
6335 match existing {
6336 Some(item) if content_kind == ContentKind::Issue => {
6337 if item.labels != incoming.labels {
6338 return Err(SourceError::Refused {
6339 message: "GitHub issue labels differ from the labels being written".into(),
6340 });
6341 }
6342 }
6343 _ => {
6344 if !incoming.labels.is_empty() {
6345 return Err(SourceError::Refused {
6346 message: "GitHub items created by this destination carry no labels".into(),
6347 });
6348 }
6349 }
6350 }
6351
6352 // An existing issue is never moved; a new one is created where the rule says. The
6353 // repository the issue really lives in is what the slot below is written against,
6354 // so a single entry that is where the issue is created travels as no key at all,
6355 // and the read side derives it back from the issue.
6356 let (own_repository, creation_target) = match existing {
6357 Some(item) => (item.own_repository.clone(), None),
6358 None => {
6359 let target = self.creation_target(incoming).await?;
6360 let origin = Repository::try_from(target.origin())
6361 .map_err(|message| SourceError::Config { message })?;
6362 (Some(origin), Some(target))
6363 }
6364 };
6365 let (native, fallback) = self
6366 .partition_edges(incoming.written.kind(), content_kind, depends_on)
6367 .await?;
6368 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
6369 let body = compose_body(incoming.content, &slot)?;
6370 // Read before anything is created, for the reason the field below is: a value
6371 // this destination cannot store has to refuse, and refusing after `createIssue`
6372 // would leave an issue behind that nothing asked for. The engine writes a
6373 // qualified id here; a caller handing this key anything else is told so rather
6374 // than having it silently stored as no origin at all.
6375 // 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.
6376 let origin = match incoming.metadata.get(ORIGIN_KEY) {
6377 None => "",
6378 Some(Value::String(origin)) => origin.as_str(),
6379 Some(other) => {
6380 return Err(SourceError::Refused {
6381 message: format!(
6382 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
6383 is {other}"
6384 ),
6385 });
6386 }
6387 };
6388 // Resolved before anything is created: a board that cannot carry the copy origin
6389 // has to refuse the write, and refusing it after `createIssue` would leave an
6390 // issue behind that nothing asked for.
6391 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
6392 Some(field) => {
6393 if required_str(field, "__typename")? != "ProjectV2Field" {
6394 return Err(SourceError::Refused {
6395 message: format!(
6396 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
6397 ),
6398 });
6399 }
6400 Some(required_str(field, "id")?.to_owned())
6401 }
6402 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
6403 return Err(SourceError::Refused {
6404 message: format!(
6405 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
6406 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
6407 the board"
6408 ),
6409 });
6410 }
6411 None => None,
6412 };
6413
6414 let Landed {
6415 content_id,
6416 item_id,
6417 url,
6418 number,
6419 } = match existing {
6420 Some(item) => {
6421 self.update_existing(item, incoming, &body, status_target.as_ref())
6422 .await?;
6423 Landed {
6424 content_id: item.id.clone(),
6425 item_id: item.item_id.clone(),
6426 url: item.url.clone(),
6427 number: item.number,
6428 }
6429 }
6430 None => {
6431 let target = creation_target
6432 .as_ref()
6433 .ok_or_else(|| SourceError::Malformed {
6434 message: "a new item was decided without a repository to create it in"
6435 .into(),
6436 })?;
6437 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
6438 .await?
6439 }
6440 };
6441
6442 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
6443 let column = column
6444 .filter(|(_, _, name)| existing.is_none_or(|item| item.option.as_ref() != Some(name)))
6445 .map(|(field, option, _)| (field, option));
6446 // Creating an item here is several calls — `createIssue`, `addProjectV2ItemById`,
6447 // then each board field, the parent and the dependencies — and GitHub can fail at
6448 // any of them. Everything this source can refuse *before* the first of those is
6449 // already checked above, so what is left is GitHub itself failing part way. When it
6450 // does over an item this call created, the issue is taken back: a write that
6451 // refused must not leave an item behind that nobody asked for, and one that does
6452 // makes the retry create a second.
6453 let landed = self
6454 .finish_write(
6455 board.id.as_str(),
6456 incoming,
6457 &content_id,
6458 &item_id,
6459 content_kind,
6460 existing,
6461 origin_field.as_deref(),
6462 origin,
6463 column,
6464 status_target.as_ref(),
6465 priority_write.as_ref(),
6466 &native,
6467 )
6468 .await;
6469 if let Err(error) = landed {
6470 if existing.is_none() {
6471 // Best effort, and the write's own failure is what the caller is told: a
6472 // refusal naming the tidy-up would hide why the write failed at all.
6473 let _ = self.delete_issue(&content_id).await;
6474 }
6475 return Err(error);
6476 }
6477
6478 let written_status = match (incoming.written.status(), status_target.as_ref()) {
6479 (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
6480 self.statuses
6481 .status(written_option.as_deref(), true, Some(reason.reason()))
6482 }
6483 (Some(_), Some(StatusTarget::Column(_))) => {
6484 self.statuses.status(written_option.as_deref(), false, None)
6485 }
6486 (Some(status), _) => status.clone(),
6487 (None, _) => Status {
6488 category: StatusCategory::Unknown,
6489 name: "Open".to_owned(),
6490 },
6491 };
6492
6493 // So the rest of this command reads what it just did rather than what the board
6494 // said before it. See `remember_written` for which half takes it.
6495 let remembered = Resolved {
6496 item_id,
6497 id: content_id.clone(),
6498 content_kind,
6499 kind: incoming.written.kind(),
6500 title: incoming.title.to_owned(),
6501 // The visible half of the body this write composed, split back off it the
6502 // way a read splits it — so what this record reports is what a read of the
6503 // same issue reports, rather than the person's text with the metadata slot
6504 // still on the end of it.
6505 body: metadata_body(body.clone())?.0,
6506 raw_body: body.clone(),
6507 // A document has no status of its own; what it reads back as is whatever
6508 // the issue's own state says, which is what a re-read reports.
6509 status: written_status,
6510 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
6511 priority: match incoming.priority {
6512 Some(priority) => HeldPriority::Read(priority),
6513 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
6514 item.priority.clone()
6515 }),
6516 },
6517 // What `state_input` asked for: closed for a terminal target, open for any other
6518 // status, and the issue's own state left as it was by a document write.
6519 closed: content_kind == ContentKind::Issue
6520 && match status_target.as_ref() {
6521 Some(StatusTarget::Terminal(_, _)) => true,
6522 Some(_) => false,
6523 None => existing.is_some_and(|item| item.closed),
6524 },
6525 delivers: incoming.delivers.to_vec(),
6526 delivered_by: incoming.delivered_by.to_vec(),
6527 labels: incoming.labels.to_vec(),
6528 parent: incoming.parent.cloned(),
6529 origin: (!origin.is_empty()).then(|| origin.to_owned()),
6530 number,
6531 // In the update path this is the item's own url, read off `existing` where the
6532 // record above was bound, so one expression serves both halves.
6533 url,
6534 created_at: existing.and_then(|item| item.created_at),
6535 updated_at: existing.and_then(|item| item.updated_at),
6536 own_repository,
6537 repositories: incoming.repositories.to_vec(),
6538 slot,
6539 board_id: Some(board.id.as_str().to_owned()),
6540 fields: board
6541 .fields
6542 .get("nodes")
6543 .and_then(Value::as_array)
6544 .cloned()
6545 .unwrap_or_default(),
6546 };
6547 self.remember_written(remembered, existing.is_none())?;
6548 Ok(content_id)
6549 }
6550
6551 /// Everything a write does after the item exists: its board fields, its parent, and
6552 /// its dependencies.
6553 ///
6554 /// Split out of `write_item` so there is one place a failure past the point of no
6555 /// return is caught, rather than a tidy-up repeated at each `?` above.
6556 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
6557 // so there is one place a failure past the point of no return is caught, and its
6558 // arguments are exactly the values that tail already had in scope. Bundling them into a
6559 // struct would describe no concept — it would be "the arguments of this function" — and
6560 // would put the whole of `write_item`'s locals behind one more indirection.
6561 #[allow(clippy::too_many_arguments)]
6562 async fn finish_write(
6563 &self,
6564 board_id: &str,
6565 incoming: &Incoming<'_>,
6566 content_id: &NativeId,
6567 item_id: &str,
6568 content_kind: ContentKind,
6569 existing: Option<&Resolved>,
6570 origin_field: Option<&str>,
6571 origin: &str,
6572 column: Option<(String, String)>,
6573 status_target: Option<&StatusTarget>,
6574 priority: Option<&PriorityWrite>,
6575 native: &[String],
6576 ) -> Result<(), SourceError> {
6577 let mut fields = Vec::new();
6578 if let Some(field_id) = origin_field
6579 && existing.map_or(!origin.is_empty(), |item| {
6580 item.origin.as_deref().unwrap_or("") != origin
6581 })
6582 {
6583 fields.push((field_id.to_owned(), json!({"text":origin})));
6584 }
6585 if let Some((field_id, option_id)) = column {
6586 fields.push((field_id, json!({"singleSelectOptionId":option_id})));
6587 }
6588 let clear = match priority {
6589 Some(PriorityWrite::Select { field, option }) => {
6590 fields.push((field.clone(), json!({"singleSelectOptionId":option})));
6591 None
6592 }
6593 Some(PriorityWrite::Clear { field }) => Some(field.as_str()),
6594 None => None,
6595 };
6596 self.set_item_fields(board_id, item_id, &fields, clear)
6597 .await?;
6598
6599 if content_kind == ContentKind::Issue
6600 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
6601 {
6602 self.update_content(
6603 ContentKind::Issue,
6604 content_id,
6605 json!({"stateInput":state_input(status_target)}),
6606 )
6607 .await?;
6608 }
6609
6610 if content_kind == ContentKind::Issue {
6611 self.reparent(
6612 existing.and_then(|item| item.parent.clone()),
6613 content_id,
6614 incoming.parent,
6615 )
6616 .await?;
6617 // A document takes part in no dependency graph, so writing one neither reads
6618 // nor changes the issue's own `blockedBy` relationships. Reconciling them
6619 // against the empty list a document write carries would *delete* whatever
6620 // relationships a person had made on that issue, which is a write nobody
6621 // asked for.
6622 if incoming.written.kind() != BoardKind::Document {
6623 let issue = match existing {
6624 Some(_) => Issue::Existing,
6625 None => Issue::Created,
6626 };
6627 self.reconcile_blocked_by(content_id, native, issue).await?;
6628 }
6629 }
6630 Ok(())
6631 }
6632
6633 /// Delete one issue, which takes its board item with it.
6634 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
6635 let data = self
6636 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6637 .await?;
6638 data.pointer("/deleteIssue/repository")
6639 .filter(|value| !value.is_null())
6640 .ok_or_else(|| SourceError::Malformed {
6641 message: "GitHub issue deletion returned no repository".into(),
6642 })?;
6643 self.forget(id)?;
6644 Ok(())
6645 }
6646
6647 /// Remove one item this copy created, so a copy that could not finish leaves the board
6648 /// as it found it.
6649 ///
6650 /// Deleting the issue takes its board item with it, so there is no second mutation to
6651 /// keep in step. An id the board does not hold is not an error: the item is already
6652 /// gone, which is the state this asks for. Which that is, is decided by reading the item
6653 /// by its own id — a listing of the board can still be missing an item it holds, and
6654 /// reading that as *already gone* would leave behind the very item this was asked to
6655 /// take back.
6656 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
6657 let Some(item) = self.bound_item(id).await? else {
6658 return Ok(());
6659 };
6660 if item.content_kind == ContentKind::DraftIssue {
6661 return Err(SourceError::Refused {
6662 message: format!(
6663 "GitHub item {} is a draft, and this source removes an item by deleting \
6664 its issue; next: remove it from the board by hand",
6665 id.0
6666 ),
6667 });
6668 }
6669 let data = self
6670 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
6671 .await?;
6672 data.pointer("/deleteIssue/repository")
6673 .filter(|value| !value.is_null())
6674 .ok_or_else(|| SourceError::Malformed {
6675 message: "GitHub issue deletion returned no repository".into(),
6676 })?;
6677 self.forget(id)?;
6678 Ok(())
6679 }
6680
6681 /// The issue a comment call on `task` is about, or `None` when this board holds no such
6682 /// task.
6683 ///
6684 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
6685 /// read of the task cannot disagree about which ids name one: a project or a document of
6686 /// this board is not a task here either.
6687 ///
6688 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
6689 /// issues and a draft is not one. It is refused rather than answered with an empty page,
6690 /// which would read as a task nobody has commented on yet.
6691 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
6692 let cached = self.resolved_cache()?.get(task).cloned();
6693 let Some(item) = (match cached {
6694 Some(item) => Some(item),
6695 None => self.item_by_id(task).await?,
6696 })
6697 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task)) else {
6698 return Ok(None);
6699 };
6700 if item.content_kind == ContentKind::DraftIssue {
6701 return Err(SourceError::Refused {
6702 message: format!(
6703 "task {} of source {} is a draft item on the board, and GitHub keeps \
6704 comments on issues alone, so a draft has none to read or write; next: \
6705 convert the draft to an issue on the board, then comment on the issue it \
6706 becomes",
6707 task.0, self.name
6708 ),
6709 });
6710 }
6711 Ok(Some(item.id))
6712 }
6713
6714 /// Whether the comment `comment` is one of `issue`'s own.
6715 ///
6716 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
6717 /// comment's id and nothing else: a comment id given against the wrong task would
6718 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
6719 /// names something that is not an issue comment, is a comment this task does not have —
6720 /// which is what GitHub refusing to resolve it means too.
6721 async fn comment_is_on(
6722 &self,
6723 issue: &NativeId,
6724 comment: &NativeId,
6725 ) -> Result<bool, SourceError> {
6726 let asked = self
6727 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
6728 .await;
6729 let data = match asked {
6730 Ok(data) => data,
6731 Err(error) if unresolvable_node(&error) => return Ok(false),
6732 Err(error) => return Err(error),
6733 };
6734 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6735 return Ok(false);
6736 };
6737 if optional_str(node, "__typename")? != Some("IssueComment") {
6738 return Ok(false);
6739 }
6740 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
6741 message: format!("GitHub issue comment {} names no issue", comment.0),
6742 })?;
6743 Ok(required_str(on, "id")? == issue.0)
6744 }
6745
6746 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
6747 async fn partition_edges(
6748 &self,
6749 near_kind: BoardKind,
6750 near_content: ContentKind,
6751 depends_on: &[DependencyEdge],
6752 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
6753 let mut native = Vec::new();
6754 let mut fallback = Vec::new();
6755 for edge in depends_on {
6756 let same_source = edge
6757 .to
6758 .source()
6759 .is_none_or(|source| source == self.name.as_str());
6760 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
6761 // `DependencyEndpoint::source` both read it that way — and a native id may hold
6762 // colons of its own, so the far end is everything after that one separator.
6763 // Splitting at the last would truncate `work:urn:task:7` to `7`.
6764 let far_id = if edge.to.is_qualified() {
6765 edge.to
6766 .id()
6767 .split_once(':')
6768 .map_or(edge.to.id(), |(_, native)| native)
6769 } else {
6770 edge.to.id()
6771 };
6772 // A same-source far end is read by its own id, exactly as the item it is a far end
6773 // of is: whether this board holds it is that read's answer, never a listing's.
6774 let far = if same_source {
6775 Some(
6776 self.item_by_id(&NativeId(far_id.to_owned()))
6777 .await?
6778 .ok_or_else(|| SourceError::Refused {
6779 message: format!("GitHub dependency item {far_id} was not found"),
6780 })?,
6781 )
6782 } else {
6783 None
6784 };
6785 let far = far.as_ref();
6786 // The caller says which kind the far end is, and this board holds the far end
6787 // itself, so a disagreement is settled here rather than stored: recorded, the
6788 // wrong kind would read back as a cross-level edge that never existed; written
6789 // natively, it would name a relationship of a different level than the caller
6790 // asked for.
6791 //
6792 // A far end this board holds as a *document* fails the same comparison and is
6793 // refused by the same sentence: `ItemKind` has no document variant because
6794 // nothing may point at one, so no caller can name it correctly and the refusal
6795 // is the only honest answer.
6796 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
6797 return Err(SourceError::Refused {
6798 message: format!(
6799 "GitHub dependency item {far_id} is a {} of this board, and this item \
6800 names it as a {}; record the kind it is",
6801 disagreeing.kind.describes(),
6802 edge.to.kind.marker()
6803 ),
6804 });
6805 }
6806 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
6807 // however the far end is spelled — and one classified native here would be
6808 // written nowhere at all, because a draft's native reconciliation never runs.
6809 let native_here = near_content == ContentKind::Issue
6810 && far.is_some_and(|far| {
6811 far.content_kind == ContentKind::Issue
6812 && BoardKind::Work(edge.to.kind) == near_kind
6813 });
6814 if native_here {
6815 native.push(far_id.to_owned());
6816 } else {
6817 fallback.push(edge.clone());
6818 }
6819 }
6820 Ok((native, fallback))
6821 }
6822
6823 async fn update_existing(
6824 &self,
6825 item: &Resolved,
6826 incoming: &Incoming<'_>,
6827 body: &Option<String>,
6828 status_target: Option<&StatusTarget>,
6829 ) -> Result<(), SourceError> {
6830 let title = incoming.written_title();
6831 let mut fields = match item.content_kind {
6832 ContentKind::DraftIssue => json!({"title":title,"body":body}),
6833 ContentKind::Issue => json!({"title":title,"body":body,
6834 "stateInput":state_input(status_target)}),
6835 };
6836 if matches!(status_target, Some(StatusTarget::Terminal(_, _))) {
6837 fields
6838 .as_object_mut()
6839 .expect("update fields are an object")
6840 .remove("stateInput");
6841 }
6842 self.update_content(item.content_kind, &item.id, fields)
6843 .await
6844 }
6845
6846 /// Update one board item's content with exactly `fields` beside its id, through the
6847 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
6848 /// a draft.
6849 ///
6850 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
6851 /// is what lets a narrow write carry the one thing it changes and nothing else.
6852 async fn update_content(
6853 &self,
6854 kind: ContentKind,
6855 id: &NativeId,
6856 fields: Value,
6857 ) -> Result<(), SourceError> {
6858 let (operation, id_key, pointer) = match kind {
6859 ContentKind::DraftIssue => (
6860 graphql::UPDATE_DRAFT,
6861 "draftIssueId",
6862 "/updateProjectV2DraftIssue/draftIssue",
6863 ),
6864 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
6865 };
6866 let mut input = fields;
6867 input[id_key] = json!(id.0);
6868 let data = self.graphql(operation, json!({"input":input})).await?;
6869 let returned = data
6870 .pointer(pointer)
6871 .ok_or_else(|| SourceError::Malformed {
6872 message: "GitHub item update returned no item".into(),
6873 })?;
6874 if required_str(returned, "id")? != id.0 {
6875 return Err(SourceError::Malformed {
6876 message: "GitHub item update returned the wrong item".into(),
6877 });
6878 }
6879 Ok(())
6880 }
6881
6882 /// Creates one issue, files it on the board, and reports what a read of it would say:
6883 /// its content id, its board item id, and the web address GitHub gave it.
6884 ///
6885 /// Two calls rather than one: `createIssue` needs a repository and answers with an
6886 /// issue that is on no board, and `addProjectV2ItemById` is what puts it there. A
6887 /// terminal status is not written here: `finish_write` selects its option first and
6888 /// closes the issue after, so a close never lands on an item whose board cannot show it.
6889 ///
6890 /// The address and the number come back here because this is the only place either is
6891 /// known before GitHub's own board read catches up — an item this run created answers
6892 /// the reads that follow it out of the record below, and one remembered without them
6893 /// would report no location and no key for the rest of the run.
6894 async fn create_and_file_issue(
6895 &self,
6896 board_id: &str,
6897 repository: &RepositoryTarget,
6898 incoming: &Incoming<'_>,
6899 body: &Option<String>,
6900 ) -> Result<Landed, SourceError> {
6901 let repository_id = self.repository_id(repository, incoming).await?;
6902 let data = self
6903 .graphql(
6904 graphql::CREATE_ISSUE,
6905 json!({"input":{
6906 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
6907 }}),
6908 )
6909 .await?;
6910 let created = data
6911 .pointer("/createIssue/issue")
6912 .filter(|value| !value.is_null())
6913 .ok_or_else(|| SourceError::Malformed {
6914 message: "GitHub issue creation returned no issue".into(),
6915 })?;
6916 let content_id = NativeId(required_str(created, "id")?.to_owned());
6917 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
6918 // a response without it is not worth failing a landed write over — the item simply
6919 // reports no location until the board read catches up, which is what it did before.
6920 let url = optional_str(created, "url")?.map(str::to_owned);
6921 // The issue exists from here on, so an unreadable number and a refused board
6922 // filing below each try, best effort, to take it back: an issue in the repository
6923 // that is on no board is an item nobody asked for and nothing here would find again.
6924 //
6925 // Its number is optional on the same terms its address is — a landed write is not
6926 // worth failing over a member that came back missing, and such an item reports no
6927 // handle until a board read catches up. A number that is *present* and is not an
6928 // unsigned integer is still a response this source cannot read.
6929 let number = match created_issue_number(created) {
6930 Ok(number) => number,
6931 Err(error) => {
6932 let _ = self.delete_issue(&content_id).await;
6933 return Err(error);
6934 }
6935 };
6936 let added = match self
6937 .graphql(
6938 graphql::ADD_TO_BOARD,
6939 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
6940 )
6941 .await
6942 {
6943 Ok(added) => added,
6944 Err(error) => {
6945 let _ = self.delete_issue(&content_id).await;
6946 return Err(error);
6947 }
6948 };
6949 let item = added
6950 .pointer("/addProjectV2ItemById/item")
6951 .filter(|value| !value.is_null())
6952 .ok_or_else(|| SourceError::Malformed {
6953 message: "GitHub board addition returned no project item".into(),
6954 })?;
6955 Ok(Landed {
6956 content_id,
6957 item_id: required_str(item, "id")?.to_owned(),
6958 url,
6959 number,
6960 })
6961 }
6962
6963 /// Move one issue under the project it now belongs to, or out of the one it left.
6964 async fn reparent(
6965 &self,
6966 held: Option<NativeId>,
6967 child: &NativeId,
6968 wanted: Option<&NativeId>,
6969 ) -> Result<(), SourceError> {
6970 if held.as_ref() == wanted {
6971 return Ok(());
6972 }
6973 if let Some(held) = &held {
6974 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
6975 .await?;
6976 }
6977 if let Some(wanted) = wanted {
6978 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
6979 .await?;
6980 }
6981 Ok(())
6982 }
6983
6984 async fn sub_issue(
6985 &self,
6986 operation: &str,
6987 parent: &NativeId,
6988 child: &NativeId,
6989 root: &str,
6990 ) -> Result<(), SourceError> {
6991 let data = self
6992 .graphql(
6993 operation,
6994 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
6995 )
6996 .await?;
6997 let issue =
6998 data.pointer(&format!("/{root}/issue"))
6999 .ok_or_else(|| SourceError::Malformed {
7000 message: "GitHub sub-issue update returned no issue".into(),
7001 })?;
7002 let sub =
7003 data.pointer(&format!("/{root}/subIssue"))
7004 .ok_or_else(|| SourceError::Malformed {
7005 message: "GitHub sub-issue update returned no sub-issue".into(),
7006 })?;
7007 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
7008 return Err(SourceError::Malformed {
7009 message: "GitHub sub-issue update returned the wrong issues".into(),
7010 });
7011 }
7012 Ok(())
7013 }
7014
7015 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
7016 /// whether there was one.
7017 ///
7018 /// An issue [`Issue::Created`] by this very write is blocked by nothing yet, so its
7019 /// relationships are not read: there is nothing a read of them could find.
7020 async fn reconcile_blocked_by(
7021 &self,
7022 content_id: &NativeId,
7023 native: &[String],
7024 issue: Issue,
7025 ) -> Result<bool, SourceError> {
7026 let current = match issue {
7027 Issue::Created => Vec::new(),
7028 Issue::Existing => self.native_dependency_ids(content_id).await?,
7029 };
7030 let mut changed = false;
7031 for (operation, far_id) in current
7032 .iter()
7033 .filter(|id| !native.contains(id))
7034 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
7035 .chain(
7036 native
7037 .iter()
7038 .filter(|id| !current.contains(id))
7039 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
7040 )
7041 {
7042 let data = self
7043 .graphql(
7044 operation,
7045 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
7046 )
7047 .await?;
7048 let root = if operation == graphql::ADD_BLOCKED_BY {
7049 "addBlockedBy"
7050 } else {
7051 "removeBlockedBy"
7052 };
7053 let issue =
7054 data.pointer(&format!("/{root}/issue"))
7055 .ok_or_else(|| SourceError::Malformed {
7056 message: "GitHub dependency update returned no issue".into(),
7057 })?;
7058 let blocker = data
7059 .pointer(&format!("/{root}/blockingIssue"))
7060 .ok_or_else(|| SourceError::Malformed {
7061 message: "GitHub dependency update returned no blocking issue".into(),
7062 })?;
7063 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
7064 {
7065 return Err(SourceError::Malformed {
7066 message: "GitHub dependency update returned the wrong issues".into(),
7067 });
7068 }
7069 changed = true;
7070 }
7071 Ok(changed)
7072 }
7073}
7074
7075/// Whether the issue one write reconciles was created by that write or was already there.
7076#[derive(Clone, Copy, PartialEq, Eq)]
7077enum Issue {
7078 /// Created by this write, so it holds no relationships yet.
7079 Created,
7080 /// On the board before this write, holding whatever relationships it holds.
7081 Existing,
7082}
7083
7084/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
7085enum Reached {
7086 /// An issue this board holds, resolved into everything this source reports about it.
7087 Held(Box<Resolved>),
7088 /// Nothing this board holds: no such node, or a node on some other board.
7089 Nothing,
7090 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
7091 /// again by [`GitHubProjectsSource::draft_by_id`].
7092 Draft,
7093}
7094
7095/// What GitHub says when a string is not a node id it can resolve.
7096///
7097/// Matched because it is the ordinary answer to a project selector naming a project by its
7098/// *name*, and reporting that as a failure would make naming one impossible. It is read
7099/// off the refusal GitHub sent, never guessed from the shape of the string: this source
7100/// does not define the syntax of a GitHub node id and would be wrong about it.
7101const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
7102
7103/// Whether this refusal is GitHub saying the id names no node at all.
7104fn unresolvable_node(error: &SourceError) -> bool {
7105 matches!(error, SourceError::Refused { message }
7106 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
7107}
7108
7109/// One project name, as a search qualifier which filters on it at the server.
7110///
7111/// Quoted so the whole title is one phrase rather than a bag of words, with the two
7112/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
7113/// the way it documents. A title matched here is still compared for equality afterwards:
7114/// the qualifier narrows what the server sends, and this source decides what it names.
7115fn title_qualifier(name: &str) -> String {
7116 format!("in:title {}", quoted(name))
7117}
7118
7119/// `value` as one quoted phrase of a GitHub search or a board filter, with the two
7120/// characters GitHub's quoting grammar gives a meaning inside a quoted phrase escaped the way
7121/// it documents — so a value holding a qualifier's spelling is searched for rather than
7122/// obeyed.
7123fn quoted(value: &str) -> String {
7124 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
7125 format!("\"{escaped}\"")
7126}
7127
7128/// The search qualifier for the issues updated at or after `since`.
7129///
7130/// Written to the second, rounded down, which can only widen what the search returns.
7131fn updated_qualifier(since: DateTime<Utc>) -> String {
7132 format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"))
7133}
7134
7135/// The search terms that narrow a board-scoped issue search to a task query's text and
7136/// metadata predicates, or `None` when it carries neither.
7137///
7138/// The text is one quoted phrase, searched `in:title`, `in:body` or both as its fields say,
7139/// and each metadata value is one more quoted phrase, which GitHub finds in the body because
7140/// its index covers the metadata comment the value is stored in. GitHub ANDs the phrases and
7141/// matches each in any field the `in:` qualifier names, so a query naming a title search and
7142/// a metadata value searches both fields for both — wider than asked, never narrower, and
7143/// every candidate is confirmed in process afterwards.
7144///
7145/// **This narrows a text search, and that is this source's declared semantics.** GitHub
7146/// matches whole tokens where a substring rule would match inside a word, so an item holding
7147/// the text only inside a longer word is not returned. A text of nothing but whitespace
7148/// matches every item, so it narrows nothing and is not sent.
7149fn narrowing_qualifiers(query: &TaskQuery) -> Option<String> {
7150 let text = query
7151 .text
7152 .as_ref()
7153 .filter(|text| !text.terms.trim().is_empty());
7154 if text.is_none() && query.metadata.is_empty() {
7155 return None;
7156 }
7157 let (title, body) = match text.map(|text| text.fields) {
7158 None => (false, true),
7159 Some(TextFields::Title) => (true, !query.metadata.is_empty()),
7160 Some(TextFields::Content) => (false, true),
7161 Some(TextFields::TitleOrContent) => (true, true),
7162 };
7163 let fields = match (title, body) {
7164 (true, true) => "in:title,body",
7165 (true, false) => "in:title",
7166 _ => "in:body",
7167 };
7168 let phrases = text
7169 .map(|text| text.terms.clone())
7170 .into_iter()
7171 .chain(
7172 query
7173 .metadata
7174 .iter()
7175 .map(|wanted| as_stored(wanted.value())),
7176 )
7177 .map(|phrase| quoted(&phrase))
7178 .collect::<Vec<_>>();
7179 Some(format!("{fields} {}", phrases.join(" ")))
7180}
7181
7182/// Refuses a task query naming a text or a metadata value GitHub's issue search cannot find,
7183/// before anything is asked of GitHub.
7184///
7185/// GitHub's index holds words, so a phrase with no letter or digit names none to find, and no
7186/// bounded query answers it: sent, GitHub's answer to it is nothing this source may rely on;
7187/// left out, the search is every issue of the board. So this source says it cannot answer
7188/// rather than reading the board or answering nothing. A blank text is not refused: it narrows
7189/// nothing GitHub could search for, and keeps the board read it always had.
7190fn refuse_unsearchable(query: &TaskQuery) -> Result<(), SourceError> {
7191 const WHY: &str = "GitHub's issue search indexes words, so it cannot answer a value with no \
7192 letter or digit with a bounded query";
7193 if let Some(text) = &query.text
7194 && !text.terms.trim().is_empty()
7195 && !has_words(&text.terms)
7196 {
7197 return Err(SourceError::Refused {
7198 message: format!(
7199 "cannot search for the text {:?}: {WHY}; search for a text holding a letter or a digit",
7200 text.terms
7201 ),
7202 });
7203 }
7204 if let Some(wanted) = query
7205 .metadata
7206 .iter()
7207 .find(|wanted| !has_words(wanted.value()))
7208 {
7209 return Err(SourceError::Refused {
7210 message: format!(
7211 "cannot filter by the metadata value {:?} at {:?}: {WHY}; filter by a value holding a letter or a digit",
7212 wanted.value(),
7213 std::iter::once(wanted.key())
7214 .chain(wanted.path().iter().map(String::as_str))
7215 .collect::<Vec<_>>()
7216 .join("/"),
7217 ),
7218 });
7219 }
7220 Ok(())
7221}
7222
7223/// Whether GitHub's index could hold a word of `phrase`: whether it has a letter or a digit.
7224fn has_words(phrase: &str) -> bool {
7225 phrase.chars().any(char::is_alphanumeric)
7226}
7227
7228/// `value` spelled the way the metadata slot stores it: as the inside of its JSON string.
7229///
7230/// What GitHub indexes is the slot's JSON text, so a value holding a character JSON escapes —
7231/// a newline, a tab, a quote — is found by the escape the body holds and not by the character,
7232/// which GitHub's word match would read as different words.
7233fn as_stored(value: &str) -> String {
7234 let encoded = Value::String(value.to_owned()).to_string();
7235 encoded[1..encoded.len() - 1].to_owned()
7236}
7237
7238/// The one narrower question a task query carrying a text, metadata or origin predicate is
7239/// sent as.
7240enum Narrowing {
7241 /// Every carrier of this origin: [`graphql::ORIGIN_LOOKUP`].
7242 Origin(String),
7243 /// The board-scoped issue search narrowed by these qualifiers.
7244 Search(String),
7245}
7246
7247impl Narrowing {
7248 /// What this question is remembered under for the length of one command.
7249 fn key(&self) -> String {
7250 match self {
7251 Self::Origin(origin) => format!("origin {origin}"),
7252 Self::Search(also) => format!("search {also}"),
7253 }
7254 }
7255}
7256
7257/// Where one connection of [`graphql::ORIGIN_LOOKUP`] resumes.
7258enum Resumed {
7259 /// It reported another page, which starts after this cursor.
7260 More(String),
7261 /// It has ended. Sending this cursor again — the page's own end when it had one, and
7262 /// otherwise the cursor it was reached from — answers an empty page, so the one document
7263 /// can go on walking the other connection.
7264 Ended(Option<String>),
7265}
7266
7267impl Resumed {
7268 /// Whether the connection has another page.
7269 const fn has_more(&self) -> bool {
7270 matches!(self, Self::More(_))
7271 }
7272
7273 /// The cursor to send this connection next.
7274 fn cursor(self) -> Option<String> {
7275 match self {
7276 Self::More(next) => Some(next),
7277 Self::Ended(last) => last,
7278 }
7279 }
7280}
7281
7282/// Where `connection`, reached from `after`, resumes — refused when it reports another page
7283/// with no cursor to it, or from a cursor that does not advance.
7284fn resumed(connection: &Value, after: Option<&str>) -> Result<Resumed, SourceError> {
7285 let info = connection
7286 .get("pageInfo")
7287 .ok_or_else(|| SourceError::Malformed {
7288 message: "GitHub connection has no pageInfo".into(),
7289 })?;
7290 let end = optional_str(info, "endCursor")?;
7291 if required_bool(info, "hasNextPage")? {
7292 let next = end.ok_or_else(|| SourceError::Malformed {
7293 message: "GitHub connection reports another page and no endCursor".into(),
7294 })?;
7295 validate_cursor_progress(after, next)?;
7296 return Ok(Resumed::More(next.to_owned()));
7297 }
7298 Ok(Resumed::Ended(
7299 end.map(str::to_owned).or_else(|| after.map(str::to_owned)),
7300 ))
7301}
7302
7303/// The board, and every item on it this source reports.
7304#[derive(Clone)]
7305struct Board {
7306 id: String,
7307 fields: Value,
7308 items: Vec<Resolved>,
7309}
7310
7311/// What a write needs of the board and nothing more: its node id and its field
7312/// definitions, in the shape a read of the board's own `fields` gives them.
7313///
7314/// Deliberately no items. A write decides which item it writes, which parent it files
7315/// under and which far ends it names by reading each of them by its own id; this is the
7316/// half of the board those reads cannot carry, and holding no item is what keeps it from
7317/// ever being asked whether an item is there.
7318#[derive(Clone)]
7319struct BoardFields {
7320 id: BoardId,
7321 fields: Value,
7322}
7323
7324/// A board's node id: what a field write and `addProjectV2ItemById` address.
7325///
7326/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
7327/// refused where it is read, and one an item names blank is read as not named at all.
7328#[derive(Clone)]
7329struct BoardId(String);
7330
7331/// Where one write left its item, for the record the rest of the command reads it out of.
7332///
7333/// A named record rather than a tuple because the update arm and the create arm each fill
7334/// all four, and two `Option`s of different meaning side by side in a tuple are two
7335/// positions a reader has to count.
7336struct Landed {
7337 /// The issue's own node id, which is the [`NativeId`] this source reports.
7338 content_id: NativeId,
7339 /// The board item's id, which is what a field write addresses.
7340 // 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.
7341 item_id: String,
7342 /// The web address GitHub gave the issue, when it gave one.
7343 // 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.
7344 url: Option<String>,
7345 /// The issue's number on its repository, when GitHub reported one.
7346 number: Option<u64>,
7347}
7348
7349impl BoardId {
7350 fn parse(id: &str) -> Result<Self, SourceError> {
7351 if id.trim().is_empty() {
7352 return Err(SourceError::Malformed {
7353 message: "GitHub named a board with a blank node id".into(),
7354 });
7355 }
7356 Ok(Self(id.to_owned()))
7357 }
7358
7359 fn as_str(&self) -> &str {
7360 &self.0
7361 }
7362}
7363
7364impl Board {
7365 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
7366 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
7367 let nodes = fields
7368 .get("nodes")
7369 .and_then(Value::as_array)
7370 .ok_or_else(|| SourceError::Malformed {
7371 message: "GitHub project fields.nodes is not an array".into(),
7372 })?;
7373 Ok(nodes
7374 .iter()
7375 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
7376 }
7377}
7378
7379/// One board item, resolved into everything this source reports about it.
7380#[derive(Clone)]
7381struct Resolved {
7382 item_id: String,
7383 id: NativeId,
7384 content_kind: ContentKind,
7385 kind: BoardKind,
7386 title: String,
7387 body: Option<String>,
7388 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
7389 /// that changes the slot alone has to keep byte for byte outside it.
7390 raw_body: Option<String>,
7391 status: Status,
7392 /// The name of the board `Status` option this item sits in, as the board spells it.
7393 option: Option<String>,
7394 /// What its `Priority` field says, read through this instance's mapping.
7395 priority: HeldPriority,
7396 /// Whether this item's issue is closed. A draft has no such state and is never closed.
7397 closed: bool,
7398 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
7399 delivers: Vec<TaskRef>,
7400 /// Every task that delivers this one, read out of its slot. Empty for anything not a
7401 /// task.
7402 delivered_by: Vec<TaskRef>,
7403 labels: Vec<Label>,
7404 parent: Option<NativeId>,
7405 // 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.
7406 origin: Option<String>,
7407 /// The issue's own number on its repository, as GitHub reports it.
7408 ///
7409 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
7410 /// declares none, and a draft is not filed in a repository to be numbered by one — and
7411 /// an issue this run created whose creating mutation answered without one, which is a
7412 /// response GitHub's own schema says cannot happen and which a landed write is not
7413 /// worth failing over. An `Issue` read off the board always has one.
7414 number: Option<u64>,
7415 url: Option<String>,
7416 created_at: Option<DateTime<Utc>>,
7417 updated_at: Option<DateTime<Utc>>,
7418 own_repository: Option<Repository>,
7419 repositories: Vec<Repository>,
7420 slot: BTreeMap<String, Value>,
7421 /// The node id of the board this item sits on, when the read that reached it said.
7422 board_id: Option<String>,
7423 /// The definition of every board field this item holds a value of, in the shape a read
7424 /// of the board's own `fields` gives one.
7425 ///
7426 /// Only the fields this item has a value in: a field it holds nothing of is not here,
7427 /// which says nothing about whether the board has it.
7428 fields: Vec<Value>,
7429}
7430
7431impl Resolved {
7432 /// The board this item's own read names it on, when that read named one this source can
7433 /// address.
7434 fn named_board(&self) -> Option<BoardId> {
7435 self.board_id
7436 .as_deref()
7437 .and_then(|id| BoardId::parse(id).ok())
7438 }
7439
7440 /// Whether this item holds a value of the board field called `name`, and so carries
7441 /// that field's definition. `false` says nothing about whether the board has the field.
7442 fn defines(&self, name: &str) -> bool {
7443 self.fields
7444 .iter()
7445 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
7446 }
7447
7448 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
7449 /// in a field of its own, and none of the five keys that are only an encoding.
7450 ///
7451 /// The two delivery keys are left out for every kind, not only for a task: they are
7452 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
7453 /// document carrying one holds nothing a caller's own metadata could mean by it.
7454 fn metadata(&self) -> BTreeMap<String, Value> {
7455 let mut metadata = self.slot.clone();
7456 metadata.remove(Repository::METADATA_KEY);
7457 metadata.remove(DependencyEdge::RECORDED_KEY);
7458 metadata.remove(ItemKind::METADATA_KEY);
7459 metadata.remove(TaskRef::DELIVERS_KEY);
7460 metadata.remove(TaskRef::DELIVERED_BY_KEY);
7461 // The board field is the origin, and the body's copy of it is only a mirror for the
7462 // issue search to find: an item whose field holds none has none, whatever its body
7463 // says, so no reader ever sees two answers.
7464 metadata.remove(ORIGIN_KEY);
7465 if let Some(origin) = &self.origin {
7466 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
7467 }
7468 metadata
7469 }
7470
7471 /// Where this item is, as a link a reader can open.
7472 ///
7473 /// A board is a hosted place and every issue on it has a web address, so that address
7474 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
7475 /// of place it is, so a reader knows to open it rather than to read a file out. It
7476 /// does not replace or derive from `url`: the field goes on reporting exactly what it
7477 /// reported before, and this says what that address *is*.
7478 ///
7479 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
7480 /// rather than a third variant, which is the contract's "the source did not say". An
7481 /// issue this run created is not one of those: its address comes back from the
7482 /// creating mutation, so it is somewhere a reader can open from the moment it exists
7483 /// rather than from whenever the board read catches up.
7484 fn location(&self) -> Option<Location> {
7485 self.url.clone().map(Location::Url)
7486 }
7487
7488 /// The short handle this board's backend shows people for a task: the issue's number
7489 /// alone, as a decimal string.
7490 ///
7491 /// The number alone rather than `owner/repo#1043`, because that is the contract's
7492 /// value for this backend. A draft has no number and so no handle, which is the
7493 /// contract's *absent* rather than a handle of some other shape — and the native
7494 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
7495 /// derives from.
7496 fn key(&self) -> Option<String> {
7497 self.number.map(|number| number.to_string())
7498 }
7499
7500 /// Whether its `Priority` field holds a value at all, mapped or not.
7501 fn holds_priority(&self) -> bool {
7502 self.priority != HeldPriority::Read(Priority::None)
7503 }
7504
7505 /// The task this item is.
7506 ///
7507 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
7508 /// reading that as a level would be a guess, and reading it as `none` would let the next
7509 /// copy clear a priority a person set.
7510 fn task(&self) -> Result<Task, SourceError> {
7511 let priority = match &self.priority {
7512 HeldPriority::Read(priority) => *priority,
7513 HeldPriority::Unmapped(option) => {
7514 return Err(SourceError::Malformed {
7515 message: format!(
7516 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
7517 this source's priority_mapping does not name, so its priority cannot be \
7518 read; next: name {option:?} under priority_mapping, or move the item to \
7519 a mapped option",
7520 self.id,
7521 self.number
7522 .map(|number| format!(" (#{number})"))
7523 .unwrap_or_default()
7524 ),
7525 });
7526 }
7527 };
7528 Ok(Task {
7529 id: self.id.clone(),
7530 key: self.key(),
7531 title: self.title.clone(),
7532 content: self.body.clone(),
7533 status: self.status.clone(),
7534 priority,
7535 labels: self.labels.clone(),
7536 project: self.parent.clone(),
7537 url: self.url.clone(),
7538 location: self.location(),
7539 created_at: self.created_at,
7540 updated_at: self.updated_at,
7541 metadata: self.metadata(),
7542 repositories: self.repositories.clone(),
7543 delivers: self.delivers.clone(),
7544 delivered_by: self.delivered_by.clone(),
7545 })
7546 }
7547
7548 fn project(&self) -> Project {
7549 Project {
7550 id: self.id.clone(),
7551 title: self.title.clone(),
7552 content: self.body.clone(),
7553 status: self.status.clone(),
7554 labels: self.labels.clone(),
7555 url: self.url.clone(),
7556 location: self.location(),
7557 created_at: self.created_at,
7558 updated_at: self.updated_at,
7559 metadata: self.metadata(),
7560 repositories: self.repositories.clone(),
7561 }
7562 }
7563
7564 /// The same issue as a document: the project it is filed under, and no status and no
7565 /// dependencies, because a document is not work.
7566 fn document(&self) -> Document {
7567 Document {
7568 id: self.id.clone(),
7569 title: self.title.clone(),
7570 content: self.body.clone(),
7571 project: self.parent.clone(),
7572 labels: self.labels.clone(),
7573 url: self.url.clone(),
7574 location: self.location(),
7575 created_at: self.created_at,
7576 updated_at: self.updated_at,
7577 metadata: self.metadata(),
7578 repositories: self.repositories.clone(),
7579 }
7580 }
7581}
7582
7583/// Where one targeted update moves an item's status, and which of its two halves move.
7584struct StatusMove {
7585 /// The board the item's `Status` field is on.
7586 board: BoardId,
7587 /// The `Status` field's id.
7588 field: String,
7589 /// The option's id.
7590 option: String,
7591 /// The option's name, as the board spells it.
7592 name: String,
7593 /// What the status asks of the issue's state.
7594 target: StatusTarget,
7595 /// The status the item reads as once it is there.
7596 landed: Status,
7597 /// Which of the status's two halves differ from what the item holds.
7598 moves: Moves,
7599}
7600
7601/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
7602/// closed state of its issue, or both. A status neither half of which differs is no move at all,
7603/// and is not a value of this type.
7604#[derive(Clone, Copy, PartialEq, Eq)]
7605enum Moves {
7606 /// The option alone.
7607 Option,
7608 /// The issue's state alone: open, closed, or closed with another reason.
7609 State,
7610 /// Both.
7611 Both,
7612}
7613
7614impl Moves {
7615 /// What differs, or `None` when nothing does.
7616 const fn of(option: bool, state: bool) -> Option<Self> {
7617 match (option, state) {
7618 (true, true) => Some(Self::Both),
7619 (true, false) => Some(Self::Option),
7620 (false, true) => Some(Self::State),
7621 (false, false) => None,
7622 }
7623 }
7624
7625 /// Whether the option moves.
7626 const fn option(self) -> bool {
7627 matches!(self, Self::Option | Self::Both)
7628 }
7629
7630 /// Whether the issue's state moves.
7631 const fn state(self) -> bool {
7632 matches!(self, Self::State | Self::Both)
7633 }
7634}
7635
7636/// What one write is, and the status that comes with being it.
7637///
7638/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
7639/// status and a task or a project always has one, so "a document carrying a status" and
7640/// "a task carrying none" are states a write cannot be in rather than states every use
7641/// site below has to defend against.
7642enum Written<'a> {
7643 /// A document, which is not work and so has no status at all.
7644 Document,
7645 /// A task or a project, and the status it is being written with.
7646 Work(ItemKind, &'a Status),
7647}
7648
7649impl Written<'_> {
7650 /// Which of the board's three kinds this write is.
7651 const fn kind(&self) -> BoardKind {
7652 match self {
7653 Self::Document => BoardKind::Document,
7654 Self::Work(kind, _) => BoardKind::Work(*kind),
7655 }
7656 }
7657
7658 /// The status this write carries. A document carries none, so a write of one says
7659 /// nothing about the issue's open or closed state and selects no board `Status`
7660 /// option.
7661 const fn status(&self) -> Option<&Status> {
7662 match self {
7663 Self::Document => None,
7664 Self::Work(_, status) => Some(status),
7665 }
7666 }
7667}
7668
7669/// The item being written, in the one shape all three write methods reach.
7670struct Incoming<'a> {
7671 written: Written<'a>,
7672 /// The title a person wrote. A document's goes onto the issue with
7673 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
7674 title: &'a str,
7675 content: Option<&'a str>,
7676 labels: &'a [Label],
7677 metadata: &'a BTreeMap<String, Value>,
7678 repositories: &'a [Repository],
7679 parent: Option<&'a NativeId>,
7680 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
7681 /// what keeps either key out of their slot.
7682 delivers: &'a [TaskRef],
7683 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
7684 delivered_by: &'a [TaskRef],
7685 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
7686 /// project, a document, and every write to an instance with no `priority_mapping` —
7687 /// which is what keeps such a write's requests exactly what they were before.
7688 priority: Option<Priority>,
7689}
7690
7691/// What one write does to an item's `Priority` field.
7692enum PriorityWrite {
7693 /// Select this option of this field.
7694 Select {
7695 /// The `Priority` field's id.
7696 field: String,
7697 /// The mapped option's id.
7698 option: String,
7699 },
7700 /// Clear the field's value, which is what `none` is.
7701 Clear {
7702 /// The `Priority` field's id.
7703 field: String,
7704 },
7705}
7706
7707impl Incoming<'_> {
7708 /// The title this write puts on the issue.
7709 fn written_title(&self) -> String {
7710 match self.written {
7711 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
7712 Written::Work(..) => self.title.to_owned(),
7713 }
7714 }
7715}
7716
7717#[derive(Clone, Copy, PartialEq, Eq)]
7718enum ContentKind {
7719 DraftIssue,
7720 Issue,
7721}
7722
7723/// What one board issue is: a document, or the work an [`ItemKind`] names.
7724///
7725/// A type of this source's own rather than an `ItemKind` with a third variant, because
7726/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
7727/// document — the contract keeps a document out of that enum deliberately. Holding the
7728/// board's three answers in one value is what makes every place that asks "which is this?"
7729/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
7730/// two thirds of the board.
7731#[derive(Clone, Copy, PartialEq, Eq)]
7732enum BoardKind {
7733 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
7734 Document,
7735 /// Every other issue, and every draft.
7736 Work(ItemKind),
7737}
7738
7739impl BoardKind {
7740 /// How a refusal names this kind to the person reading it.
7741 const fn describes(self) -> &'static str {
7742 match self {
7743 Self::Document => "document",
7744 Self::Work(kind) => kind.marker(),
7745 }
7746 }
7747}
7748
7749/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
7750///
7751/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
7752/// the shared cross-source journeys assert one answer to one question, so two sources
7753/// that disagree about what "carries the label bug" means fail them.
7754fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
7755 let holds = |name: &String| {
7756 labels
7757 .iter()
7758 .any(|label| label.name.eq_ignore_ascii_case(name))
7759 };
7760 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
7761 && filter.all_of.iter().all(holds)
7762 && !filter.none_of.iter().any(holds)
7763}
7764
7765/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
7766/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
7767fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
7768 statuses.is_empty() || statuses.contains(&category)
7769}
7770
7771/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
7772///
7773/// `content` is the item's own prose — the body with this source's trailing metadata
7774/// comment already taken off — so a search never matches an encoding the author of the
7775/// issue never wrote.
7776fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
7777 let terms = query.terms.to_lowercase();
7778 let in_title = title.to_lowercase().contains(&terms);
7779 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
7780 match query.fields {
7781 TextFields::Title => in_title,
7782 TextFields::Content => in_content,
7783 TextFields::TitleOrContent => in_title || in_content,
7784 }
7785}
7786
7787/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
7788///
7789/// The project predicate is passed separately because a read narrowed to one project has
7790/// already answered it by asking *that project* for its own items — and re-applying it
7791/// there would compare the caller's selector, which may be a project's **name**, against
7792/// the id of the project that name resolved to, and keep nothing. Every other read passes
7793/// `query.project` and applies it here, which is what keeps `projects` a predicate this
7794/// source really does apply.
7795fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
7796 labels_match(&task.labels, &query.labels)
7797 && status_matches(task.status.category, &query.statuses)
7798 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
7799 && match project {
7800 ProjectFilter::Any => true,
7801 ProjectFilter::Orphans => task.project.is_none(),
7802 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
7803 }
7804 && query
7805 .text
7806 .as_ref()
7807 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
7808 // Against the parsed metadata slot, and against the origin field, which is where
7809 // `Resolved::metadata` reads each of them from.
7810 && query.metadata_matches(&task.metadata)
7811 && query.origin_matches(&task.metadata)
7812}
7813
7814fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
7815 labels_match(&project.labels, &query.labels)
7816 && status_matches(project.status.category, &query.statuses)
7817 && query
7818 .text
7819 .as_ref()
7820 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
7821}
7822
7823/// The same three predicates a task query carries, minus the status filter.
7824///
7825/// A document is not work, so it has no status for one to compare against and the query
7826/// type carries none. The project predicate is the same one — a design issue filed under a
7827/// project issue is in that project, and one filed under nothing is in none — so it is
7828/// spelled the same way here rather than answered differently.
7829fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
7830 labels_match(&document.labels, &query.labels)
7831 && match project {
7832 ProjectFilter::Any => true,
7833 ProjectFilter::Orphans => document.project.is_none(),
7834 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
7835 }
7836 && query
7837 .text
7838 .as_ref()
7839 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
7840}
7841
7842#[async_trait::async_trait]
7843impl TaskSource for GitHubProjectsSource {
7844 fn kind(&self) -> &'static str {
7845 KIND
7846 }
7847 fn capabilities(&self) -> Capabilities {
7848 Capabilities {
7849 projects: Support::Native,
7850 documents: Support::Native,
7851 comments: Support::Native,
7852 priority: if self.priorities.is_some() {
7853 Support::Native
7854 } else {
7855 Support::Unsupported
7856 },
7857 filter_by_priority: Support::Native,
7858 filter_by_comment_activity: Support::Native,
7859 filter_by_metadata: Support::Native,
7860 filter_by_origin: Support::Native,
7861 orphan_tasks: Support::Native,
7862 filter_by_label: Support::Native,
7863 filter_by_status: Support::Native,
7864 search_title: Support::Native,
7865 search_content: Support::Native,
7866 task_dependencies: DependencySupport::BothDirections,
7867 project_dependencies: DependencySupport::BothDirections,
7868 max_page_size: MAX_PAGE_SIZE,
7869 }
7870 }
7871 async fn health(&self) -> Result<Health, SourceError> {
7872 let board = self.board_page(None, 1).await?;
7873 Ok(Health {
7874 reachable: true,
7875 detail: Some(format!(
7876 "reading GitHub project {}/{} ({})",
7877 self.owner,
7878 self.project_number,
7879 required_str(&board, "title")?
7880 )),
7881 })
7882 }
7883 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
7884 self.item_by_id(id)
7885 .await?
7886 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7887 .map(|item| item.task())
7888 .transpose()
7889 }
7890 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
7891 Ok(self
7892 .item_by_id(id)
7893 .await?
7894 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7895 .map(|item| item.project()))
7896 }
7897 async fn query_tasks(
7898 &self,
7899 query: &TaskQuery,
7900 page: &PageRequest,
7901 ) -> Result<Page<Task>, SourceError> {
7902 validate_page(page)?;
7903 refuse_unsearchable(query)?;
7904 if query.origin.is_none() && !matches!(query.project, ProjectFilter::Is(_)) {
7905 let qualifiers = match (narrowing_qualifiers(query), query.commented_since) {
7906 (Some(also), Some(since)) => Some(format!("{} {also}", updated_qualifier(since))),
7907 (Some(also), None) => Some(also),
7908 (None, Some(since)) => Some(updated_qualifier(since)),
7909 (None, None) => None,
7910 };
7911 if let Some(also) = qualifiers {
7912 return self.search_tasks(query, page, &also).await;
7913 }
7914 }
7915
7916 // A read narrowed to one project asks that project for its own tasks, so nothing
7917 // about it costs what the rest of the board holds. A read carrying a text, metadata
7918 // or origin predicate asks GitHub the narrower question those predicates are, and a
7919 // read narrowed to comment activity alone asks the board's own issue search for the
7920 // issues updated since, which is every issue a comment could have been written or
7921 // edited on since. Every other task read is a question about the whole board and is
7922 // answered by reading it.
7923 let (held, membership) = match (&query.project, query.commented_since) {
7924 (ProjectFilter::Is(project), _) => (
7925 self.project_children(project).await?,
7926 // Answered by where these items came from; see `task_matches`.
7927 &ProjectFilter::Any,
7928 ),
7929 (ProjectFilter::Any | ProjectFilter::Orphans, since) => {
7930 match (self.narrowed(query).await?, since) {
7931 (Some(narrowed), _) => (narrowed, &query.project),
7932 (None, Some(since)) => (self.updated_since(since).await?, &query.project),
7933 (None, None) => (self.board().await?.items, &query.project),
7934 }
7935 }
7936 };
7937 // Filtered before paged: a page of a filtered result is a page of the survivors,
7938 // never the survivors of a page.
7939 let mut tasks = Vec::new();
7940 for item in held
7941 .iter()
7942 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
7943 {
7944 let task = item.task()?;
7945 if task_matches(&task, query, membership)
7946 && self.commented_since(item, query.commented_since).await?
7947 {
7948 tasks.push(task);
7949 }
7950 }
7951 Ok(offset_page(
7952 tasks,
7953 numeric_cursor(page.cursor.as_ref())?,
7954 page.limit.min(MAX_PAGE_SIZE) as usize,
7955 ))
7956 }
7957 async fn query_projects(
7958 &self,
7959 query: &ProjectQuery,
7960 page: &PageRequest,
7961 ) -> Result<Page<Project>, SourceError> {
7962 validate_page(page)?;
7963 // The projects a board holds are found by an issue search scoped to that board,
7964 // never by walking the board's own item connection: what tells a project from a
7965 // task is the `parent` each issue carries, which costs nothing to read.
7966 let projects = self
7967 .board_issues()
7968 .await?
7969 .iter()
7970 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7971 .map(Resolved::project)
7972 .filter(|project| project_matches(project, query))
7973 .collect();
7974 Ok(offset_page(
7975 projects,
7976 numeric_cursor(page.cursor.as_ref())?,
7977 page.limit.min(MAX_PAGE_SIZE) as usize,
7978 ))
7979 }
7980 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
7981 Ok(self
7982 .item_by_id(id)
7983 .await?
7984 .filter(|item| item.kind == BoardKind::Document)
7985 .map(|item| item.document()))
7986 }
7987 async fn query_documents(
7988 &self,
7989 query: &DocumentQuery,
7990 page: &PageRequest,
7991 ) -> Result<Page<Document>, SourceError> {
7992 validate_page(page)?;
7993 // Narrowed to one project, this is the same sub-issue read a task list scoped to
7994 // that project makes — a document filed under a project is a sub-issue of it too,
7995 // and which of them come back is the kind this caller asked for.
7996 let (held, membership) = match &query.project {
7997 ProjectFilter::Is(project) => (
7998 self.project_children(project).await?,
7999 // Answered by where these items came from; see `task_matches`.
8000 &ProjectFilter::Any,
8001 ),
8002 ProjectFilter::Any | ProjectFilter::Orphans => {
8003 (self.board().await?.items, &query.project)
8004 }
8005 };
8006 // Filtered before paged, exactly as a task read is: a page of a filtered result is
8007 // a page of the survivors, never the survivors of a page.
8008 let documents = held
8009 .iter()
8010 .filter(|item| item.kind == BoardKind::Document)
8011 .map(Resolved::document)
8012 .filter(|document| document_matches(document, query, membership))
8013 .collect();
8014 Ok(offset_page(
8015 documents,
8016 numeric_cursor(page.cursor.as_ref())?,
8017 page.limit.min(MAX_PAGE_SIZE) as usize,
8018 ))
8019 }
8020 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
8021 validate_page(page)?;
8022 let offset = numeric_cursor(page.cursor.as_ref())?;
8023 let mut labels = self
8024 .board()
8025 .await?
8026 .items
8027 .into_iter()
8028 .flat_map(|item| item.labels)
8029 .fold(Vec::new(), |mut all, label| {
8030 if !all.iter().any(|x: &Label| x.id == label.id) {
8031 all.push(label);
8032 }
8033 all
8034 });
8035 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
8036 Ok(offset_page(
8037 labels,
8038 offset,
8039 page.limit.min(MAX_PAGE_SIZE) as usize,
8040 ))
8041 }
8042 async fn task_dependencies(
8043 &self,
8044 id: &NativeId,
8045 direction: Direction,
8046 page: &PageRequest,
8047 ) -> Result<Page<DependencyEdge>, SourceError> {
8048 self.dependencies(id, ItemKind::Task, direction, page).await
8049 }
8050 async fn project_dependencies(
8051 &self,
8052 id: &NativeId,
8053 direction: Direction,
8054 page: &PageRequest,
8055 ) -> Result<Page<DependencyEdge>, SourceError> {
8056 self.dependencies(id, ItemKind::Project, direction, page)
8057 .await
8058 }
8059
8060 fn writes(&self) -> WriteSupport {
8061 WriteSupport::Supported
8062 }
8063
8064 /// Create or update one task.
8065 ///
8066 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
8067 /// neither may name the task itself or name one task twice — and land in the body's
8068 /// metadata slot under their reserved keys, in place of any caller metadata of those
8069 /// names.
8070 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
8071 let near = write.target.as_ref().unwrap_or(&write.item.id);
8072 for (key, entries) in [
8073 (TaskRef::DELIVERS_KEY, &write.item.delivers),
8074 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
8075 ] {
8076 TaskRef::listed(key, near, Some(&self.name), entries.clone())
8077 .map_err(|message| SourceError::Refused { message })?;
8078 }
8079 if self.priorities.is_none() && write.item.priority != Priority::None {
8080 return Err(self.holds_no_priority());
8081 }
8082 self.write_item(
8083 &Incoming {
8084 written: Written::Work(ItemKind::Task, &write.item.status),
8085 title: &write.item.title,
8086 content: write.item.content.as_deref(),
8087 labels: &write.item.labels,
8088 metadata: &write.item.metadata,
8089 repositories: &write.item.repositories,
8090 parent: write.item.project.as_ref(),
8091 delivers: &write.item.delivers,
8092 delivered_by: &write.item.delivered_by,
8093 priority: self.priorities.as_ref().map(|_| write.item.priority),
8094 },
8095 write.target.as_ref(),
8096 &write.depends_on,
8097 )
8098 .await
8099 }
8100
8101 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
8102 self.write_item(
8103 &Incoming {
8104 written: Written::Work(ItemKind::Project, &write.item.status),
8105 title: &write.item.title,
8106 content: write.item.content.as_deref(),
8107 labels: &write.item.labels,
8108 metadata: &write.item.metadata,
8109 repositories: &write.item.repositories,
8110 parent: None,
8111 delivers: &[],
8112 delivered_by: &[],
8113 priority: None,
8114 },
8115 write.target.as_ref(),
8116 &write.depends_on,
8117 )
8118 .await
8119 }
8120
8121 /// Create or update one document, which is one issue titled the way this board spells
8122 /// a document.
8123 ///
8124 /// Everything else is exactly a task write: caller metadata goes to the same canonical
8125 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
8126 /// or a field this board cannot carry is refused by name rather than dropped, a target
8127 /// naming an issue this board does not hold is refused rather than created, and an
8128 /// issue this call created is taken back when the rest of the write fails.
8129 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
8130 // A document takes part in no dependency graph, so there is no far end to write
8131 // natively and none to record: a caller naming one is told so rather than having it
8132 // stored under the reserved key, where a later read would report an edge the
8133 // contract says cannot exist.
8134 if !write.depends_on.is_empty() {
8135 return Err(SourceError::Refused {
8136 message: format!(
8137 "this write names {} dependencies for a document, and a document takes \
8138 part in no dependency graph; next: put the dependency on the task or \
8139 project the document is about",
8140 write.depends_on.len()
8141 ),
8142 });
8143 }
8144 self.write_item(
8145 &Incoming {
8146 written: Written::Document,
8147 title: &write.item.title,
8148 content: write.item.content.as_deref(),
8149 labels: &write.item.labels,
8150 metadata: &write.item.metadata,
8151 repositories: &write.item.repositories,
8152 parent: write.item.project.as_ref(),
8153 delivers: &[],
8154 delivered_by: &[],
8155 priority: None,
8156 },
8157 write.target.as_ref(),
8158 &[],
8159 )
8160 .await
8161 }
8162
8163 /// Set one task's status alone.
8164 ///
8165 /// An open target reopens a closed issue with an `updateIssue` carrying only its
8166 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
8167 /// terminal target selects its mapped option, then closes with its fixed reason. No
8168 /// request carries a title, a body or a label. The status
8169 /// answered is what [`StatusMapping::status`] reads off the state just written, which is
8170 /// what a re-read reports.
8171 async fn set_task_status(
8172 &self,
8173 id: &NativeId,
8174 category: StatusCategory,
8175 ) -> Result<Option<Status>, SourceError> {
8176 self.set_status(id, category).await
8177 }
8178
8179 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
8180 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
8181 /// for `none`. Refused by an instance with no `priority_mapping`.
8182 async fn set_task_priority(
8183 &self,
8184 id: &NativeId,
8185 priority: Priority,
8186 ) -> Result<Option<Priority>, SourceError> {
8187 self.set_priority(id, priority).await
8188 }
8189
8190 /// Replace one task's content with a single body update that keeps the metadata slot
8191 /// byte for byte.
8192 async fn set_task_content(
8193 &self,
8194 id: &NativeId,
8195 content: &str,
8196 ) -> Result<Option<()>, SourceError> {
8197 self.replace_content(id, content).await
8198 }
8199
8200 /// Replace one task issue's content and its provenance slot entry with a single body
8201 /// update. The answers are not kept: see `replace_rendering`.
8202 async fn set_task_rendering(
8203 &self,
8204 id: &NativeId,
8205 content: &str,
8206 provenance: &Value,
8207 _answers: &BTreeMap<String, Value>,
8208 ) -> Result<Option<()>, SourceError> {
8209 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
8210 .await
8211 }
8212
8213 /// Replace one design-document issue's content and its provenance slot entry, on exactly
8214 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
8215 async fn set_document_rendering(
8216 &self,
8217 id: &NativeId,
8218 content: &str,
8219 provenance: &Value,
8220 _answers: &BTreeMap<String, Value>,
8221 ) -> Result<Option<()>, SourceError> {
8222 self.replace_rendering(id, BoardKind::Document, content, provenance)
8223 .await
8224 }
8225
8226 /// Apply a targeted update with one read of the item and a write only for what differs:
8227 /// at most one `updateIssue` for title, body and state, one field write each for `Status`
8228 /// and `Priority`, and the `blockedBy` difference. See `targeted_update`.
8229 async fn update_task(
8230 &self,
8231 id: &NativeId,
8232 update: &TaskUpdate,
8233 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
8234 self.targeted_update(id, update).await
8235 }
8236
8237 /// Replace one task's `delivered_by` with a single body update that changes the
8238 /// metadata slot and nothing outside it.
8239 async fn set_delivered_by(
8240 &self,
8241 id: &NativeId,
8242 delivered_by: &[TaskRef],
8243 ) -> Result<Option<()>, SourceError> {
8244 self.replace_delivered_by(id, delivered_by).await
8245 }
8246
8247 /// Set one key of one task issue's metadata with a single body update that changes the
8248 /// metadata slot and nothing outside it — no title, label, state or board field request —
8249 /// and sends nothing when the task already holds that value under the key.
8250 async fn set_task_metadata(
8251 &self,
8252 id: &NativeId,
8253 key: &MetadataKey,
8254 value: &Value,
8255 ) -> Result<Option<Task>, SourceError> {
8256 Ok(self
8257 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
8258 .await?
8259 .map(|item| item.task())
8260 .transpose()?)
8261 }
8262
8263 /// Set one key of one project issue's metadata, on exactly the terms of
8264 /// [`set_task_metadata`](TaskSource::set_task_metadata).
8265 async fn set_project_metadata(
8266 &self,
8267 id: &NativeId,
8268 key: &MetadataKey,
8269 value: &Value,
8270 ) -> Result<Option<Project>, SourceError> {
8271 Ok(self
8272 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
8273 .await?
8274 .map(|item| item.project()))
8275 }
8276
8277 /// Set one key of one design-document issue's metadata, on exactly the terms of
8278 /// [`set_task_metadata`](TaskSource::set_task_metadata).
8279 async fn set_document_metadata(
8280 &self,
8281 id: &NativeId,
8282 key: &MetadataKey,
8283 value: &Value,
8284 ) -> Result<Option<Document>, SourceError> {
8285 Ok(self
8286 .set_slot_key(id, BoardKind::Document, key, value)
8287 .await?
8288 .map(|item| item.document()))
8289 }
8290
8291 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
8292 self.delete_item(id).await
8293 }
8294
8295 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
8296 self.delete_item(id).await
8297 }
8298
8299 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
8300 self.delete_item(id).await
8301 }
8302
8303 /// One page of the task issue's own comments, walked by GitHub's own cursor.
8304 ///
8305 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
8306 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
8307 async fn task_comments(
8308 &self,
8309 task: &NativeId,
8310 page: &PageRequest,
8311 ) -> Result<Option<Page<Comment>>, SourceError> {
8312 validate_page(page)?;
8313 let Some(issue) = self.commented_issue(task).await? else {
8314 return Ok(None);
8315 };
8316 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
8317 let data = self
8318 .graphql(
8319 graphql::ISSUE_COMMENTS,
8320 json!({"id":issue.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after}),
8321 )
8322 .await?;
8323 // The issue was there a moment ago; one removed since is no longer a task here.
8324 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
8325 return Ok(None);
8326 };
8327 let connection = node
8328 .get("comments")
8329 .filter(|value| !value.is_null())
8330 .ok_or_else(|| SourceError::Malformed {
8331 message: format!(
8332 "GitHub issue {} answered with no comments connection",
8333 issue.0
8334 ),
8335 })?;
8336 let items = optional_nodes(Some(connection), "issue comments")?
8337 .into_iter()
8338 .flatten()
8339 .map(comment_from)
8340 .collect::<Result<Vec<_>, _>>()?;
8341 let next = next_cursor(connection)?;
8342 if let Some(next) = &next {
8343 validate_cursor_progress(after, &next.0)?;
8344 }
8345 Ok(Some(Page { items, next }))
8346 }
8347
8348 /// Add one comment to the task's issue, as the account the token belongs to.
8349 ///
8350 /// The author is refused before anything is sent — not even the task is read — because
8351 /// no answer GitHub could give would make posting under another name than the one asked
8352 /// for the right outcome.
8353 async fn add_comment(
8354 &self,
8355 task: &NativeId,
8356 comment: &NewComment,
8357 ) -> Result<Option<Comment>, SourceError> {
8358 if let Some(author) = &comment.author {
8359 return Err(SourceError::Refused {
8360 message: format!(
8361 "source {} cannot post a comment as {author:?}: GitHub records the account \
8362 the token signs in as the author of every comment; next: leave --author \
8363 out, and the comment is posted as that account",
8364 self.name
8365 ),
8366 });
8367 }
8368 let Some(issue) = self.commented_issue(task).await? else {
8369 return Ok(None);
8370 };
8371 let data = self
8372 .graphql(
8373 graphql::ADD_COMMENT,
8374 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
8375 )
8376 .await?;
8377 let subject = data
8378 .pointer("/addComment/subject")
8379 .filter(|value| !value.is_null())
8380 .ok_or_else(|| SourceError::Malformed {
8381 message: "GitHub comment addition returned no subject".into(),
8382 })?;
8383 if required_str(subject, "id")? != issue.0 {
8384 return Err(SourceError::Malformed {
8385 message: "GitHub comment addition answered about another issue".into(),
8386 });
8387 }
8388 let added = data
8389 .pointer("/addComment/commentEdge/node")
8390 .filter(|value| !value.is_null())
8391 .ok_or_else(|| SourceError::Malformed {
8392 message: "GitHub comment addition returned no comment".into(),
8393 })?;
8394 comment_from(added).map(Some)
8395 }
8396
8397 async fn edit_comment(
8398 &self,
8399 task: &NativeId,
8400 comment: &NativeId,
8401 body: &CommentBody,
8402 ) -> Result<Option<Comment>, SourceError> {
8403 let Some(issue) = self.commented_issue(task).await? else {
8404 return Ok(None);
8405 };
8406 if !self.comment_is_on(&issue, comment).await? {
8407 return Ok(None);
8408 }
8409 let data = self
8410 .graphql(
8411 graphql::UPDATE_COMMENT,
8412 json!({"input":{"id":comment.0,"body":body.as_str()}}),
8413 )
8414 .await?;
8415 let edited = data
8416 .pointer("/updateIssueComment/issueComment")
8417 .filter(|value| !value.is_null())
8418 .ok_or_else(|| SourceError::Malformed {
8419 message: "GitHub comment update returned no comment".into(),
8420 })?;
8421 let edited = comment_from(edited)?;
8422 if edited.id != *comment {
8423 return Err(SourceError::Malformed {
8424 message: "GitHub comment update returned the wrong comment".into(),
8425 });
8426 }
8427 Ok(Some(edited))
8428 }
8429
8430 async fn delete_comment(
8431 &self,
8432 task: &NativeId,
8433 comment: &NativeId,
8434 ) -> Result<Option<NativeId>, SourceError> {
8435 let Some(issue) = self.commented_issue(task).await? else {
8436 return Ok(None);
8437 };
8438 if !self.comment_is_on(&issue, comment).await? {
8439 return Ok(None);
8440 }
8441 let data = self
8442 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
8443 .await?;
8444 // The payload says nothing about the comment it removed, so what is checked is that
8445 // GitHub answered the mutation at all rather than leaving it unanswered.
8446 data.get("deleteIssueComment")
8447 .filter(|value| !value.is_null())
8448 .ok_or_else(|| SourceError::Malformed {
8449 message: "GitHub comment deletion returned no payload".into(),
8450 })?;
8451 Ok(Some(comment.clone()))
8452 }
8453
8454 /// Every request this source has recorded, and what each of GitHub's two budgets was
8455 /// attributed — read off the same accounting the session report is rendered from, so
8456 /// the two cannot count one request two ways.
8457 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
8458 Ok(Some(self.ledger.snapshot().metering()))
8459 }
8460}
8461
8462/// One issue comment as the contract carries it.
8463///
8464/// `author` is absent both when GitHub answers `null` for an account that no longer exists
8465/// and when it answers an actor with no login, because either way the source did not say who
8466/// wrote it — which is what an absent author means, rather than an author called nothing.
8467fn comment_from(value: &Value) -> Result<Comment, SourceError> {
8468 Ok(Comment {
8469 id: NativeId(required_str(value, "id")?.to_owned()),
8470 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
8471 .map(str::to_owned),
8472 created_at: optional_time(value, "createdAt")?,
8473 updated_at: optional_time(value, "updatedAt")?,
8474 body: required_str(value, "body")?.to_owned(),
8475 url: optional_str(value, "url")?.map(str::to_owned),
8476 })
8477}
8478
8479/// Where the recorded tail of a dependency walk resumes; see
8480/// [`GitHubProjectsSource::recorded_edges`].
8481const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
8482
8483/// The board text field this source keeps a copy's origin in.
8484///
8485/// Named after the key it holds, and held to that name by the guard below rather than by
8486/// a reader noticing.
8487const ORIGIN_FIELD: &str = "onetaskgraph.origin";
8488
8489/// The metadata key that field holds.
8490///
8491/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
8492/// constructs or interprets the qualified id it carries. This source names it only to
8493/// route it — a short, typed value belongs in a typed field rather than in the body slot
8494/// a caller's own prose shares.
8495///
8496/// Restated rather than imported, because no plugin crate may depend on the engine. What
8497/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
8498/// target in `check`: it reads the engine's own literal and fails naming the file and the
8499/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
8500/// that creates a second item every run instead of finding the one it wrote — and that is
8501/// too late to learn it.
8502const ORIGIN_KEY: &str = "onetaskgraph.origin";
8503
8504/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
8505///
8506/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
8507/// is derived from the far end, never written down on the near item — so only a forward
8508/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
8509/// it did not come from, and it is told so rather than answered with an empty page that
8510/// reads as a walk which ended.
8511fn recorded_offset(
8512 cursor: Option<&str>,
8513 direction: Direction,
8514) -> Result<Option<usize>, SourceError> {
8515 cursor
8516 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
8517 .map(|offset| {
8518 if direction != Direction::DependsOn {
8519 return Err(SourceError::Config {
8520 message: format!(
8521 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
8522 reverse dependency read never issues; resume it in the direction \
8523 that reported it"
8524 ),
8525 });
8526 }
8527 offset.parse().map_err(|_| SourceError::Config {
8528 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
8529 })
8530 })
8531 .transpose()
8532}
8533
8534fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
8535 let mut page = offset_page(edges, offset, limit.max(1));
8536 page.next = page
8537 .next
8538 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
8539 page
8540}
8541
8542/// The kind of one issue reached through a dependency connection.
8543///
8544/// The same questions the board scan asks, over the fields the dependency document
8545/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
8546/// then anything with sub-issues or the marker is a project.
8547///
8548/// # Errors
8549///
8550/// A far end this board holds as a document is refused rather than reported. The two
8551/// answers that are not refusals would both be wrong: reporting it as a task names an id
8552/// no task read of this source can find, and reporting it as a project names one no
8553/// project read can. There is no third value to return — `ItemKind` has no document
8554/// variant, because nothing may point at a document — so the relationship itself is what
8555/// the person is told about.
8556fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
8557 let id = required_str(value, "id")?;
8558 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
8559 return Err(SourceError::Refused {
8560 message: format!(
8561 "GitHub issue {id} is a document of this board — its title begins \
8562 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
8563 on by one; next: remove that issue's blocking relationship on this board"
8564 ),
8565 });
8566 }
8567 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
8568 if parent.is_some() {
8569 return Ok(ItemKind::Task);
8570 }
8571 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
8572 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
8573 message: format!("GitHub issue {id}: {message}"),
8574 })?;
8575 let sub_issues = sub_issue_total(value)?;
8576 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
8577 ItemKind::Project
8578 } else {
8579 ItemKind::Task
8580 })
8581}
8582
8583/// The `IssueStateUpdateInput` one status target asks for.
8584///
8585/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
8586/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
8587/// a currently-closed issue: without that the item would read back `Unknown` and a copy
8588/// would report a change forever. A document has no status at all, and asks for neither.
8589fn state_input(target: Option<&StatusTarget>) -> Value {
8590 match target {
8591 Some(StatusTarget::Terminal(_, reason)) => {
8592 json!({"value":"CLOSED","stateReason":reason.reason()})
8593 }
8594 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
8595 // A document has no status, so a write of one says nothing about the issue's open
8596 // or closed state rather than forcing it open: `stateInput` is what carries that
8597 // instruction, and an explicit null asks for no change to it.
8598 None => Value::Null,
8599 }
8600}
8601
8602/// The metadata one write stores in the item's body slot.
8603///
8604/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
8605/// rather than carried: the kind marker so an empty project stays readable, the
8606/// repository list only when it is not exactly the issue's own repository, and the far
8607/// ends no relationship here can name.
8608///
8609/// The copy origin is the one typed field that is also mirrored here, and only as a
8610/// mirror: it lands in the board's origin field as well, which stays the one every reader
8611/// takes it from, and it is here so that GitHub's issue search — which indexes this comment
8612/// and catches up with a write in seconds rather than minutes — can find the item by it.
8613/// A reader of the release before this one drops the slot's copy and reads the field, so an
8614/// item written here still reads with exactly one origin there.
8615fn slot_metadata(
8616 incoming: &Incoming<'_>,
8617 own_repository: Option<&Repository>,
8618 fallback: &[DependencyEdge],
8619) -> BTreeMap<String, Value> {
8620 let mut metadata = incoming.metadata.clone();
8621 match metadata.remove(ORIGIN_KEY) {
8622 Some(Value::String(origin)) if !origin.is_empty() => {
8623 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin));
8624 }
8625 _ => {}
8626 }
8627 match incoming.written.kind() {
8628 BoardKind::Work(kind) => metadata.insert(
8629 ItemKind::METADATA_KEY.to_owned(),
8630 Value::String(kind.marker().to_owned()),
8631 ),
8632 // A document is told by its title, so it carries no kind marker: that key names
8633 // what a dependency endpoint points at, and nothing may point at a document.
8634 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
8635 };
8636 let derivable = own_repository
8637 .map(|own| incoming.repositories == [own.clone()])
8638 .unwrap_or(incoming.repositories.is_empty());
8639 if derivable {
8640 metadata.remove(Repository::METADATA_KEY);
8641 } else {
8642 metadata.insert(
8643 Repository::METADATA_KEY.to_owned(),
8644 Value::Array(
8645 incoming
8646 .repositories
8647 .iter()
8648 .map(|repository| Value::String(repository.as_str().to_owned()))
8649 .collect(),
8650 ),
8651 );
8652 }
8653 // The typed lists are what land, whatever the caller's own metadata held under their
8654 // keys: a key of either name travelling beside the field would otherwise be a second
8655 // answer to the same question, and the field is the one the contract names.
8656 for (key, entries) in [
8657 (TaskRef::DELIVERS_KEY, incoming.delivers),
8658 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
8659 ] {
8660 set_task_list(&mut metadata, key, entries);
8661 }
8662 record_edges(&mut metadata, fallback);
8663 metadata
8664}
8665
8666/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
8667/// one slot's metadata, or no such key when there are none.
8668fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
8669 if fallback.is_empty() {
8670 metadata.remove(DependencyEdge::RECORDED_KEY);
8671 } else {
8672 metadata.insert(
8673 DependencyEdge::RECORDED_KEY.to_owned(),
8674 Value::Array(
8675 fallback
8676 .iter()
8677 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
8678 .collect(),
8679 ),
8680 );
8681 }
8682}
8683
8684/// Every label one item carries, from its content's own connection and nowhere else.
8685///
8686/// There is no second place to read one from: no document this source sends selects the
8687/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
8688/// cannot carry one at all. The module documentation records the three schema facts that
8689/// settle it.
8690fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
8691 optional_nodes(content.get("labels"), "content labels")?
8692 .into_iter()
8693 .flatten()
8694 .map(|v| {
8695 Ok(Label {
8696 id: NativeId(required_str(v, "id")?.to_owned()),
8697 name: required_str(v, "name")?.to_owned(),
8698 color: optional_str(v, "color")?.map(str::to_owned),
8699 })
8700 })
8701 .collect()
8702}
8703
8704/// The definition of each board field one item's values are values of, in the shape a read
8705/// of the board's own `fields` gives one.
8706///
8707/// A value names its field through a fragment on that field's own type, so the type is
8708/// known from which kind of value it is: a single-select value's field is a
8709/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
8710/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
8711fn field_definitions(field_values: &[Value]) -> Vec<Value> {
8712 field_values
8713 .iter()
8714 .filter_map(|value| {
8715 let field = value.get("field")?.as_object()?;
8716 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
8717 let typename = if value.get("text").is_some() {
8718 "ProjectV2Field"
8719 } else if value.get("name").is_some() {
8720 "ProjectV2SingleSelectField"
8721 } else {
8722 return None;
8723 };
8724 let mut defined = field.clone();
8725 defined.insert("__typename".to_owned(), json!(typename));
8726 Some(Value::Object(defined))
8727 })
8728 .collect()
8729}
8730
8731fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
8732 let Some(node) = field_values
8733 .iter()
8734 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
8735 else {
8736 return Ok(None);
8737 };
8738 Ok(optional_str(node, "text")?.map(str::to_owned))
8739}
8740
8741fn valid_github_owner(owner: &str) -> bool {
8742 !owner.is_empty()
8743 && owner.len() <= 39
8744 && !owner.starts_with('-')
8745 && !owner.ends_with('-')
8746 && !owner.contains("--")
8747 && owner
8748 .bytes()
8749 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
8750}
8751
8752/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
8753/// neither of the two names a path segment already means.
8754fn valid_github_repository_name(name: &str) -> bool {
8755 !name.is_empty()
8756 && name.len() <= 100
8757 && name != "."
8758 && name != ".."
8759 && name
8760 .bytes()
8761 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
8762}
8763
8764fn valid_environment_name(name: &str) -> bool {
8765 let mut bytes = name.bytes();
8766 bytes
8767 .next()
8768 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
8769 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
8770}
8771
8772/// How many sub-issues one issue has.
8773///
8774/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
8775/// absent or non-integer one is a response this source cannot read — and reading it as
8776/// zero would classify a project as a task, which is exactly the mistake the marker
8777/// exists to keep from happening quietly.
8778fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
8779 let summary = issue
8780 .get("subIssuesSummary")
8781 .ok_or_else(|| SourceError::Malformed {
8782 message: "GitHub issue is missing subIssuesSummary".into(),
8783 })?;
8784 summary
8785 .get("total")
8786 .and_then(Value::as_u64)
8787 .ok_or_else(|| SourceError::Malformed {
8788 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
8789 })
8790}
8791
8792/// One issue's own `number`.
8793///
8794/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
8795/// an issue in this module asks for it. So a read of one that comes back without it, or
8796/// with something that is not an unsigned integer, is a response this source cannot read —
8797/// absence here is **not** "this issue has no number". A draft is the content that has
8798/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
8799/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
8800fn issue_number(issue: &Value) -> Result<u64, SourceError> {
8801 issue
8802 .get("number")
8803 .and_then(Value::as_u64)
8804 .ok_or_else(|| SourceError::Malformed {
8805 message: "GitHub issue number is missing or is not an unsigned integer".into(),
8806 })
8807}
8808
8809/// The `number` a creating mutation answered with, and `None` when it answered without one;
8810/// why a missing one is tolerated is at the call in `create_and_file_issue`.
8811fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
8812 match created.get("number") {
8813 None | Some(Value::Null) => Ok(None),
8814 Some(value) => value
8815 .as_u64()
8816 .map(Some)
8817 .ok_or_else(|| SourceError::Malformed {
8818 message: "GitHub created issue number is not an unsigned integer".into(),
8819 }),
8820 }
8821}
8822
8823fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8824 value
8825 .get(field)
8826 .and_then(Value::as_str)
8827 .ok_or_else(|| SourceError::Malformed {
8828 message: format!("GitHub response is missing string field {field}"),
8829 })
8830}
8831
8832fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
8833 let found = required_str(value, field)?;
8834 if found.trim().is_empty() {
8835 return Err(SourceError::Malformed {
8836 message: format!("GitHub response has blank string field {field}"),
8837 });
8838 }
8839 Ok(found)
8840}
8841
8842/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
8843/// needs one — Linear spells them too, in its own description field.
8844///
8845/// Restated rather than shared, because a plugin crate depends on the contract crate and
8846/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
8847/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
8848/// source round-trips its own writes perfectly well under its own spelling.
8849const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
8850const METADATA_CLOSE: &str = "\n-->";
8851
8852/// What the composer puts between a non-empty visible body and the slot, and the one thing
8853/// the parser takes off the visible body when it takes the slot off — exactly once, so every
8854/// other trailing byte of the body comes back as it was written.
8855// 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.
8856const METADATA_SEPARATOR: &str = "\n\n";
8857
8858/// The visible body and the metadata slot at the end of it.
8859///
8860/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
8861/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
8862/// own content and is left alone. The visible body is everything before the slot less the
8863/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
8864fn metadata_body(
8865 body: Option<String>,
8866) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
8867 let Some(body) = body else {
8868 return Ok((None, BTreeMap::new()));
8869 };
8870 let Some(slot) = slot_span(&body)? else {
8871 return Ok((Some(body), BTreeMap::new()));
8872 };
8873 let metadata =
8874 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
8875 SourceError::Malformed {
8876 message: format!(
8877 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
8878 ),
8879 }
8880 })?;
8881 let before = &body[..slot.start];
8882 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
8883 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
8884}
8885
8886/// Where the metadata slot sits in one body, as byte offsets into it.
8887struct SlotSpan {
8888 /// Where [`METADATA_OPEN`] begins.
8889 start: usize,
8890 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
8891 encoded_start: usize,
8892 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
8893 encoded_end: usize,
8894 /// Just past [`METADATA_CLOSE`].
8895 end: usize,
8896}
8897
8898/// The slot at the very end of `body`, or `None` when it has none.
8899///
8900/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
8901/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
8902/// slot.
8903fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
8904 let Some(start) = body.rfind(METADATA_OPEN) else {
8905 return Ok(None);
8906 };
8907 let encoded_start = start + METADATA_OPEN.len();
8908 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
8909 return Err(SourceError::Malformed {
8910 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
8911 });
8912 };
8913 let encoded_end = encoded_start + relative_end;
8914 let end = encoded_end + METADATA_CLOSE.len();
8915 if !body[end..].trim().is_empty() {
8916 return Ok(None);
8917 }
8918 Ok(Some(SlotSpan {
8919 start,
8920 encoded_start,
8921 encoded_end,
8922 end,
8923 }))
8924}
8925
8926/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
8927/// slot as it was.
8928///
8929/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
8930/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
8931/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
8932/// or alone in an empty body — and a body with no slot that is given no metadata is
8933/// returned as it is.
8934fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
8935 let encoded = if metadata.is_empty() {
8936 None
8937 } else {
8938 Some(
8939 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8940 message: error.to_string(),
8941 })?,
8942 )
8943 };
8944 Ok(match (slot_span(body)?, encoded) {
8945 (Some(slot), Some(encoded)) => format!(
8946 "{}{encoded}{}",
8947 &body[..slot.encoded_start],
8948 &body[slot.encoded_end..]
8949 ),
8950 (Some(slot), None) => {
8951 let before = &body[..slot.start];
8952 format!(
8953 "{}{}",
8954 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
8955 &body[slot.end..]
8956 )
8957 }
8958 (None, None) => body.to_owned(),
8959 (None, Some(encoded)) if body.is_empty() => {
8960 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8961 }
8962 (None, Some(encoded)) => {
8963 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8964 }
8965 })
8966}
8967
8968/// `body` with everything before its metadata slot replaced by `content`, and the slot
8969/// itself kept byte for byte.
8970///
8971/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
8972/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
8973/// `content` is empty — so a read of the result reports `content` as the visible body and
8974/// the slot's metadata exactly as it was.
8975fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
8976 let Some(slot) = slot_span(body)? else {
8977 return Ok(content.to_owned());
8978 };
8979 let kept = &body[slot.start..];
8980 Ok(if content.is_empty() {
8981 kept.to_owned()
8982 } else {
8983 format!("{content}{METADATA_SEPARATOR}{kept}")
8984 })
8985}
8986
8987/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
8988fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
8989 if entries.is_empty() {
8990 metadata.remove(key);
8991 } else {
8992 metadata.insert(
8993 key.to_owned(),
8994 Value::Array(
8995 entries
8996 .iter()
8997 .map(|entry| Value::String(entry.as_str().to_owned()))
8998 .collect(),
8999 ),
9000 );
9001 }
9002}
9003
9004fn compose_body(
9005 content: Option<&str>,
9006 metadata: &BTreeMap<String, Value>,
9007) -> Result<Option<String>, SourceError> {
9008 let visible = content.unwrap_or_default();
9009 if metadata.is_empty() {
9010 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
9011 }
9012 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
9013 message: error.to_string(),
9014 })?;
9015 Ok(Some(if visible.is_empty() {
9016 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9017 } else {
9018 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
9019 }))
9020}
9021
9022fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
9023 value
9024 .get(field)
9025 .and_then(Value::as_bool)
9026 .ok_or_else(|| SourceError::Malformed {
9027 message: format!("GitHub response is missing boolean field {field}"),
9028 })
9029}
9030fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
9031 match value.get(field) {
9032 None | Some(Value::Null) => Ok(None),
9033 Some(value) => value
9034 .as_str()
9035 .map(Some)
9036 .ok_or_else(|| SourceError::Malformed {
9037 message: format!("GitHub response field {field} is not a string or null"),
9038 }),
9039 }
9040}
9041fn optional_nodes<'a>(
9042 connection: Option<&'a Value>,
9043 name: &str,
9044) -> Result<Option<&'a Vec<Value>>, SourceError> {
9045 match connection {
9046 None | Some(Value::Null) => Ok(None),
9047 Some(value) => value
9048 .get("nodes")
9049 .and_then(Value::as_array)
9050 .map(Some)
9051 .ok_or_else(|| SourceError::Malformed {
9052 message: format!("GitHub {name}.nodes is not an array"),
9053 }),
9054 }
9055}
9056fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
9057 let page_info = connection
9058 .get("pageInfo")
9059 .ok_or_else(|| SourceError::Malformed {
9060 message: format!("GitHub {name} has no pageInfo"),
9061 })?;
9062 if required_bool(page_info, "hasNextPage")? {
9063 return Err(SourceError::Malformed {
9064 message: format!(
9065 "GitHub {name} exceeds the supported nested connection size of {size}"
9066 ),
9067 });
9068 }
9069 Ok(())
9070}
9071fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
9072 optional_str(value, field)?
9073 .map(|timestamp| {
9074 timestamp.parse().map_err(|error| SourceError::Malformed {
9075 message: format!("GitHub response field {field} is not a timestamp: {error}"),
9076 })
9077 })
9078 .transpose()
9079}
9080fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
9081 if page.limit == 0 {
9082 Err(SourceError::Config {
9083 message: "page limit must be at least 1".into(),
9084 })
9085 } else {
9086 Ok(())
9087 }
9088}
9089fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
9090 let page = connection
9091 .get("pageInfo")
9092 .filter(|value| value.is_object())
9093 .ok_or_else(|| SourceError::Malformed {
9094 message: "GitHub connection is missing pageInfo".into(),
9095 })?;
9096 if required_bool(page, "hasNextPage")? {
9097 let cursor = required_str(page, "endCursor")?;
9098 validate_cursor_progress(None, cursor)?;
9099 Ok(Some(Cursor(cursor.into())))
9100 } else {
9101 Ok(None)
9102 }
9103}
9104fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
9105 if next.is_empty() || previous == Some(next) {
9106 Err(SourceError::Malformed {
9107 message: "GitHub pagination cursor is empty or did not advance".into(),
9108 })
9109 } else {
9110 Ok(())
9111 }
9112}
9113/// The version of this plugin's opaque narrowing-search cursor.
9114pub const SEARCH_CURSOR_VERSION: u32 = 4;
9115
9116#[derive(Serialize, Deserialize)]
9117#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
9118enum SearchConnection {
9119 Initial {},
9120 Continuing { after: Cursor },
9121 Exhausted {},
9122}
9123impl SearchConnection {
9124 fn after(&self) -> Option<&str> {
9125 match self {
9126 Self::Continuing { after } => Some(&after.0),
9127 _ => None,
9128 }
9129 }
9130 fn exhausted(&self) -> bool {
9131 matches!(self, Self::Exhausted { .. })
9132 }
9133 /// Whether a cursor naming this position, `offset` rows into its page, is one this
9134 /// plugin could have handed out: a page is resumed only part of the way through it — an
9135 /// offset of a whole page or more would skip rows nobody was given — an initial page
9136 /// only once some of it was handed out, and an exhausted connection has no page to be
9137 /// part of the way through.
9138 fn valid_resume(&self, offset: usize) -> bool {
9139 let within = offset < SEARCH_PAGE_SIZE as usize;
9140 match self {
9141 Self::Initial { .. } => offset > 0 && within,
9142 Self::Continuing { after } => !after.0.is_empty() && within,
9143 Self::Exhausted { .. } => offset == 0,
9144 }
9145 }
9146}
9147
9148/// Versioned source cursor. A zero offset and empty own-write ids are omitted.
9149#[derive(Serialize, Deserialize)]
9150#[serde(deny_unknown_fields)]
9151struct SearchPosition {
9152 version: u32,
9153 connection: SearchConnection,
9154 /// How many rows of the page `connection` starts were already handed out.
9155 #[serde(default, skip_serializing_if = "is_zero")]
9156 offset: usize,
9157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9158 seen: Vec<NativeId>,
9159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9160 own: Vec<NativeId>,
9161}
9162impl Default for SearchPosition {
9163 fn default() -> Self {
9164 Self {
9165 version: SEARCH_CURSOR_VERSION,
9166 connection: SearchConnection::Initial {},
9167 offset: 0,
9168 seen: Vec::new(),
9169 own: Vec::new(),
9170 }
9171 }
9172}
9173
9174fn is_zero(offset: &usize) -> bool {
9175 *offset == 0
9176}
9177
9178fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
9179 cursor.map_or(Ok(0), |c| {
9180 c.0.parse().map_err(|_| SourceError::Config {
9181 message: "page cursor is invalid".into(),
9182 })
9183 })
9184}
9185fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
9186 if offset > items.len() {
9187 return Page::last(vec![]);
9188 }
9189 let tail = items.split_off(offset);
9190 let mut selected = tail;
9191 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
9192 selected.truncate(limit);
9193 Page {
9194 items: selected,
9195 next,
9196 }
9197}