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.
81//!
82// 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.
83//! **Status.** `status_mapping` is per-instance configuration from a status category to
84//! `null` or a board `Status` option name. `done` selects its mapped option and closes the
85//! issue as `COMPLETED`; `cancelled` selects its mapped option and closes it as
86//! `NOT_PLANNED`. Every open category reopens a closed issue before selecting its option.
87//! A missing mapped option refuses the write before either representation changes. Reads
88//! give a closed issue's reason precedence over its option, while an open issue's option
89//! decides its category. The guarded [`GitHubProjectsSource::status_options`] operation is
90//! the one path here that calls `updateProjectV2Field`: GitHub replaces the whole option
91//! list, so it preserves every existing option id and verifies the field and item
92//! assignments immediately afterwards. It counts a terminal category's mapped option as
93//! configured, because a terminal write refuses without it. No ordinary source read or
94//! write calls that mutation, whose
95//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
96//! and a mistake destroys every item's status. A status this board cannot represent is a
97//! refusal naming the status and the instance instead.
98//!
99//! `unknown` is disabled by default because this source cannot preserve an open-ended
100//! status word: it writes an existing board option and never
101//! creates an option. An operator may map `unknown` to one existing option, in which case
102//! every unknown word lands on that option and reads back as `unknown` under the option's
103//! name. This differs from `local-md`, which writes and reads the original word itself.
104//!
105//! The shipped terminal options are exactly `done: Done` and `cancelled: Cancelled`.
106//! `done` also closes the issue because GitHub derives `subIssuesSummary.completed`
107//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
108//! finished tasks were only moved to a "Done" column would read 0% complete forever.
109// llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
110//!
111//! # What this source declares, field by field
112//!
113//! One verdict per field of [`Capabilities`], and what `Native` means when this source
114//! says it. *Proven* means a shared journey drives it against the real
115//! binary over this source's own row in `crates/onetaskgraph/tests/e2e/fixtures.rs`, and
116//! `every_row_declares_exactly_what_its_plugin_reports` is what keeps this list and
117//! [`capabilities`](TaskSource::capabilities) from parting.
118//!
119//! | Field | Verdict |
120//! | --- | --- |
121//! | `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. |
122//! | `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. |
123//! | `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. |
124//! | `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. |
125//! | `filter_by_priority` | **Supported and proven,** over the priority each task reads as — `none` for every task of an instance without `priority_mapping`. |
126//! | `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. |
127//! | `orphan_tasks` | **Supported and proven.** A task issue with no `parent` is in no project. |
128//! | `filter_by_label` | **Supported and proven,** over the issue's own labels. |
129//! | `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`. |
130//! | `search_title` | **Supported and proven,** over `Issue.title`. |
131//! | `search_content` | **Supported and proven,** over the visible body — the trailing metadata comment is not part of it. |
132//! | `task_dependencies` | **Supported and proven,** in both directions: `blockedBy` and `blocking`. |
133//! | `project_dependencies` | **Supported and proven,** in both directions, over the same two connections, because a project here is an issue. |
134//! | `max_page_size` | **Supported and proven.** [`MAX_PAGE_SIZE`], GitHub's own connection maximum. |
135//!
136//! Nothing here is unsupported. `documents` and `comments` are not predicates — they say this
137//! source has documents and that its tasks have comments, both of which hold — and the three
138//! facts behind the uniform `Native` on the
139//! predicates beside it are recorded below rather than re-derived, because a reader who
140//! takes `Native` to mean *the remote service filters* will read that uniformity as a
141//! lie.
142//!
143//! First, the plugin contract defines `Support::Native` as *the source applies this
144//! predicate itself*, and says nothing about where it applies it. What the declaration
145//! promises the engine is capability rule 1 — a predicate declared `Native` **is** applied
146//! — so that the engine may push it down and apply nothing of its own.
147//!
148//! Second, this source can keep that promise for every predicate at no additional API
149//! cost, because whichever of the three reads below answers a query has already read every
150//! item that query could keep before it filters anything. Filtering those items is
151//! in-process work over data already in hand.
152//!
153//! Third, no predicate but `projects` and `filter_by_comment_activity` could be pushed into
154//! the API even if that were wanted, and both are — comment activity through the issue
155//! search's `updated:` qualifier, the row above says how: `ProjectV2.items` takes `first` and `after` and
156//! offers no filter argument of any kind, GitHub's issue search offers no qualifier for a
157//! label set, a status column or a substring of a body, and its title qualifier matches
158//! tokens where this source — and the local Markdown source beside it — match substrings,
159//! so pushing a search down would silently *narrow* the answer. What a project filter has
160//! instead is a relationship: a project's tasks are that issue's sub-issues, and asking
161//! the issue for them is both cheaper and exact. So there are two predicates this source
162//! applies by asking a narrower question, six it applies in process, and none it is unable
163//! to apply. Declaring one `Unsupported` would make the engine compensate for work this
164//! source has already done, and declaring `projects` native while ignoring the filter
165//! (which this source once did) silently returns another project's tasks, because the
166//! engine trusts the declaration and applies nothing locally.
167//!
168//! # The three ways this source reaches an item, and what each costs
169//!
170//! A board read is charged for what its *nested* connections could return rather than for
171//! what was asked, so one whole-board read costs the same whether the question was about
172//! one project or about all of them. That is why a question about one project is never
173//! answered by reading the board:
174//!
175//! | The question | What is sent | What it costs |
176//! | --- | --- | --- |
177//! | 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 |
178//! | 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 |
179//! | one project's tasks or documents | [`graphql::SUB_ISSUES`] — that issue's own `subIssues` | that project |
180//! | which projects this board holds | [`graphql::SEARCH_ISSUES`] — an issue search scoped to the board | the board's issues, without their board items |
181//! | 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 |
182//! | every task, every document, every label | [`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 |
183//! | 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 |
184//!
185//! The board half of an issue — its board item's id, its `Status` option and this
186//! source's origin text field — rides along on `Issue.projectItems` in the first three, so
187//! an item reached any of those ways resolves through the same
188//! [`GitHubProjectsSource::resolve`] the board walk uses and reports the same title, the
189//! same status, the same labels and the same qualified id. That connection comes back a
190//! *page* at a time, at `BOARD_ITEMS_PAGE_SIZE`, so the entry for this board is looked for
191//! on the page in hand and — only if that page reports more of the connection — in the
192//! last row's read of that one issue's memberships, resumed from the page's own cursor and
193//! walked to exhaustion. An issue with no entry for *this* board is not this source's to
194//! report, which is what keeps an id naming another repository's issue from being answered
195//! as an item of this board; and because the page is where the search starts rather than
196//! where it ends, that answer is one about a connection read to exhaustion and never about
197//! an unread page. Nothing costs the extra read but an issue on more boards than a page
198//! holds: an issue this board really does not hold reports no next page, so its
199//! memberships are already exhausted where they arrived.
200//!
201//! **No document here selects the board's own `Labels` field, and nothing is lost by
202//! that.** An item's labels are read from its content alone, wherever that content is
203//! reached: the three documents above select `Issue.labels` on the fragment, and
204//! [`graphql::BOARD`] selects the same connection on the `... on Issue` arm of its
205//! `content`. A board's `Labels` field is not one anybody fills in: it is a built-in
206//! `ProjectV2FieldType`, it is absent from `ProjectV2CustomFieldType` so no project can
207//! create one, and `ProjectV2FieldValue` — the whole of what
208//! `updateProjectV2ItemFieldValue` accepts — offers no way to write one. So GitHub derives
209//! it from the content, for every content type it exists on, and there is nothing it can
210//! hold that the content does not already say: for an `Issue` it *is* that issue's own
211//! labels, so selecting it beside them unions a set with itself.
212//!
213//! **A draft loses nothing by that either**, which is the reasoning this paragraph once had
214//! backwards. `DraftIssue` exposes no `labels` field, and by the three schema facts above
215//! it cannot carry a board `Labels` value to be derived from one — so a draft has nothing
216//! to select *and nothing to lose*, and reports no labels at all. A `PullRequest` item is
217//! discarded by [`GitHubProjectsSource::resolve`] before labels are read. Both halves are
218//! held to that by tests in `tests/plugin.rs`: the four ways an item is reached report one
219//! label set, and that set is the fixture issue's own, by
220//! `an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached`; and a board
221//! item whose content is a draft reports an empty set, by
222//! `a_board_item_whose_content_is_a_draft_reports_no_labels_at_all`. The absence of the
223//! selection is held over [`graphql::DOCUMENTS`] by
224//! `no_document_selects_the_boards_own_labels_field`.
225//!
226//! The whole-board row is still the board's own item connection, and deliberately: a
227//! **draft** board item is not an issue, so no search can list one, and the reads that have
228//! to answer for the whole board are the ones whose cost is the board's size anyway.
229//!
230//! **A question about one item this source already names by id never lists the board.**
231//! Whether that item is on this board, and what its board fields are, is answered by reading
232//! that item — its own `Issue.projectItems`, walked to exhaustion by
233//! [`GitHubProjectsSource::resolve_issue`], or a draft's own board item — and never by
234//! looking for it in [`graphql::BOARD`]'s `items` or in a listing this command already
235//! holds. That covers a write's destination, the project a new item is filed under, a
236//! same-source far end a dependency names, a status write, the dependency slot a draft keeps,
237//! and the delete that takes back an item a copy made. What such a write needs of the board
238//! and the item does not carry — the board's id, the `Status` and origin field definitions —
239//! comes from [`graphql::BOARD_FIELDS`], which reads no item at all. The reason is evidence,
240//! not economy alone: `ProjectV2.items` is a projection that lags the membership GitHub
241//! itself reports — an issue added with `addProjectV2ItemById` can be missing from it for
242//! minutes. Scanning this host's 842-item board has refused a document copy and an update
243//! even though the items' own reads named that board. A scan there gives the wrong answer
244//! as well as paying for every page. So a `board.items` lookup does not belong on any of
245//! those paths.
246//!
247//! **What a read may return is capped too, and that cap is on the document rather than on
248//! the board.** GitHub limits the number of nodes **one query may return** to
249//! [`NODE_COUNT_LIMIT`] and refuses a query above that before executing it: the answer is
250//! an error naming the connection the count crossed at, not a slow or a partial result.
251//! Every board this source reads is refused the same way, so no board is too big for these
252//! documents and none is small enough to save one that is over.
253//!
254//! The count is arithmetic over the document's own text: each connection contributes the
255//! `first:` it asks for, counts **multiply** down a nested path and **sum** across sibling
256//! paths. Those are [GitHub's published rules][node-limits] and this workspace does not
257//! restate them — `github-graphql-node-count` implements them, and
258//! [`worst_case_node_count`] under [`largest_page_sizes`] is where every node count here
259//! comes from. `every_document_this_source_sends_stays_under_githubs_node_limit`, in
260//! `tests/node_count.rs`, recomputes every document in [`graphql::DOCUMENTS`] from that
261//! same text on every run and fails naming any that reaches the limit — so a connection
262//! added to a shared fragment is caught there rather than by GitHub.
263//!
264//! What decides those counts is the page sizes: [`MAX_PAGE_SIZE`] on the outer page,
265//! `NESTED_PAGE_SIZE` on the connections hanging off one item, and
266//! `BOARD_ITEMS_PAGE_SIZE` on the page of an issue's board memberships a read carries.
267//! `$nestedFirst` is spent twice down one path of a board read, so that constant is
268//! effectively squared there, which is why it is the one the limit is most sensitive to.
269//! `BOARD_ITEMS_PAGE_SIZE` is small for a reason of its own, recorded beside it: what a
270//! page of memberships misses is recovered by one further read rather than refused, so it
271//! buys a bound every read pays for at the price of a request only a multi-board issue
272//! pays.
273//!
274//! **`nodeCount` and `cost` are two numbers against two limits, and both are computed
275//! offline here — per document, one document at a time.** `nodeCount` is the one above: the
276//! most nodes one query may return, checked per query and bounded by [`NODE_COUNT_LIMIT`].
277//! `cost` is rate-limit points, metered per hour across everything one credential does; it
278//! is what the two limiters [`Limiter`] tells apart meter, and a document under
279//! [`NODE_COUNT_LIMIT`] still says nothing about its price. [`worst_case_point_cost`] is
280//! that second number, and `tests/point_cost.rs` pins every document in
281//! [`graphql::DOCUMENTS`] at what it costs — there being no per-call point ceiling to hold
282//! one under, the pin itself is the check. The credentialed lane reconciles both figures
283//! against GitHub's own, off a probe it already sends.
284//!
285//! **What is pinned that way is a per-document price and never a session's.** The record in
286//! `session-cost.md` measures the two quantities a whole session can be counted in offline —
287//! **requests** and **worst-case nodes** — and neither is points. What one whole session
288//! consumes of the hourly point allowance is observable only from a credentialed run's own
289//! `x-ratelimit-*` headers, which is what [`accounting`] fills its per-budget figures from
290//! and what `tests/live.rs` prints at the end of every run.
291//!
292//! [node-limits]: https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api
293//!
294//! **Where a read-after-write guarantee comes from, since neither of GitHub's two
295//! enumerations of a board can supply one alone.** Resolving a node id is strongly
296//! consistent, so a read by id and a project's own sub-issues are already current. The
297//! other two are not, and they are behind by different amounts and in different directions:
298//!
299//! - GitHub's **issue search** is an index and answers a write made moments ago with the
300//! value from before it — usually for a second or two.
301//! - **`ProjectV2.items`** is a projection GitHub rebuilds behind the write, and an item put
302//! on a board with `addProjectV2ItemById` can be **absent** from it — not present with its
303//! content withheld, absent, with the connection walked to its own `hasNextPage: false` —
304//! for *minutes*, while `Issue.projectItems` names the same membership at once.
305//!
306//! That second one is a measurement rather than a caution. This repository's own
307//! credentialed journey writes a project and waits for the board to report it, then writes a
308//! task and waits for the same thing seconds later on the same board: the project wait is
309//! answered through the search and converged in two or three attempts in each of three runs,
310//! and the task wait is answered through `ProjectV2.items` and converged in none of them
311//! inside thirty. Separately, an item added to a second and larger board was read back by
312//! `Issue.projectItems` on that board's own id while every one of that connection's nine
313//! pages, walked to exhaustion nine minutes after the add, did not name it. Reading a board
314//! through the lagging one alone is what had a board read deny an issue that had certainly
315//! landed on it.
316//!
317//! So [`GitHubProjectsSource::board`] is the **union** of both — each search result still
318//! admitted only on this board's own strongly-consistent `Issue.projectItems`, and neither
319//! enumeration dropped, because only `ProjectV2.items` lists a board draft and only the
320//! search reports what the projection is behind on. What closes the last
321//! gap, the one where both are behind, is [`GitHubProjectsSource::created`]: every read this
322//! source answers is completed with what this process itself wrote, so an item created
323//! seconds ago is reported whether or not GitHub has caught up. Nothing else is remembered,
324//! nothing is written down, and the record dies with the process. **A wait that has to
325//! observe GitHub's own data cannot be answered from that record** — which is why the
326//! credentialed journey asks through a source built afresh, and why the union above rather
327//! than a longer wait is what makes such a wait converge.
328//!
329//! Filtering happens before paging, so a page of a filtered result is a page of the
330//! survivors rather than the survivors of a page. Label and text matching answer the same
331//! question the same way the local Markdown source's do, so one cross-source expectation
332//! holds for both.
333//!
334//! <!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] The declaration itself
335//! has one source, `capabilities`, and the note above is the reasoning behind it rather
336//! than a second copy of it: without the three facts recorded here a reader takes the
337//! uniform `Native` for a lie and reverts it. The drift gate on the declaration is this
338//! crate's own capabilities test, which pins every field of it against a fully spelled-out
339//! `Capabilities` literal — a struct with no `Default`, so a field added to the contract
340//! fails to compile there rather than going unasserted. -->
341//! The fixture-server tests above run wherever this crate is selected; the credentialed
342//! lane runs in the same required check, beside them, and can fail it — it verifies the
343//! 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,
344//! one filed under neither, a label on one of the three and a closed status on another —
345//! because that shape is what tells an honoured predicate from an ignored one: a board
346//! holding a single project answers a project filter the same way whether or not this
347//! source applies it, which is exactly how the defect above went unseen.
348//!
349//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
350//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
351//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
352//! nominated is what keeps a credentialed write lane off a board and a repository nobody
353//! nominated; it never asks GitHub which project was updated most recently. Before it
354//! starts, the lane also clears any item titled — and any repository label named — the way
355//! it titles and names its own artifacts, which is self-healing after an interrupted run:
356//! a process killed between its writes and its cleanup leaves artifacts the next run
357//! removes.
358//!
359//! # What a session of requests costs, and where the report is
360//!
361//! This source records **every** request it sends into [`accounting::Accounting`], at
362//! `send_once` — the one place a request leaves this crate, which is why a read path added
363//! later is counted without anybody remembering to count it. That is the whole of what this
364//! crate adds to the arrangement; [`accounting`] is where what a record carries, how a
365//! session's spend is arrived at, and what it deliberately does not know are set out.
366//!
367//! What one whole session of the live journey costs, counted that way against this crate's
368//! loopback fixture board, is written down in `session-cost.md` beside this crate — with the
369//! reduction it came out of, and with what it does and does not say about rate-limit points.
370//!
371//! [`GitHubProjectsSource::accounting`] is the read: a snapshot to hold and compare, which
372//! [`accounting::Session::report`] renders the session report from. It is on the ordinary
373//! code path — no environment variable, no feature, no build configuration — because an
374//! instrument nobody switches on measures nothing, and
375//! [`Plugin::build_recording_into`] is how a caller making its own calls beside this
376//! source's counts the whole session rather than this source's share. The credentialed lane
377//! in `tests/live.rs` does exactly that, and prints the report at the end of every run,
378//! passed or failed.
379//!
380//! **A live session refuses to start unless the account can afford it.** Before it does any
381//! of the work it exists to do, the journey makes one request — `GET /rate_limit`, which
382//! GitHub documents as not counting against the REST rate limit and which answers both of
383//! its budgets at once — and starts only if, for each of them, what remains minus this
384//! session's estimated cost is still at least
385//! `onetaskgraph_live::RETAINED_BUFFER` — twenty per cent — of that budget's whole
386//! allowance. A session that cannot **declines**: it did not run, so it is
387//! neither a pass nor a failing assertion, and it says which budget was short, that budget's
388//! limit, what remained, the estimate, the buffer and when it resets — then stops, without
389//! waiting for the budget to come back. The estimate is derived offline from
390//! `tests/fixtures/session-cost.txt` and a cost model stated in `tests/journey/budget.rs`,
391//! which is also where the published rule that model rests on is cited; the accounting
392//! above records the gate's own read like any other request, and
393//! [`accounting::Session::report`] prints the estimate beside what the session really spent.
394//!
395//! **GitHub is the authority on both of its own numbers, and the credentialed lane goes and
396//! asks it.** Everything above computes `nodeCount` and `cost` offline from a document's own
397//! text, which is what lets it run on every platform and on a pull request from a fork with
398//! no credential — and that is what actually stops a regression merging. But an offline
399//! arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
400//! goes on computing the old answer and nothing notices. So `tests/live.rs` reconciles them.
401//! GitHub's schema exposes `rateLimit(dryRun: true)`, whose `nodeCount` is *"the maximum
402//! number of nodes this query may return"* and whose `cost` is what that document would
403//! spend, both for a document **without executing it**, and the lane asks it for every query
404//! document this source sends, under the largest bindings this source sends, and fails when
405//! GitHub's figure and [`worst_case_node_count`] or [`worst_case_point_cost`] disagree. A
406//! mutation is skipped, because `rateLimit` is a field of `Query` and cannot be asked about
407//! one; the offline pins still cover it. It records what those calls reported about the
408//! account's own allowance, because whether asking is free is a thing to observe rather than
409//! to assume. Two quantities, not one: [`NODE_COUNT_LIMIT`] bounds `nodeCount` per query,
410//! and `cost` is metered against an hourly allowance the accounting above reads off a
411//! credentialed run's own response headers.
412//!
413//! **GitHub has two rate limiters and this source is refused by both, so nothing here
414//! treats them as one thing.** The primary budget is the hourly allowance `gh api
415//! rate_limit` reports; the secondary limiter is a burst limiter over content-generating
416//! requests, and *nothing* reports it. Which one refused decides the operator's next step,
417//! so [`Limiter`] is a type rather than a detail, and it is what [`MIN_MUTATION_INTERVAL_MS`],
418//! [`GitHubProjectsSource::board_cache`] and [`GitHubProjectsSource::graphql`] each answer
419//! one part of.
420#![deny(missing_docs)]
421
422use std::collections::BTreeMap;
423use std::sync::{Arc, Mutex};
424use std::time::{Duration, Instant};
425
426use chrono::{DateTime, Utc};
427use onetaskgraph_plugin_api::{
428 Capabilities, Comment, CommentBody, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind,
429 DependencySupport, Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label,
430 LabelFilter, Location, MetadataKey, Metering, NativeId, NewComment, Page, PageRequest,
431 Priority, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError,
432 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskRef,
433 TaskSource, TaskUpdate, TaskUpdateOutcome, TextFields, TextQuery, UpdatedField, WriteSupport,
434};
435use reqwest::{Client, StatusCode, Url};
436use schemars::{Schema, schema_for};
437use secrecy::{ExposeSecret, SecretString};
438use serde::{Deserialize, Serialize};
439use serde_json::{Value, json};
440
441pub mod accounting;
442
443use accounting::Accounting;
444
445/// The registry name for this plugin.
446pub const KIND: &str = "github-projects";
447/// GitHub's maximum connection page size.
448pub const MAX_PAGE_SIZE: u32 = 100;
449
450/// The most nodes any one document this source sends may be asked to return.
451///
452/// GitHub's own published per-query ceiling, taken from
453/// [`github_graphql_node_count::NODE_LIMIT`] rather than written out again here, so this
454/// workspace cannot hold a stale copy of somebody else's number. A query above it is
455/// **refused before it is executed**, whoever is asking and whatever board they are
456/// asking about — so this is a bound on the documents rather than a budget that runs out.
457///
458/// This is `nodeCount`, the maximum number of nodes *one query may return*. It is not
459/// `cost`, the rate-limit points a call spends against an hourly allowance shared by
460/// everything the credential does — two numbers against two limits, and this constant
461/// bounds only the first. The second is computed offline too, per document:
462/// [`worst_case_point_cost`], pinned for every document in [`graphql::DOCUMENTS`] by
463/// `tests/point_cost.rs`, and reconciled against GitHub's own `cost` by the credentialed
464/// lane. There is no constant like this one to hold a price under, because points are an
465/// hourly allowance rather than a per-call bound.
466///
467/// Neither is a session's price. What `session-cost.md` records of a whole session is its
468/// **requests** and its **worst-case nodes**; what a whole session spends in points is
469/// reported only by a credentialed run's own `x-ratelimit-*` headers, through
470/// [`accounting`]. The module section on the three ways this source reaches an item says how
471/// the count is arrived at, and which of the page sizes below decide it.
472pub const NODE_COUNT_LIMIT: u64 = github_graphql_node_count::NODE_LIMIT;
473
474/// Nested connection size for the connections that hang off one item.
475///
476/// It multiplies through every document that reaches an item under a page — the count
477/// rules multiply down a nested path — so it is the constant [`NODE_COUNT_LIMIT`] is most
478/// sensitive to. `tests/node_count.rs` is what holds the pair together: it recomputes
479/// every document under these constants and fails naming any that reaches the limit, so
480/// raising this is caught there rather than by GitHub.
481const NESTED_PAGE_SIZE: u32 = 50;
482/// How many of one issue's board memberships are read when an issue is reached directly.
483///
484/// An issue reached through a search or through its own node id carries its board half in
485/// `Issue.projectItems`, and only the entry for *this* board is read. This connection sits
486/// under a page of issues, so every point of it multiplies through the whole document and
487/// is paid for whether or not any issue is on a second board — which is why it is
488/// deliberately far smaller than [`NESTED_PAGE_SIZE`].
489///
490/// **Three, because what a page misses is now recovered rather than refused**, and the
491/// recovery is what the value is chosen against. An issue whose entry for this board sits
492/// past this page costs one further request — [`graphql::ISSUE_BOARD_ITEMS`], resumed from
493/// that page's own cursor — so the value trades a bound every read pays for a request only
494/// a multi-board issue pays. At one, a deployment whose issues commonly sit on two or more
495/// boards would pay that request *per issue*, which is order N against the one page per
496/// hundred issues a read costs today. At three it is only reached by an issue on four or
497/// more boards at once, which keeps the recovery path exceptional rather than routine for
498/// a plausible deployment.
499const BOARD_ITEMS_PAGE_SIZE: u32 = 3;
500
501pub use github_graphql_node_count::{NodeCountError, Variables};
502
503/// The largest value this source can bind to each page-size variable its documents name.
504///
505/// Every `first:` in [`graphql`] reads one of these three, and each is capped at the
506/// constant above it wherever a caller's own limit could reach it — `$first` at
507/// [`MAX_PAGE_SIZE`], `$nestedFirst` at `NESTED_PAGE_SIZE`, `$boardItems` at
508/// `BOARD_ITEMS_PAGE_SIZE`. So this is the worst case a caller can drive this source to,
509/// not one configuration of it, which is what makes a bound computed under it a bound on
510/// every read.
511pub fn largest_page_sizes() -> Variables {
512 Variables::from([
513 ("first".to_owned(), MAX_PAGE_SIZE),
514 ("nestedFirst".to_owned(), NESTED_PAGE_SIZE),
515 ("boardItems".to_owned(), BOARD_ITEMS_PAGE_SIZE),
516 ])
517}
518
519/// The most nodes `document` could be asked to return, by GitHub's published rules.
520///
521/// Computed offline from the document's own text under [`largest_page_sizes`] — no
522/// network, no credential and no schema — by
523/// [`github_graphql_node_count::node_count`], which is where the rules themselves live.
524/// A document at or above [`NODE_COUNT_LIMIT`] is one GitHub refuses before executing, so
525/// this is what a check holds every document in [`graphql::DOCUMENTS`] below.
526///
527/// # Errors
528///
529/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
530/// no single operation, or binds a page size this source does not name — each of which is
531/// a defect in the document rather than a number.
532pub fn worst_case_node_count(document: &str) -> Result<u64, NodeCountError> {
533 node_count(document, &largest_page_sizes())
534}
535
536/// The most rate-limit points one call of `document` could spend, by GitHub's published
537/// rules.
538///
539/// Computed offline from the document's own text under [`largest_page_sizes`] — no
540/// network, no credential and no schema — by
541/// [`github_graphql_node_count::point_cost`], which is where the rules themselves live.
542/// This is `cost`, metered **per hour** against the allowance one credential shares across
543/// everything it does; it is not `nodeCount`, which is [`worst_case_node_count`] and is
544/// bounded per query by [`NODE_COUNT_LIMIT`]. There is no per-call ceiling to hold this
545/// under, so what `tests/point_cost.rs` does with it is pin every document in
546/// [`graphql::DOCUMENTS`] at what it costs, and the credentialed lane reconciles those
547/// figures against GitHub's own reported `cost`.
548///
549/// # Errors
550///
551/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
552/// no single operation, or binds a page size this source does not name — each of which is
553/// a defect in the document rather than a number.
554pub fn worst_case_point_cost(document: &str) -> Result<u64, NodeCountError> {
555 github_graphql_node_count::point_cost(document, &largest_page_sizes())
556}
557
558/// The most nodes `document` could be asked to return under `variables`.
559///
560/// [`worst_case_node_count`] is this under [`largest_page_sizes`], and the accounting in
561/// [`accounting`] is this under the bindings one request really sent — one spelling of the
562/// calculation, so a bound checked offline and a cost recorded at run time cannot come to
563/// disagree. The rules themselves live in [`github_graphql_node_count::node_count`].
564///
565/// # Errors
566///
567/// Returns the calculation's own [`NodeCountError`] when `document` does not parse, holds
568/// no single operation, or binds a page size `variables` does not name.
569pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
570 github_graphql_node_count::node_count(document, variables)
571}
572
573/// The issue-title prefix that makes a board issue a document.
574///
575/// A GitHub Projects board has no document type — it holds issues — so the discriminator
576/// is the title, and this is the whole of it: an issue whose title begins with these bytes
577/// is a document and every other issue is the task or project the sub-issue rule makes it.
578///
579/// It is spelled **once**, here, and read rather than restated everywhere else — including
580/// by the shared journeys, which take it from this constant so a board fixture cannot
581/// drift from what this source reads. `docs/metadata.md` records the two consequences that
582/// are not obvious from the bytes: the reported title has this prefix taken off, exactly
583/// as the body's metadata slot is taken off `content`, and this prefix is read *before*
584/// the sub-issue rule, so a design issue with no sub-issues is never an empty project.
585pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
586
587/// Exact GraphQL query documents issued by this plugin.
588///
589/// Keeping the production documents here lets the pinned-schema test validate the same
590/// bytes that are sent to GitHub, rather than a test-only copy which could drift
591/// independently. [`STATUS_OPTIONS_UPDATE`] is the sole document that may rewrite a board
592/// field, and its guarded caller always supplies the complete existing option set with ids.
593pub mod graphql {
594 /// The board half of one item: the field values every document here reads it from.
595 ///
596 /// A macro for the same reason [`board_issue!`] below is one, a level further in. This
597 /// selection is needed by that fragment, by [`BOARD`] under the board's own `items`,
598 /// and by [`ISSUE_BOARD_ITEMS`] under a membership walk — and all three have to produce
599 /// *the same value*, because
600 /// [`GitHubProjectsSource::resolve`](super::GitHubProjectsSource) reads them through
601 /// one path. Three spellings of it is what would drift, so there is one.
602 ///
603 /// The `Status` option and this source's own origin text field are the whole of it. It
604 /// selects no `ProjectV2ItemFieldLabelValue`: GitHub derives that field from the item's
605 /// content, so it holds nothing the content's own `labels` do not already say, and it
606 /// would sit a label connection two page sizes deep.
607 macro_rules! board_item_values {
608 () => {
609 r#"fieldValues(first:$nestedFirst){nodes{
610 ... on ProjectV2ItemFieldSingleSelectValue{name field{
611 ... on ProjectV2SingleSelectField{id name options{id name}}
612 }}
613 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
614 }pageInfo{hasNextPage}}"#
615 };
616 }
617
618 /// Everything this source reads about one issue, wherever it reaches that issue.
619 ///
620 /// A macro rather than a constant so the three documents below can `concat!` it: one
621 /// spelling of these fields is what makes an issue read through the board-scoped
622 /// search, through its own node id, and through its project's sub-issue relationship
623 /// resolve to *the same* item, which is the whole of what
624 /// [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource) relies on.
625 ///
626 /// `projectItems` is what carries the board half of an issue: the board item's own id
627 /// and the [`board_item_values!`] above — the `Status` option and this source's origin
628 /// text field — that a `ProjectV2.items` read used to carry. It is asked for on the
629 /// issue rather than on the board, which is what makes the cost of a read proportional
630 /// to what was asked for instead of to the board's size.
631 ///
632 /// It carries a *page* of that connection, at `BOARD_ITEMS_PAGE_SIZE`, and its
633 /// `endCursor` is what [`ISSUE_BOARD_ITEMS`] resumes from when this board's entry is
634 /// not on that page: a page here is where the search for the entry starts rather than
635 /// where it ends.
636 ///
637 /// It does **not** select the board's `Labels` field value, and that is the whole of
638 /// what keeps the three documents below under [`NODE_COUNT_LIMIT`](super::NODE_COUNT_LIMIT):
639 /// a label connection there sits under `fieldValues` under `projectItems` under a page
640 /// of issues, spending `$nestedFirst` twice down one path, and took
641 /// [`SEARCH_ISSUES`] and [`SUB_ISSUES`] to 2,556,100 nodes against a limit of 500,000.
642 /// No label is lost — this is a fragment `on Issue`, whose own `labels` are selected
643 /// above, and that connection is where every label this source reports comes from. No
644 /// document in this module selects the board field any longer, [`BOARD`] included; the
645 /// module documentation records why nothing it could have held is lost.
646 macro_rules! board_issue {
647 () => {
648 concat!(
649 r#" fragment BoardIssue on Issue{__typename id number title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total}
650 labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}
651 projectItems(first:$boardItems){nodes{id project{id number}
652 "#,
653 board_item_values!(),
654 r#"}pageInfo{hasNextPage endCursor}}}"#
655 )
656 };
657 }
658
659 /// Every issue of one board, found by a search scoped to that board.
660 ///
661 /// This is how the projects a board holds are listed, and it selects no `items`
662 /// connection on `ProjectV2`: the board is a *qualifier of the search* rather than a
663 /// container walked page by page, so nothing nested inside a board item is paid for.
664 /// Which of the issues it returns is a project is then read off `parent` — GitHub
665 /// accepts `-has:parent` as a search qualifier and silently ignores it, so the
666 /// discriminator has to be applied to the field, which is a scalar on the issue and
667 /// costs nothing.
668 pub const SEARCH_ISSUES: &str = concat!(
669 r#"query($search:String!,$type:SearchType!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
670 search(query:$search,type:$type,first:$first,after:$after){
671 pageInfo{hasNextPage endCursor}
672 nodes{__typename ...BoardIssue}
673 }
674 }"#,
675 board_issue!()
676 );
677
678 /// One issue by its own node id, which is what a qualified id names here.
679 ///
680 /// Strongly consistent, unlike the search above: GitHub's issue search is an index and
681 /// answers a write made moments ago with the value from before it, and resolving a node
682 /// id does not.
683 pub const ISSUE: &str = concat!(
684 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
685 node(id:$id){__typename ...BoardIssue}
686 }"#,
687 board_issue!()
688 );
689
690 /// One project's tasks: the sub-issues of the issue that project is.
691 ///
692 /// The work this costs is the project's own size. Nothing about it grows as the board
693 /// gains projects, or as those projects gain tasks.
694 pub const SUB_ISSUES: &str = concat!(
695 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!,$boardItems:Int!,$duplicates:Boolean!){
696 node(id:$id){__typename
697 ... on Issue{subIssues(first:$first,after:$after){
698 pageInfo{hasNextPage endCursor}
699 nodes{__typename ...BoardIssue}
700 }}}
701 }"#,
702 board_issue!()
703 );
704
705 /// Reads the board's fields and one page of its items.
706 pub const BOARD: &str = concat!(
707 r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
708 owner:repositoryOwner(login:$owner){
709 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
710 }
711 } fragment Board on ProjectV2 { id title
712 fields(first:$nestedFirst){nodes{
713 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
714 ... on ProjectV2Field{__typename id name}
715 }pageInfo{hasNextPage}}
716 items(first:$first,after:$after){nodes{id "#,
717 board_item_values!(),
718 r#" content{
719 ... 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}}}
720 ... on PullRequest{__typename id}
721 ... on DraftIssue{__typename id title body createdAt updatedAt}
722 }} pageInfo{hasNextPage endCursor}}
723 }"#
724 );
725
726 /// The board's own id and field definitions, and not one of its items.
727 ///
728 /// What a write needs of the board when the item it writes does not say: the id a field
729 /// write and `addProjectV2ItemById` address, and the definitions of the `Status` and
730 /// origin fields. It selects no `items`, so what it costs is the board's field list
731 /// however many items the board holds — and it decides nothing about which items those
732 /// are, which is the question a read of one item by its own id answers instead.
733 ///
734 /// The root is aliased `boardFields` rather than `owner`, so nothing counting the
735 /// board's item reads by their root counts this one among them.
736 pub const BOARD_FIELDS: &str = r#"query($owner:String!,$number:Int!,$nestedFirst:Int!){
737 boardFields:repositoryOwner(login:$owner){
738 ... on ProjectV2Owner{projectV2(number:$number){id
739 fields(first:$nestedFirst){nodes{
740 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
741 ... on ProjectV2Field{__typename id name}
742 }pageInfo{hasNextPage}}
743 }}
744 }
745 }"#;
746
747 /// One board draft by its own node id, with the board item it sits in.
748 ///
749 /// A draft is not an issue, so [`ISSUE`] reaches it and reads nothing of it; this is the
750 /// second read that answers it. `DraftIssue.projectV2Items` names the board item a draft
751 /// is — GitHub links a draft to one item — with the same [`board_item_values!`] the
752 /// issue fragment reads, so a draft reached by id resolves through the same resolver a
753 /// board listing hands it to, and nothing has to list the board to find one.
754 pub const DRAFT: &str = concat!(
755 r#"query($id:ID!,$nestedFirst:Int!,$boardItems:Int!){
756 node(id:$id){__typename ... on DraftIssue{id title body createdAt updatedAt
757 projectV2Items(first:$boardItems){nodes{id project{id number}
758 "#,
759 board_item_values!(),
760 r#"}pageInfo{hasNextPage endCursor}}}}
761 }"#
762 );
763
764 /// One issue's board memberships alone, walked past the page a read of it carried.
765 ///
766 /// The recovery read behind [`GitHubProjectsSource::resolve_issue`](super::GitHubProjectsSource):
767 /// every document above carries a *page* of `Issue.projectItems`, and an issue on more
768 /// boards than that page holds may have this board's entry past its end. This asks that
769 /// one issue for its memberships and nothing else — the caller already holds the issue —
770 /// so an answer of "this board does not hold it" is only ever given about a connection
771 /// read to exhaustion.
772 ///
773 /// It selects the board item's id, its project number and the same
774 /// [`board_item_values!`] the fragment does, because what it produces is handed to the
775 /// very same resolver: an issue recovered this way reports the same title, the same
776 /// status, the same labels and the same qualified id as one whose entry was on the
777 /// page.
778 ///
779 /// `$first` rather than `$boardItems`: this document reads one issue, so nothing
780 /// multiplies through it and the membership connection can be walked at
781 /// [`MAX_PAGE_SIZE`](super::MAX_PAGE_SIZE) — which is what keeps the recovery to one
782 /// further request for any issue a person really keeps.
783 pub const ISSUE_BOARD_ITEMS: &str = concat!(
784 r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){
785 node(id:$id){
786 ... on Issue{projectItems(first:$first,after:$after){
787 nodes{id project{id number}
788 "#,
789 board_item_values!(),
790 r#"}
791 pageInfo{hasNextPage endCursor}}}
792 }
793 }"#
794 );
795 /// Resolves the configured repository's node id, which creating an issue requires.
796 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
797 /// Reads both dependency directions for one issue, with each far end's own kind — and
798 /// the issue's own body, which is where an edge to another source is recorded, so that
799 /// half of a dependency read needs no second read of the issue or of the board.
800 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
801 ... on Issue{body
802 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
803 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
804 }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
805 /// Creates one issue in the configured repository.
806 pub const CREATE_ISSUE: &str =
807 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id number url}}}"#;
808 /// Puts an existing issue on the configured board.
809 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
810 /// Updates an issue's visible fields and its open or closed state in one call.
811 pub const UPDATE_ISSUE: &str =
812 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
813 /// Updates an existing draft's user-visible fields.
814 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
815 /// Updates a text or single-select value on one project item.
816 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
817 /// Clears one project item's value of one field, which is what a `none` priority is.
818 pub const CLEAR_FIELD: &str = r#"mutation($input:ClearProjectV2ItemFieldValueInput!){clearProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
819 /// Creates one single-select field with its options. Only the guarded field setup may use
820 /// this document, and only for a field the board lacks.
821 pub const CREATE_FIELD: &str = r#"mutation($input:CreateProjectV2FieldInput!){createProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id name options{id name color description}}}}}"#;
822 /// Replaces a single-select field's options. Only the guarded field setup — the
823 /// `status-options` and `fields` operations — may use this document, because GitHub
824 /// treats the input as the complete option list.
825 pub const STATUS_OPTIONS_UPDATE: &str = r#"mutation($input:UpdateProjectV2FieldInput!){updateProjectV2Field(input:$input){projectV2Field{... on ProjectV2SingleSelectField{id options{id name color description}}}}}"#;
826 /// A fresh snapshot of the Status field and every board item's assignment.
827 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}}}}}}"#;
828 /// Files one issue under another as a sub-issue, which is what project membership is.
829 pub const ADD_SUB_ISSUE: &str =
830 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
831 /// Takes one issue back out of its parent.
832 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
833 /// Adds GitHub's native issue blocked-by relationship.
834 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
835 /// Removes one native issue blocked-by relationship.
836 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
837 /// Deletes one issue, which takes its board item with it.
838 ///
839 /// The engine sends this in one situation only: undoing a copy that could not finish,
840 /// over the items that same copy created. Deleting the issue removes the board item
841 /// too, so there is no second `deleteProjectV2Item` to keep in step with it.
842 pub const DELETE_ISSUE: &str =
843 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
844
845 /// Everything this source reads about one issue comment, wherever it reaches one.
846 ///
847 /// A macro for the reason [`board_issue!`] is one: a comment listed, a comment just added
848 /// and a comment just edited are handed to one mapper, so they are selected by one
849 /// spelling. `author` is `Actor`, which GitHub answers `null` for an account that no
850 /// longer exists, and `login` is the one member every kind of actor carries.
851 macro_rules! issue_comment {
852 () => {
853 "id author{login} createdAt updatedAt body url"
854 };
855 }
856
857 /// One task's comments: a page of its issue's own `comments` connection.
858 ///
859 /// **No `orderBy`, and that is what makes the page oldest first.** GitHub's only
860 /// `IssueCommentOrder` field is `UPDATED_AT`, which would move a comment to the end of the
861 /// list every time somebody edited it; left unordered the connection answers in the order
862 /// the comments were written, which is the order GitHub documents for the same collection
863 /// over REST — ascending id. Nothing multiplies through it, so `$first` is the whole of its
864 /// node count and the caller's own page size is pushed straight down.
865 pub const ISSUE_COMMENTS: &str = concat!(
866 r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{comments(first:$first,after:$after){nodes{"#,
867 issue_comment!(),
868 r#"}pageInfo{hasNextPage endCursor}}}}}"#
869 );
870 /// Which issue one comment is on, read before that comment is edited or removed.
871 ///
872 /// GitHub's comment mutations take the comment's id and nothing else, so without this a
873 /// comment id given against the wrong task would change a comment on another issue.
874 pub const COMMENT_ISSUE: &str =
875 r#"query($id:ID!){node(id:$id){__typename ... on IssueComment{id issue{id}}}}"#;
876 /// Adds one comment to an issue, signed as the account the token belongs to.
877 pub const ADD_COMMENT: &str = concat!(
878 r#"mutation($input:AddCommentInput!){addComment(input:$input){subject{id} commentEdge{node{"#,
879 issue_comment!(),
880 r#"}}}}"#
881 );
882 /// Replaces the body of one issue comment.
883 pub const UPDATE_COMMENT: &str = concat!(
884 r#"mutation($input:UpdateIssueCommentInput!){updateIssueComment(input:$input){issueComment{"#,
885 issue_comment!(),
886 r#"}}}"#
887 );
888 /// Removes one issue comment. Its payload carries nothing about the comment it removed.
889 pub const DELETE_COMMENT: &str = r#"mutation($input:DeleteIssueCommentInput!){deleteIssueComment(input:$input){clientMutationId}}"#;
890
891 /// Every document above, with what this source is doing when it sends one.
892 ///
893 /// One list rather than a `match` beside the constants: a rate-limit diagnostic has to
894 /// name the call that was refused, and a `match` with a catch-all arm would answer a
895 /// document added later with "talking to GitHub" and never say so.
896 ///
897 /// `documents_are_all_inventoried` reads this file back and fails naming any `pub
898 /// const` here that this list omits, so the two cannot part — which is the same guard
899 /// `CATEGORIES` carries, in the one shape available to a set of `&str` constants.
900 pub const DOCUMENTS: [(&str, &str); 28] = [
901 (SEARCH_ISSUES, "searching this board's issues"),
902 (ISSUE, "reading one issue"),
903 (
904 ISSUE_BOARD_ITEMS,
905 "reading one issue's board memberships past the page it came with",
906 ),
907 (SUB_ISSUES, "reading a project's tasks"),
908 (BOARD, "reading the board"),
909 (BOARD_FIELDS, "reading the board's fields"),
910 (DRAFT, "reading one draft"),
911 (REPOSITORY, "reading the destination repository"),
912 (ISSUE_DEPENDENCIES, "reading an issue's dependencies"),
913 (CREATE_ISSUE, "creating an issue"),
914 (ADD_TO_BOARD, "adding an issue to the board"),
915 (UPDATE_ISSUE, "updating an issue"),
916 (UPDATE_DRAFT, "updating a draft item"),
917 (UPDATE_FIELD, "writing a board field"),
918 (CLEAR_FIELD, "clearing a board field"),
919 (
920 CREATE_FIELD,
921 "creating a board single-select field with its options",
922 ),
923 (
924 STATUS_OPTIONS_SNAPSHOT,
925 "snapshotting board Status options and assignments",
926 ),
927 (
928 STATUS_OPTIONS_UPDATE,
929 "safely replacing the board Status option list",
930 ),
931 (ADD_SUB_ISSUE, "filing an issue under its project"),
932 (REMOVE_SUB_ISSUE, "taking an issue out of its project"),
933 (ADD_BLOCKED_BY, "recording a dependency"),
934 (REMOVE_BLOCKED_BY, "removing a dependency"),
935 (DELETE_ISSUE, "deleting an issue"),
936 (ISSUE_COMMENTS, "reading a task's comments"),
937 (COMMENT_ISSUE, "reading which issue a comment is on"),
938 (ADD_COMMENT, "adding a comment"),
939 (UPDATE_COMMENT, "editing a comment"),
940 (DELETE_COMMENT, "deleting a comment"),
941 ];
942}
943
944/// Which of GitHub's two rate limiters refused a request.
945///
946/// Waiting is the whole answer to the primary budget, and polling is what *extends* the
947/// secondary one — so an operator told the wrong one takes the wrong next step, which is
948/// the whole reason this is carried rather than collapsed into "rate limited".
949#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950enum Limiter {
951 /// The hourly API budget, which `gh api rate_limit` reports and a wait answers.
952 Primary,
953 /// The burst limiter over content-generating requests, which nothing reports.
954 Secondary,
955}
956
957/// The wordings GitHub answers a secondary rate limit with.
958///
959/// It sends them under a forbidden status, under a too-many-requests status, and inside
960/// the `errors` of a *successful* response, which is why the text is what this matches on
961/// rather than the status. `abuse detection` is the wording GitHub used before the
962/// limiter was renamed and still returns from some endpoints; `submitted too quickly` is
963/// what a burst of content creation is refused with.
964///
965/// This is GitHub's vocabulary rather than this source's, so it is pinned rather than
966/// remembered: `tests/fixtures/rate-limits.json` records where each wording was read and
967/// when, and the drift gate reconciles the two lists both ways. Public for that gate
968/// alone — a caller has no use for it, and matching on a refusal is this source's job.
969pub const SECONDARY_WORDINGS: [&str; 5] = [
970 "secondary rate limit",
971 "temporarily blocked from content creation",
972 "abuse detection",
973 "submitted too quickly",
974 "exceeded a secondary",
975];
976
977/// The wordings GitHub answers an exhausted primary budget with.
978///
979/// `rate_limited` is the `type` its GraphQL error carries, which is read as a field rather
980/// than looked for in the response text. `api rate limit already exceeded` is what GraphQL
981/// answers a request made once the hour's budget is spent — "API rate limit already exceeded
982/// for user ID …" in the `errors` of an HTTP 200, with no `type` — and neither of the other
983/// two phrases is a substring of it, so without it that answer read as a refusal that will
984/// never lift. Pinned and gated exactly as [`SECONDARY_WORDINGS`] is, and public for the same
985/// one reason.
986pub const PRIMARY_WORDINGS: [&str; 4] = [
987 "api rate limit exceeded",
988 "api rate limit already exceeded",
989 "rate limit exceeded",
990 "rate_limited",
991];
992
993/// What a response *says about itself*, which is the only place a refusal can be read.
994///
995/// Deliberately not the whole response body. A board is a place people write about their
996/// own work, and a task on it titled "the secondary rate limit" would, matched across the
997/// raw text, turn a perfectly good answer into a refusal this source then waited out and
998/// reported. So the item data is never read: what is read is GitHub's own REST-style
999/// `message` envelope, which is what a forbidden status carries, and the `message` and
1000/// `type` of each GraphQL error, which is where a *successful* response says it.
1001///
1002/// A body that is not JSON at all has nothing structured to read, so only a failing
1003/// response's own text is taken — a successful response that is not JSON is malformed
1004/// rather than refused, and [`GitHubProjectsSource::answer`] says so.
1005fn refusal_wording(status: StatusCode, body: &str) -> String {
1006 let Ok(parsed) = serde_json::from_str::<Value>(body) else {
1007 return if status.is_success() {
1008 String::new()
1009 } else {
1010 body.to_owned()
1011 };
1012 };
1013 let mut said: Vec<&str> = parsed
1014 .get("message")
1015 .and_then(Value::as_str)
1016 .into_iter()
1017 .collect();
1018 if let Some(errors) = parsed.get("errors").and_then(Value::as_array) {
1019 for error in errors {
1020 said.extend(
1021 ["message", "type"]
1022 .into_iter()
1023 .filter_map(|key| error.get(key).and_then(Value::as_str)),
1024 );
1025 }
1026 }
1027 said.join("; ")
1028}
1029
1030impl Limiter {
1031 /// Which limiter refused this response, or `None` when none of them did.
1032 ///
1033 /// The wording is read first and the status only decides what carries none of it,
1034 /// because GitHub answers a secondary limit with a forbidden status far more often
1035 /// than with too-many-requests — while a forbidden status saying nothing about a limit
1036 /// really is a credential this token lacks.
1037 ///
1038 /// A response is a refusal because of its status or its own wording. A spent budget
1039 /// only ever explains one; it never turns an answer into a refusal.
1040 fn classify(status: StatusCode, budget_exhausted: bool, body: &str) -> Option<Self> {
1041 let normalized = refusal_wording(status, body).to_ascii_lowercase();
1042 if SECONDARY_WORDINGS
1043 .iter()
1044 .any(|wording| normalized.contains(wording))
1045 {
1046 return Some(Self::Secondary);
1047 }
1048 if status == StatusCode::TOO_MANY_REQUESTS {
1049 return Some(Self::Primary);
1050 }
1051 // An exhausted budget *explains* a response that failed; it does not make one that
1052 // succeeded into a failure. GitHub sets `x-ratelimit-remaining: 0` on the last
1053 // request the budget allowed as well as on the ones it then refuses, so reading
1054 // the header alone threw away a good answer — and, once refusals were retried,
1055 // replayed a request that had already taken effect.
1056 if !status.is_success() && budget_exhausted {
1057 return Some(Self::Primary);
1058 }
1059 // A successful response saying it: GitHub reports a GraphQL rate limit in the
1060 // `errors` of an HTTP 200, where nothing about the status says so at all.
1061 if status.is_success()
1062 && PRIMARY_WORDINGS
1063 .iter()
1064 .any(|wording| normalized.contains(wording))
1065 {
1066 return Some(Self::Primary);
1067 }
1068 None
1069 }
1070
1071 /// What this limiter is called where an operator can look it up.
1072 const fn name(self) -> &'static str {
1073 match self {
1074 Self::Primary => "GitHub's primary API rate limit",
1075 Self::Secondary => "GitHub's secondary rate limit",
1076 }
1077 }
1078
1079 /// What the endpoint an operator would go and check says about this limiter.
1080 const fn where_to_look(self) -> &'static str {
1081 match self {
1082 Self::Primary => {
1083 "That is the budget `gh api rate_limit` reports, so that endpoint says when it \
1084 comes back."
1085 }
1086 Self::Secondary => {
1087 "That limiter is not the primary API budget: `gh api rate_limit` reports the \
1088 primary budget and does not report this one, so budget showing there says \
1089 nothing about this refusal, and every further attempt extends it."
1090 }
1091 }
1092 }
1093
1094 /// The next step this limiter actually calls for.
1095 const fn what_to_do(self) -> &'static str {
1096 match self {
1097 Self::Primary => {
1098 "wait for the reset `gh api rate_limit` reports, then run the command again."
1099 }
1100 Self::Secondary => {
1101 "leave this board alone for a few minutes, then run the command again — or \
1102 raise pacing.min_mutation_interval_ms on this source so it writes more slowly."
1103 }
1104 }
1105 }
1106}
1107
1108/// One rate-limit refusal, and the wait GitHub asked for if it asked for one.
1109#[derive(Debug, Clone, Copy)]
1110struct Limited {
1111 limiter: Limiter,
1112 hint: Option<u64>,
1113}
1114
1115impl Limited {
1116 /// What the caller is told once this source has waited as long as it may.
1117 ///
1118 /// Both limiters report as [`SourceError::RateLimited`], because that is what
1119 /// happened: the kind a caller matches on says a rate limit refused this, and nothing
1120 /// about *which* limiter it was makes it a different kind of failure. What differs is
1121 /// the operator's next step, and that is what the message carries — a secondary
1122 /// refusal read as a primary one sends an operator to `gh api rate_limit`, where the
1123 /// budget looks fine, and then back to retry the very burst that was refused.
1124 fn exhausted(
1125 self,
1126 doing: &str,
1127 waits: u32,
1128 waited: Duration,
1129 needed: Duration,
1130 budget: Duration,
1131 ) -> SourceError {
1132 SourceError::RateLimited {
1133 retry_after_seconds: self.hint,
1134 message: Some(format!(
1135 "{} refused this source while {doing}; it waited {} out over {} and was refused \
1136 again, and the next wait of {} would take it past the {} one call may spend \
1137 waiting. {} next: {}",
1138 self.limiter.name(),
1139 plural(waits, "refusal"),
1140 seconds(waited),
1141 seconds(needed),
1142 seconds(budget),
1143 self.limiter.where_to_look(),
1144 self.limiter.what_to_do(),
1145 )),
1146 }
1147 }
1148}
1149
1150/// One HTTP attempt's result, with what its response said about the rate limit.
1151///
1152/// The two travel together so the record and the outcome are written from the same place:
1153/// what a response said about the budget is only readable while that response is in hand,
1154/// and what the attempt *meant* is only decidable once its body has been read.
1155struct Attempted {
1156 result: Result<Value, Attempt>,
1157 limits: accounting::RateLimit,
1158 /// GitHub's own reported cost for this call, for a document that asked for it.
1159 reported_cost: Option<u64>,
1160}
1161
1162/// One attempt's outcome: an error to report, or a rate limit to wait out.
1163enum Attempt {
1164 Failed(SourceError),
1165 Limited(Limited),
1166}
1167
1168fn plural(count: u32, thing: &str) -> String {
1169 if count == 1 {
1170 format!("{count} {thing}")
1171 } else {
1172 format!("{count} {thing}s")
1173 }
1174}
1175
1176fn seconds(duration: Duration) -> String {
1177 format!("{:.1}s", duration.as_secs_f64())
1178}
1179
1180/// A header GitHub spells as a whole number of seconds, or `None` when this one is not.
1181///
1182/// A value that is present and unreadable is deliberately *not* an error. `retry-after` is
1183/// allowed by HTTP to be a date rather than a count, an intermediary can rewrite either
1184/// header, and neither is what makes a response a refusal — so the whole cost of one this
1185/// cannot read is that the refusal carries no hint and the backing-off schedule answers it
1186/// instead. Refusing the response over the header would turn a readable refusal into an
1187/// unreadable one, and refusing to *wait* would be the one wrong direction to fail in.
1188fn whole_seconds(value: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
1189 value
1190 .and_then(|value| value.to_str().ok())
1191 .and_then(|value| value.trim().parse::<u64>().ok())
1192}
1193
1194/// Every mutation this source sends creates content — an issue, a board item, a field of
1195/// one, a sub-issue link, a dependency, a comment — or edits or removes content of that
1196/// kind, and no query in [`graphql::DOCUMENTS`] does, so what the secondary limiter counts
1197/// and what the keyword says are the same set. That is what makes the keyword a sound test
1198/// rather than a convenient one: pacing an edit or a removal the limiter might not have
1199/// counted costs a wait, and not pacing one it did count costs the next fifty minutes.
1200fn is_mutation(query: &str) -> bool {
1201 query.trim_start().starts_with("mutation")
1202}
1203
1204/// What this source was doing, for a diagnostic that has to say so.
1205///
1206/// Read out of [`graphql::DOCUMENTS`], which is the inventory rather than a copy of it, so
1207/// a document added without a description is caught by that list's own gate instead of
1208/// falling through to the vague arm below.
1209fn operation_description(query: &str) -> &'static str {
1210 graphql::DOCUMENTS
1211 .iter()
1212 .find(|(document, _)| *document == query)
1213 .map_or("talking to GitHub", |(_, doing)| *doing)
1214}
1215
1216/// GitHub's published ceiling on content-generating requests, per minute.
1217///
1218/// Pinned in `tests/fixtures/rate-limits.json` and gated against it, because it is
1219/// GitHub's number rather than this source's: [`MIN_MUTATION_INTERVAL_MS`] is *derived*
1220/// from it, so a pacing value checked only against itself cannot go stale here.
1221pub const CONTENT_CREATION_PER_MINUTE: u64 = 80;
1222/// The same ceiling as GitHub publishes it per hour, which this source does **not** pace
1223/// at. See [`MIN_MUTATION_INTERVAL_MS`] for why the per-minute bound is the one that
1224/// governs; it is pinned beside its sibling so the gate would notice either one moving.
1225pub const CONTENT_CREATION_PER_HOUR: u64 = 500;
1226/// Shortest interval between two content-creating mutations, in milliseconds.
1227///
1228/// GitHub documents two secondary limits on content-generating requests:
1229/// [`CONTENT_CREATION_PER_MINUTE`] and [`CONTENT_CREATION_PER_HOUR`]. 60000/80 is 750, so
1230/// a mutation every 750 ms is the fastest rate that cannot exceed the per-minute bound,
1231/// and that is the bound a copy actually trips: a copy of one plan-sized project is a
1232/// burst of a few dozen mutations inside a few seconds. The hourly bound works out at one
1233/// every 7.2 seconds sustained, which no single copy reaches and which, used as the
1234/// spacing here, would turn an ordinary copy into an hour of waiting — so it is
1235/// deliberately *not* what this paces at. An installation that wants the hourly bound
1236/// honoured for a long sequence of copies says so through
1237/// `pacing.min_mutation_interval_ms`.
1238pub const MIN_MUTATION_INTERVAL_MS: u64 = 60_000 / CONTENT_CREATION_PER_MINUTE;
1239/// First wait when a rate-limit refusal carries no hint; each further wait doubles it.
1240///
1241/// A doubling schedule from one second reaches a minute in six waits, which is GitHub's
1242/// own advice for a secondary limit — wait, and wait longer each time — without spending
1243/// the first minute of a transient refusal doing nothing.
1244pub const RETRY_BACKOFF_MS: u64 = 1_000;
1245/// Total time one call may spend waiting out rate limits before it reports a failure.
1246///
1247/// Two minutes is long enough to ride out the refusals a paced copy still collects and
1248/// short enough that a command an operator is watching returns. The bound is what makes
1249/// the wait a wait rather than a hang: a call refused past it ends in a diagnostic naming
1250/// the limiter, not in a process nobody can tell from a wedged one.
1251pub const RETRY_BUDGET_MS: u64 = 120_000;
1252
1253fn default_token_env() -> String {
1254 "GH_PROJECTS_TOKEN".to_owned()
1255}
1256fn default_endpoint() -> String {
1257 "https://api.github.com/graphql".to_owned()
1258}
1259
1260/// Where one status category lands on this board.
1261///
1262/// `null` — an absent value — disables the category for this instance, and using a
1263/// disabled status is a refusal naming the status and the instance.
1264#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
1265#[serde(untagged)]
1266pub enum StatusTargetConfig {
1267 /// The name of a `Status` single-select option already on the board.
1268 Column(ColumnName),
1269}
1270
1271/// The name of a `Status` single-select option on the board.
1272///
1273/// Validated on the way in rather than checked later, so a blank option name — which
1274/// nothing on a board can be — is a state this type cannot hold.
1275#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1276#[serde(try_from = "String")]
1277#[schemars(extend("minLength" = 1))]
1278pub struct ColumnName(String);
1279
1280impl ColumnName {
1281 /// The option name, as the board spells it.
1282 fn as_str(&self) -> &str {
1283 &self.0
1284 }
1285}
1286
1287impl TryFrom<String> for ColumnName {
1288 type Error = String;
1289
1290 fn try_from(name: String) -> Result<Self, Self::Error> {
1291 if name.trim().is_empty() {
1292 return Err("a status_mapping option name cannot be blank".to_owned());
1293 }
1294 Ok(Self(name))
1295 }
1296}
1297
1298/// The two closed states this product can mean.
1299///
1300/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
1301/// work nor abandoned work, so nothing here ever writes it.
1302#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
1303#[serde(rename_all = "kebab-case")]
1304pub enum ClosedState {
1305 /// `COMPLETED` — precisely done.
1306 Completed,
1307 /// `NOT_PLANNED` — precisely cancelled.
1308 NotPlanned,
1309}
1310
1311impl ClosedState {
1312 const fn reason(self) -> &'static str {
1313 match self {
1314 Self::Completed => "COMPLETED",
1315 Self::NotPlanned => "NOT_PLANNED",
1316 }
1317 }
1318}
1319
1320/// Configuration for one GitHub Projects v2 board.
1321#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1322#[serde(default, deny_unknown_fields)]
1323pub struct GitHubProjectsConfig {
1324 /// Login of the user or organization which owns the board.
1325 pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
1326 /// The project number shown in the board's GitHub URL.
1327 pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
1328 // 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.
1329 /// `owner/name` of the repository this source creates an issue in when the item's own
1330 /// `repositories` field does not decide it.
1331 ///
1332 /// An item naming exactly one repository is created there; a task or a document naming
1333 /// none or several is created in its parent project's repository; and a project, or a
1334 /// task or document with no parent, naming none or several is created here. A board
1335 /// has no repository of its own and `createIssue` requires one, so a write without
1336 /// this is refused naming the field. Reads never need it.
1337 pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
1338 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
1339 /// Environment variable containing a fine-grained token with Projects and Issues
1340 /// read/write plus Pull requests read-only access for every repository represented on
1341 /// the board.
1342 #[serde(default = "default_token_env")]
1343 pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
1344 /// GraphQL endpoint. GitHub Enterprise installations may override it.
1345 #[serde(default = "default_endpoint")]
1346 pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
1347 /// Per-instance mapping from a status category to where it lands on this board.
1348 ///
1349 /// A category this does not mention keeps its shipped default: `backlog` to
1350 /// "Backlog", `todo` to "Todo", `queued` to "Queued", `in-progress` to "In Progress",
1351 /// `done` to "Done" plus closed as completed, `cancelled` to "Cancelled" plus closed
1352 /// as not planned, and `draft` and `unknown` disabled. `unknown` may name one existing
1353 /// board option; every unknown word then lands on that option and reads back as
1354 /// `unknown` under its name. Unlike `local-md`, this source cannot keep each unknown
1355 /// word because it never creates board options.
1356 #[serde(default)]
1357 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.
1358 /// Per-instance mapping from a task's priority to an option of this board's
1359 /// single-select field named `Priority`.
1360 ///
1361 /// Absent, this source holds no priority: every task reads as `none`, and a write of any
1362 /// other priority is refused before it reaches this board. Present, each of `urgent`,
1363 /// `high`, `medium` and `low` it does not mention keeps its shipped default — `Urgent`,
1364 /// `High`, `Medium` and `Low` — and an item with no value in the `Priority` field reads
1365 /// as `none`, so writing `none` clears the value. Option names match case-insensitively;
1366 /// no two levels may name one option. Reads and writes never create the field or an
1367 /// option: `onetaskgraph sources fields <source> --apply` does, and a write naming one
1368 /// the board lacks is refused pointing there.
1369 #[serde(default)]
1370 pub priority_mapping: Option<PriorityMappingConfig>,
1371 /// How fast this source writes, and how long it waits out a rate-limit refusal.
1372 ///
1373 /// Every field keeps its shipped default when it is absent, and the defaults are
1374 /// GitHub's own published limits rather than taste. See [`Pacing`].
1375 #[serde(default)]
1376 pub pacing: PacingConfig,
1377}
1378
1379/// Which option of the board's `Priority` field each priority lands on.
1380///
1381/// One member per level rather than a map, so a key that is not a level is refused where
1382/// the configuration is read, naming the levels there are. `none` is not a member: it is no
1383/// value in the field, not an option of it.
1384#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1385#[serde(default, deny_unknown_fields)]
1386pub struct PriorityMappingConfig {
1387 /// The option `urgent` lands on; `Urgent` when absent.
1388 pub urgent: Option<PriorityOptionName>,
1389 /// The option `high` lands on; `High` when absent.
1390 pub high: Option<PriorityOptionName>,
1391 /// The option `medium` lands on; `Medium` when absent.
1392 pub medium: Option<PriorityOptionName>,
1393 /// The option `low` lands on; `Low` when absent.
1394 pub low: Option<PriorityOptionName>,
1395}
1396
1397/// The name of an option of the board's `Priority` single-select field.
1398///
1399/// Validated on the way in, for the reason [`ColumnName`] is: nothing on a board can have a
1400/// blank name.
1401#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
1402#[serde(try_from = "String")]
1403#[schemars(extend("minLength" = 1))]
1404pub struct PriorityOptionName(String);
1405
1406impl PriorityOptionName {
1407 /// The option name, as the board spells it.
1408 fn as_str(&self) -> &str {
1409 &self.0
1410 }
1411}
1412
1413impl TryFrom<String> for PriorityOptionName {
1414 type Error = String;
1415
1416 fn try_from(name: String) -> Result<Self, Self::Error> {
1417 if name.trim().is_empty() {
1418 return Err("a priority_mapping option name cannot be blank".to_owned());
1419 }
1420 Ok(Self(name))
1421 }
1422}
1423
1424/// The name of the board field a priority is held in.
1425pub const PRIORITY_FIELD: &str = "Priority";
1426
1427/// The four priorities a board option can hold, in the order a new `Priority` field lists
1428/// them. `none` is not among them: it is the field holding no value.
1429///
1430/// This list mirrors `Priority`, so it carries its own drift gate, in the shape [`CATEGORIES`]
1431/// does: [`level_position`] is a wildcard-free match, so a priority added to the shared
1432/// vocabulary fails to compile until it is placed there, and this crate's suite reconciles
1433/// this list and [`PriorityMappingConfig`]'s members against that enum's own derived schema.
1434pub const PRIORITY_LEVELS: [Priority; 4] = [
1435 Priority::Urgent,
1436 Priority::High,
1437 Priority::Medium,
1438 Priority::Low,
1439];
1440
1441/// Where one priority sits in [`PRIORITY_LEVELS`], or `None` for `none`, which is no option;
1442/// see that list for what this pins.
1443#[must_use]
1444pub const fn level_position(priority: Priority) -> Option<usize> {
1445 match priority {
1446 Priority::None => None,
1447 Priority::Urgent => Some(0),
1448 Priority::High => Some(1),
1449 Priority::Medium => Some(2),
1450 Priority::Low => Some(3),
1451 }
1452}
1453
1454/// This instance's complete priority-to-option mapping, read in both directions.
1455///
1456/// One option per level, held in [`PRIORITY_LEVELS`] order, once it is established that no
1457/// two levels name one option.
1458#[derive(Debug, Clone)]
1459struct PriorityMapping {
1460 options: [PriorityOptionName; 4],
1461}
1462
1463impl PriorityMapping {
1464 fn resolve(config: PriorityMappingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1465 let shipped = |name: &str| PriorityOptionName(name.to_owned());
1466 let mapping = Self {
1467 options: [
1468 config.urgent.unwrap_or_else(|| shipped("Urgent")),
1469 config.high.unwrap_or_else(|| shipped("High")),
1470 config.medium.unwrap_or_else(|| shipped("Medium")),
1471 config.low.unwrap_or_else(|| shipped("Low")),
1472 ],
1473 };
1474 for (index, option) in mapping.options.iter().enumerate() {
1475 if let Some(earlier) = mapping.options[..index]
1476 .iter()
1477 .position(|other| other.as_str().eq_ignore_ascii_case(option.as_str()))
1478 {
1479 return Err(SourceError::Config {
1480 message: format!(
1481 "priority_mapping of source {instance} sends both {} and {} to the board \
1482 option {:?}; one option cannot read back as two priorities",
1483 PRIORITY_LEVELS[earlier],
1484 PRIORITY_LEVELS[index],
1485 option.as_str()
1486 ),
1487 });
1488 }
1489 }
1490 Ok(mapping)
1491 }
1492
1493 /// The option `priority` lands on, or `None` for `none`, which is no option at all.
1494 fn option(&self, priority: Priority) -> Option<&str> {
1495 level_position(priority).map(|index| self.options[index].as_str())
1496 }
1497
1498 /// The priority a board option name reports, or `None` when nothing maps to it.
1499 fn priority_of(&self, option: &str) -> Option<Priority> {
1500 self.options
1501 .iter()
1502 .position(|name| name.as_str().eq_ignore_ascii_case(option))
1503 .map(|index| PRIORITY_LEVELS[index])
1504 }
1505
1506 /// Every mapped option name, in the order a new `Priority` field lists them.
1507 fn names(&self) -> impl Iterator<Item = &str> {
1508 self.options.iter().map(PriorityOptionName::as_str)
1509 }
1510}
1511
1512/// What one item's `Priority` field says, read through this instance's mapping.
1513#[derive(Debug, Clone, PartialEq, Eq)]
1514enum HeldPriority {
1515 /// A priority this source reports: an option the mapping names, or no value (`none`).
1516 Read(Priority),
1517 /// An option the mapping does not name, which is never read as a level or as `none`.
1518 Unmapped(String),
1519}
1520
1521/// How fast this source writes, and how long it waits out a rate-limit refusal.
1522///
1523/// Configurable because a GitHub Enterprise installation sets its own limits and an
1524/// operator who has already been refused may want to go slower still — not because the
1525/// defaults are guesses.
1526#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
1527#[serde(default, deny_unknown_fields)]
1528pub struct PacingConfig {
1529 /// Shortest interval between two content-creating mutations, in milliseconds.
1530 ///
1531 /// Zero sends them as fast as they are asked for, which is what a fixture server on
1532 /// loopback wants and what no board on github.com does. At most [`MAX_PACING_MS`].
1533 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.
1534 /// First wait when a rate-limit refusal carries no hint, in milliseconds. Each
1535 /// further wait of the same call doubles it. At most [`MAX_PACING_MS`], and never
1536 /// zero while there is a budget to spend, because a schedule of zero-length waits
1537 /// consumes none of it and so never ends.
1538 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.
1539 /// Total time one call may spend waiting out rate limits, in milliseconds.
1540 ///
1541 /// Zero reports the refusal rather than waiting at all. At most [`MAX_PACING_MS`]:
1542 /// the bound is what makes this a wait rather than a hang.
1543 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.
1544}
1545
1546/// The largest any pacing setting may be, in milliseconds.
1547///
1548/// One hour. GitHub's own harshest published bound on content-generating requests works
1549/// out at one every 7.2 seconds, so an hour is already three orders of magnitude past
1550/// anything a real limit asks for, and past it the settings stop describing pacing at all:
1551/// a wait budget beyond it is the unbounded wait this whole mechanism exists to replace,
1552/// and an interval beyond it is a command that never sends its second mutation. It also
1553/// keeps the clock arithmetic in [`GitHubProjectsSource::reserve_mutation_slot`] inside
1554/// what an `Instant` can hold on every platform.
1555pub const MAX_PACING_MS: u64 = 3_600_000;
1556
1557/// [`PacingConfig`] with every default resolved and every value checked, which is what the
1558/// source holds.
1559#[derive(Debug, Clone, Copy)]
1560struct Pacing {
1561 min_mutation_interval: Duration,
1562 retry_backoff: Duration,
1563 retry_budget: Duration,
1564}
1565
1566impl Pacing {
1567 /// Resolve one instance's pacing, refusing a configuration that would not pace at all.
1568 fn resolve(config: PacingConfig, instance: &SourceName) -> Result<Self, SourceError> {
1569 let bounded = |value: Option<u64>, default: u64, field: &str| match value {
1570 Some(value) if value > MAX_PACING_MS => Err(SourceError::Config {
1571 message: format!(
1572 "pacing.{field} of source {instance} is {value} ms, and the most any pacing \
1573 setting may be is {MAX_PACING_MS} ms — an hour, which is already far past \
1574 GitHub's own harshest published limit"
1575 ),
1576 }),
1577 Some(value) => Ok(Duration::from_millis(value)),
1578 None => Ok(Duration::from_millis(default)),
1579 };
1580 let retry_backoff = bounded(
1581 config.retry_backoff_ms,
1582 RETRY_BACKOFF_MS,
1583 "retry_backoff_ms",
1584 )?;
1585 let retry_budget = bounded(config.retry_budget_ms, RETRY_BUDGET_MS, "retry_budget_ms")?;
1586 if retry_backoff.is_zero() && !retry_budget.is_zero() {
1587 return Err(SourceError::Config {
1588 message: format!(
1589 "pacing.retry_backoff_ms of source {instance} is 0 while \
1590 pacing.retry_budget_ms is {} ms; a schedule of zero-length waits spends \
1591 none of that budget, so it would retry a refusal forever. Set a backoff of \
1592 at least 1 ms, or set retry_budget_ms to 0 to report a refusal without \
1593 waiting at all",
1594 retry_budget.as_millis()
1595 ),
1596 });
1597 }
1598 Ok(Self {
1599 min_mutation_interval: bounded(
1600 config.min_mutation_interval_ms,
1601 MIN_MUTATION_INTERVAL_MS,
1602 "min_mutation_interval_ms",
1603 )?,
1604 retry_backoff,
1605 retry_budget,
1606 })
1607 }
1608}
1609
1610/// Factory for [`GitHubProjectsSource`].
1611#[derive(Debug, Clone, Copy, Default)]
1612pub struct Plugin;
1613
1614impl SourcePlugin for Plugin {
1615 fn kind(&self) -> &'static str {
1616 KIND
1617 }
1618 fn config_schema(&self) -> Schema {
1619 schema_for!(GitHubProjectsConfig)
1620 }
1621 fn build(
1622 &self,
1623 name: &SourceName,
1624 config: &Value,
1625 secrets: &dyn SecretResolver,
1626 ) -> Result<Box<dyn TaskSource>, SourceError> {
1627 self.build_recording_into(name, config, secrets, Arc::new(Accounting::new()))
1628 }
1629}
1630
1631impl Plugin {
1632 /// Build a source recording every request it sends into an accounting the caller holds.
1633 ///
1634 /// [`SourcePlugin::build`] is this with an accounting of its own, which is what the
1635 /// registry gets. This is for a caller that is also calling GitHub itself and wants one
1636 /// session total rather than two — see [`accounting`] and
1637 /// [`GitHubProjectsSource::recording_into`].
1638 ///
1639 /// # Errors
1640 ///
1641 /// Exactly [`SourcePlugin::build`]'s, with the same source name in front of each:
1642 /// [`SourceError::Config`] for configuration this plugin cannot use and
1643 /// [`SourceError::Auth`] for a credential it cannot find.
1644 pub fn build_recording_into(
1645 &self,
1646 name: &SourceName,
1647 config: &Value,
1648 secrets: &dyn SecretResolver,
1649 ledger: Arc<Accounting>,
1650 ) -> Result<Box<dyn TaskSource>, SourceError> {
1651 let config: GitHubProjectsConfig =
1652 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
1653 message: format!("source {name}: {e}"),
1654 })?;
1655 let source = GitHubProjectsSource::recording_into(name, config, secrets, ledger).map_err(
1656 |error| match error {
1657 SourceError::Config { message } => SourceError::Config {
1658 message: format!("source {name}: {message}"),
1659 },
1660 SourceError::Auth { message } => SourceError::Auth {
1661 message: format!("source {name}: {message}"),
1662 },
1663 other => other,
1664 },
1665 )?;
1666 Ok(Box::new(source))
1667 }
1668}
1669
1670/// Where a status category lands on this board, once configuration is resolved.
1671#[derive(Debug, Clone, PartialEq, Eq)]
1672enum StatusTarget {
1673 /// Not usable against this instance.
1674 Disabled,
1675 /// The board's `Status` option of this name.
1676 Column(ColumnName),
1677 /// A closed issue, with both its board option and the reason that says which closed it means.
1678 // 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.
1679 Terminal(ColumnName, ClosedState),
1680}
1681
1682/// Every status category, in the order the vocabulary declares them.
1683///
1684/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
1685/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
1686/// added to the shared vocabulary fails to compile until it is named there, and this
1687/// crate's suite reconciles this list against that enum's own derived schema, which is
1688/// generated from the variants rather than written beside them. The schema is what
1689/// catches a list left one short — a list checking only the positions it already holds
1690/// would pass while every mapping indexed by the new position panicked.
1691pub const CATEGORIES: [StatusCategory; 8] = [
1692 StatusCategory::Draft,
1693 StatusCategory::Backlog,
1694 StatusCategory::Todo,
1695 StatusCategory::Queued,
1696 StatusCategory::InProgress,
1697 StatusCategory::Done,
1698 StatusCategory::Cancelled,
1699 StatusCategory::Unknown,
1700];
1701
1702/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
1703#[must_use]
1704pub const fn category_position(category: StatusCategory) -> usize {
1705 match category {
1706 StatusCategory::Draft => 0,
1707 StatusCategory::Backlog => 1,
1708 StatusCategory::Todo => 2,
1709 StatusCategory::Queued => 3,
1710 StatusCategory::InProgress => 4,
1711 StatusCategory::Done => 5,
1712 StatusCategory::Cancelled => 6,
1713 StatusCategory::Unknown => 7,
1714 }
1715}
1716
1717/// The spelling a status category is configured and reported under.
1718fn category_name(category: StatusCategory) -> &'static str {
1719 match category {
1720 StatusCategory::Draft => "draft",
1721 StatusCategory::Backlog => "backlog",
1722 StatusCategory::Todo => "todo",
1723 StatusCategory::Queued => "queued",
1724 StatusCategory::InProgress => "in-progress",
1725 StatusCategory::Done => "done",
1726 StatusCategory::Cancelled => "cancelled",
1727 StatusCategory::Unknown => "unknown",
1728 }
1729}
1730
1731/// A shipped default's option name.
1732///
1733/// The literals below are this file's own and non-blank, and they are validated by the
1734/// one constructor a configured name goes through rather than beside it.
1735fn shipped_column(name: &'static str) -> ColumnName {
1736 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
1737}
1738
1739/// The shipped default for one category, before this instance's configuration.
1740fn shipped_default(category: StatusCategory) -> StatusTarget {
1741 match category {
1742 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
1743 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
1744 StatusCategory::Queued => StatusTarget::Column(shipped_column("Queued")),
1745 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
1746 StatusCategory::Done => {
1747 StatusTarget::Terminal(shipped_column("Done"), ClosedState::Completed)
1748 }
1749 StatusCategory::Cancelled => {
1750 StatusTarget::Terminal(shipped_column("Cancelled"), ClosedState::NotPlanned)
1751 }
1752 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
1753 }
1754}
1755
1756/// This instance's complete category-to-target mapping, read in both directions.
1757///
1758/// One target per category, held at that category's own [`category_position`], so a
1759/// category missing from the mapping, named twice in it, or filed out of order is a
1760/// state this type cannot hold rather than one [`Self::target`] has to defend against.
1761#[derive(Debug, Clone)]
1762struct StatusMapping {
1763 targets: [StatusTarget; CATEGORIES.len()],
1764}
1765
1766impl StatusMapping {
1767 fn resolve(
1768 configured: BTreeMap<String, Option<StatusTargetConfig>>,
1769 instance: &SourceName,
1770 ) -> Result<Self, SourceError> {
1771 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
1772 for (key, value) in configured {
1773 let category = CATEGORIES
1774 .iter()
1775 .find(|category| category_name(**category) == key)
1776 .ok_or_else(|| SourceError::Config {
1777 message: format!(
1778 "status_mapping names {key:?}, which is not a status category of source \
1779 {instance}; the categories are {}",
1780 CATEGORIES
1781 .iter()
1782 .map(|category| category_name(*category))
1783 .collect::<Vec<_>>()
1784 .join(", ")
1785 ),
1786 })?;
1787 overrides.insert(category_name(*category), value);
1788 }
1789 // `CATEGORIES[position] == category` for every category — the crate's suite
1790 // asserts it — so mapping the list in order fills each category's own slot.
1791 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
1792 None => shipped_default(category),
1793 Some(None) => StatusTarget::Disabled,
1794 Some(Some(StatusTargetConfig::Column(option))) => match category {
1795 StatusCategory::Done => StatusTarget::Terminal(option, ClosedState::Completed),
1796 StatusCategory::Cancelled => {
1797 StatusTarget::Terminal(option, ClosedState::NotPlanned)
1798 }
1799 _ => StatusTarget::Column(option),
1800 },
1801 });
1802 let mapping = Self { targets };
1803 for (index, category) in CATEGORIES.into_iter().enumerate() {
1804 let option = match mapping.target(category) {
1805 StatusTarget::Column(option) | StatusTarget::Terminal(option, _) => option,
1806 StatusTarget::Disabled => continue,
1807 };
1808 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
1809 matches!(mapping.target(**earlier), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1810 if name.as_str().eq_ignore_ascii_case(option.as_str()))
1811 }) {
1812 return Err(SourceError::Config {
1813 message: format!(
1814 "status_mapping of source {instance} sends both {} and {} to the board \
1815 option {:?}; one option cannot read back as two categories",
1816 category_name(*other),
1817 category_name(category),
1818 option.as_str()
1819 ),
1820 });
1821 }
1822 }
1823 Ok(mapping)
1824 }
1825
1826 fn target(&self, category: StatusCategory) -> &StatusTarget {
1827 &self.targets[category_position(category)]
1828 }
1829
1830 /// The category a board option name reports, or `None` when nothing maps to it.
1831 fn category_of(&self, option: &str) -> Option<StatusCategory> {
1832 CATEGORIES.into_iter().find(|category| {
1833 matches!(self.target(*category), StatusTarget::Column(name) | StatusTarget::Terminal(name, _)
1834 if name.as_str().eq_ignore_ascii_case(option))
1835 })
1836 }
1837
1838 /// The status an item reports, from the three things a read of it says: its board
1839 /// `Status` option, whether its issue is closed, and the reason it was closed with.
1840 ///
1841 /// The closed state decides the category and the `Status` option decides the name, so
1842 /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
1843 /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
1844 /// duplicate is not finished work, and calling it done is a lie the next copy would
1845 /// write back. `REOPENED`-while-closed is a state this source can never produce, so
1846 /// it is read permissively rather than refused — reads are faithful, and refusals
1847 /// belong on writes.
1848 ///
1849 /// One function of those three rather than of a response, so a narrow status write can
1850 /// answer what a re-read would report by applying it to the state it has just written.
1851 fn status(&self, option: Option<&str>, closed: bool, reason: Option<&str>) -> Status {
1852 if closed {
1853 let category = match reason {
1854 None | Some("COMPLETED") => StatusCategory::Done,
1855 Some("NOT_PLANNED") => StatusCategory::Cancelled,
1856 Some(_) => StatusCategory::Unknown,
1857 };
1858 let fallback = match category {
1859 StatusCategory::Done => "Done",
1860 StatusCategory::Cancelled => "Cancelled",
1861 _ => "Closed",
1862 };
1863 return Status {
1864 category,
1865 name: option.unwrap_or(fallback).to_owned(),
1866 };
1867 }
1868 let name = option.unwrap_or("Open").to_owned();
1869 Status {
1870 category: self.category_of(&name).unwrap_or(StatusCategory::Unknown),
1871 name,
1872 }
1873 }
1874}
1875
1876// 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.
1877/// One repository this source can create an issue in, as `owner/name`.
1878///
1879/// Every `createIssue` this source sends names one of these: the item's own single
1880/// `repositories` entry, else its parent project issue's repository, else the configured
1881/// [`GitHubProjectsConfig::repository`]. [`GitHubProjectsSource::creation_target`] makes
1882/// that choice and says what it refuses before `createIssue`.
1883// llmlint: ignore-end[comments_earn_their_place, contracts_have_one_source_or_a_drift_gate]
1884#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1885struct RepositoryTarget {
1886 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1887 name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
1888}
1889
1890impl RepositoryTarget {
1891 fn parse(value: &str) -> Result<Self, SourceError> {
1892 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
1893 message: format!(
1894 "repository must be spelled owner/name; {value:?} names no repository"
1895 ),
1896 })?;
1897 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
1898 return Err(SourceError::Config {
1899 message: format!(
1900 "repository must be spelled owner/name with a GitHub login and one \
1901 repository name; {value:?} is not"
1902 ),
1903 });
1904 }
1905 Ok(Self {
1906 owner: owner.to_owned(),
1907 name: name.to_owned(),
1908 })
1909 }
1910
1911 /// The one host whose repositories this source creates issues in, spelled once: it is
1912 /// what [`Self::origin`] renders and what [`Self::from_origin`] accepts.
1913 const HOST: &str = "github.com";
1914
1915 fn origin(&self) -> String {
1916 format!("{}/{}/{}", Self::HOST, self.owner, self.name)
1917 }
1918
1919 /// The repository a normalized origin names, or why it is none this source can create
1920 /// an issue in: another host, or more or fewer than `owner/name` under this one.
1921 fn from_origin(origin: &Repository) -> Result<Self, String> {
1922 let not_here = || {
1923 format!(
1924 "{} is not a {}/owner/name repository",
1925 origin.as_str(),
1926 Self::HOST
1927 )
1928 };
1929 let (host, rest) = origin.as_str().split_once('/').ok_or_else(not_here)?;
1930 if host != Self::HOST {
1931 return Err(not_here());
1932 }
1933 Self::parse(rest).map_err(|_| not_here())
1934 }
1935
1936 fn slug(&self) -> String {
1937 format!("{}/{}", self.owner, self.name)
1938 }
1939}
1940
1941/// A source which reads GitHub afresh for every operation.
1942pub struct GitHubProjectsSource {
1943 /// This source's configured name, used both to tell a far end naming this source
1944 /// from one naming a system it knows nothing about, and to name the instance a
1945 /// status refusal is about.
1946 name: SourceName,
1947 owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
1948 project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
1949 repository: Option<RepositoryTarget>,
1950 endpoint: Url,
1951 token: SecretString,
1952 credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
1953 statuses: StatusMapping,
1954 /// Where each priority lands on this board, or `None` when this instance holds none.
1955 priorities: Option<PriorityMapping>,
1956 client: Client,
1957 /// Every item this source has created since it was built, in the order it created
1958 /// them.
1959 ///
1960 /// GitHub's `projectV2.items` is eventually consistent: an issue added to a board with
1961 /// `addProjectV2ItemById` is routinely absent from the very next read of that board, so
1962 /// a copy resolving a dependency on an item it had just created refused it as not
1963 /// found. A board read is completed from this — an item remembered here and absent from
1964 /// the read is added back, because the board really does hold it and only the read is
1965 /// behind.
1966 ///
1967 /// It is not a cache of a user's work: nothing is remembered that this process did not
1968 /// itself just write, it lives and dies with the process, and it is never consulted for
1969 /// an item this source did not create.
1970 created: Mutex<Vec<Resolved>>,
1971 /// How fast this source writes, and how long it waits out a refusal.
1972 pacing: Pacing,
1973 /// When the last content-creating mutation finished, or the moment the furthest-out
1974 /// reserved slot releases the next one, whichever is later — so the one after it can be
1975 /// spaced from that. See [`MIN_MUTATION_INTERVAL_MS`] for the interval and
1976 /// [`GitHubProjectsSource::finish_mutation`] for why completion rather than release is
1977 /// what it is measured from.
1978 last_mutation: Mutex<Option<Instant>>,
1979 /// The board as this process last read it, for the length of one command.
1980 ///
1981 /// A copy of a project used to re-read the whole board, paged, before writing each of
1982 /// its items, which is by far the largest part of a copy's request count and none of
1983 /// its work. Nothing else changes this board while a command runs — this source's own
1984 /// writes are the only writer — so one read answers them all.
1985 ///
1986 /// It is not a store of a user's work and it is not the cache the no-persistence
1987 /// invariant forbids: it lives and dies with the process exactly as `created` does,
1988 /// nothing is written down, and [`Self::board`] still completes it from `created`, so
1989 /// an item this command created and then depends on resolves whether or not GitHub's
1990 /// own eventually-consistent read has caught up. A write to an item already on the
1991 /// board updates the entry here too, so what this holds is the last read plus this
1992 /// process's own writes rather than a snapshot taken before them.
1993 board_cache: Mutex<Option<Board>>,
1994 /// Every issue this board's own search reported, for the length of one command.
1995 ///
1996 /// The second half of a board read, and cached for the same reason and on the same
1997 /// terms as the first: it lives and dies with the process, nothing is written down, and
1998 /// a write this process makes updates the entry here exactly as it updates the one in
1999 /// [`Self::board_cache`]. One read answers every question a command asks, so a command
2000 /// that lists this board's projects and its tasks pays for one search rather than two.
2001 search_cache: Mutex<Option<Vec<Resolved>>>,
2002 /// The board's own id and field definitions as this process last read them on their
2003 /// own, for the length of one command.
2004 ///
2005 /// What a write needs of the board and its item does not say, read once per command
2006 /// rather than once per item written, on the terms [`Self::board_cache`] is held on: it
2007 /// lives and dies with the process and nothing is written down. It holds no item and so
2008 /// can answer no question about one — see [`Self::board_fields`].
2009 fields_cache: Mutex<Option<BoardFields>>,
2010 /// Each destination repository's node id, resolved once per repository
2011 /// rather than per issue created.
2012 ///
2013 /// A repository's node id does not change, and re-reading it for every issue of a copy
2014 /// spent one request per item on an answer this source already had. It is a map rather
2015 /// than one entry because a copy files each item in the repository its own
2016 /// `repositories` field names, so a plan across five repositories asks GitHub five
2017 /// times and not once per item.
2018 repository_cache: Mutex<BTreeMap<RepositoryTarget, String>>,
2019 /// What every request this source sends is recorded into.
2020 ///
2021 /// Ordinary code path, not a mode: [`Self::send_once`] records into it at the one place
2022 /// a request leaves this crate, so nothing has to be switched on for a session to be
2023 /// counted. It is shared rather than owned so a caller accounting for a whole session —
2024 /// its own schema verification, board lookups, residue sweep and cleanup beside this
2025 /// source's reads and writes — adds up one accounting instead of two. See
2026 /// [`accounting`] for what a record carries and what a session's spend is and is not.
2027 ledger: Arc<Accounting>,
2028}
2029
2030/// GitHub's closed single-select color vocabulary.
2031#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
2032#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2033pub enum StatusOptionColor {
2034 /// Gray.
2035 Gray,
2036 /// Blue.
2037 Blue,
2038 /// Green.
2039 Green,
2040 /// Yellow.
2041 Yellow,
2042 /// Purple.
2043 Purple,
2044 /// Red.
2045 Red,
2046 /// Orange.
2047 Orange,
2048 /// Pink.
2049 Pink,
2050}
2051
2052/// Whether a guarded board setup — of the fields, or of the Status options alone — plans or
2053/// applies its additions.
2054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2055pub enum SetupMode {
2056 /// Read without mutation.
2057 Plan,
2058 /// Apply and verify.
2059 Apply,
2060}
2061
2062/// The name [`SetupMode`] had when Status was the one field set up, kept so a caller written
2063/// against it goes on compiling.
2064pub type StatusOptionsMode = SetupMode;
2065
2066/// The explicit result of the requested operation.
2067#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2068#[serde(rename_all = "kebab-case")]
2069pub enum StatusOptionsOutcome {
2070 /// A read-only plan.
2071 Planned,
2072 /// Apply found nothing missing.
2073 Unchanged,
2074 /// Additions were applied and verified.
2075 Applied,
2076}
2077
2078/// A GitHub single-select option's opaque GraphQL node identifier.
2079#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2080#[serde(transparent)]
2081pub struct StatusOptionId(#[schemars(length(min = 1))] String);
2082
2083impl TryFrom<String> for StatusOptionId {
2084 type Error = String;
2085
2086 fn try_from(id: String) -> Result<Self, Self::Error> {
2087 if id.trim().is_empty() {
2088 return Err("a GitHub Status option id cannot be blank".to_owned());
2089 }
2090 Ok(Self(id))
2091 }
2092}
2093
2094/// One existing or proposed option in a guarded Status-field update.
2095#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2096pub struct StatusOption {
2097 /// GitHub's stable id.
2098 pub id: StatusOptionId,
2099 /// The visible option name.
2100 pub name: ColumnName,
2101 /// GitHub's single-select color token.
2102 pub color: StatusOptionColor,
2103 /// The option description, including an empty one.
2104 pub description: String,
2105}
2106
2107/// One board item's Status assignment, retained as recovery data.
2108#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2109pub struct StatusAssignment {
2110 /// The project item id whose assignment this is.
2111 // llmlint: ignore[invalid_states_unrepresentable] This opaque GraphQL node ID is
2112 // carried verbatim as operator recovery data; introducing a semantic type would claim
2113 // validation rules GitHub does not publish and no operation here interprets.
2114 pub item_id: String,
2115 /// The selected option, absent when the item has no status.
2116 #[serde(skip_serializing_if = "Option::is_none")]
2117 pub option: Option<AssignedStatusOption>,
2118}
2119
2120/// The inseparable id and name of an assigned option.
2121#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2122pub struct AssignedStatusOption {
2123 /// GitHub's stable id.
2124 pub id: StatusOptionId,
2125 /// The visible name.
2126 pub name: ColumnName,
2127}
2128
2129/// The plan and verified outcome of reconciling configured Status options.
2130#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2131pub struct StatusOptionsReport {
2132 /// The configured source name.
2133 pub source: SourceName,
2134 /// Configured option names absent before the operation.
2135 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a
2136 // `ColumnName` and has therefore already passed its nonblank validation; retaining the
2137 // serialized string here preserves the report's intentionally simple public contract.
2138 pub missing: Vec<String>,
2139 /// What the requested operation did.
2140 pub outcome: StatusOptionsOutcome,
2141 /// The complete option list observed before any mutation.
2142 pub existing: Vec<StatusOption>,
2143}
2144
2145#[derive(Debug, Clone, PartialEq, Eq)]
2146struct StatusSnapshot {
2147 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2148 // passed back as the mutation's project identity; a newtype could enforce no stronger
2149 // invariant because GitHub publishes no grammar for it.
2150 board_id: String,
2151 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2152 // passed back as the mutation's field identity; a newtype could enforce no stronger
2153 // invariant because GitHub publishes no grammar for it.
2154 field_id: String,
2155 options: Vec<StatusOption>,
2156 assignments: Vec<StatusAssignment>,
2157}
2158
2159/// The name of the board field a status is held in.
2160const STATUS_FIELD: &str = "Status";
2161
2162/// Every item's value of each field `report` names, as it stood before the setup wrote
2163/// anything — what a person puts back when the setup is refused part way.
2164fn recovery(report: &FieldsReport, before: &BoardSnapshot) -> Result<String, SourceError> {
2165 let assignments: BTreeMap<&str, Vec<StatusAssignment>> = report
2166 .fields
2167 .iter()
2168 .map(|field| (field.field.name(), before.assignments(field.field)))
2169 .collect();
2170 serde_json::to_string_pretty(&assignments).map_err(|error| SourceError::Malformed {
2171 message: format!("cannot render the pre-write field recovery snapshot: {error}"),
2172 })
2173}
2174
2175/// One board field the guarded setup reads and writes — every one it reads, and the only
2176/// ones it writes.
2177#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
2178pub enum BoardField {
2179 /// The single-select `Status` field every instance's `status_mapping` resolves into.
2180 Status,
2181 /// The single-select `Priority` field an instance's `priority_mapping` resolves into.
2182 Priority,
2183}
2184
2185impl BoardField {
2186 /// The field's name on the board.
2187 #[must_use]
2188 pub const fn name(self) -> &'static str {
2189 match self {
2190 Self::Status => STATUS_FIELD,
2191 Self::Priority => PRIORITY_FIELD,
2192 }
2193 }
2194
2195 /// The field a board calls `name`, or `None` for one this setup does not own.
2196 fn named(name: &str) -> Option<Self> {
2197 [Self::Status, Self::Priority]
2198 .into_iter()
2199 .find(|field| field.name() == name)
2200 }
2201}
2202
2203/// What the guarded setup did to one field.
2204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2205#[serde(rename_all = "kebab-case")]
2206pub enum FieldOutcome {
2207 /// A read-only plan.
2208 Planned,
2209 /// Apply found the field there with every configured option.
2210 Unchanged,
2211 /// Missing options were added to the field that was there, and verified.
2212 Applied,
2213 /// The field was not there; it was created holding the configured options, and verified.
2214 Created,
2215}
2216
2217/// One field's plan, or its verified outcome.
2218#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2219pub struct FieldReport {
2220 /// Which field.
2221 pub field: BoardField,
2222 /// Whether the board had the field before the operation.
2223 // llmlint: ignore[invalid_states_unrepresentable] `exists` beside `outcome` is the report's
2224 // wire shape as its consumer's contract fixes it — `{"field", "exists", "missing",
2225 // "outcome", "existing"}` — so folding one into the other would change a published JSON
2226 // shape. The contradictory pairings cannot be built: `GitHubProjectsSource::fields` is the
2227 // one constructor, and it derives `outcome` from `exists` in one match.
2228 pub exists: bool,
2229 /// Configured option names the field lacked before the operation — every one of them,
2230 /// in the order a new field lists them, when the field was not there at all.
2231 // llmlint: ignore[invalid_states_unrepresentable] Each value originates from a validated
2232 // mapping name and has therefore already passed its nonblank validation; the serialized
2233 // string is the report's intentionally simple public contract, as `StatusOptionsReport`'s is.
2234 pub missing: Vec<String>,
2235 /// What the requested operation did.
2236 pub outcome: FieldOutcome,
2237 /// The field's complete option list observed before any mutation; empty when the field
2238 /// was not there.
2239 pub existing: Vec<StatusOption>,
2240}
2241
2242/// The plan and verified outcome of setting up every field a source's configuration names.
2243#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
2244pub struct FieldsReport {
2245 /// The configured source name.
2246 pub source: SourceName,
2247 /// `Status`, always, and `Priority` when the source sets `priority_mapping`.
2248 // llmlint: ignore[invalid_states_unrepresentable] A list is the report's wire shape as its
2249 // consumer's contract fixes it — `{"source", "fields": [...]}` — so a struct with one member
2250 // per field would change a published JSON shape. The states the list could hold and the
2251 // contract forbids cannot be built: `GitHubProjectsSource::fields` is the one constructor,
2252 // and it pushes `Status` first and exactly once, then `Priority` exactly when configured.
2253 pub fields: Vec<FieldReport>,
2254}
2255
2256/// Which options one field is configured with, in the order a new field would list them.
2257struct FieldPlan {
2258 field: BoardField,
2259 wanted: Vec<String>,
2260}
2261
2262/// One single-select field as the guarded setup snapshots it.
2263#[derive(Debug, Clone, PartialEq, Eq)]
2264struct SnapshotField {
2265 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2266 // passed back as the mutation's field identity; a newtype could enforce no stronger
2267 // invariant because GitHub publishes no grammar for it.
2268 field_id: String,
2269 options: Vec<StatusOption>,
2270}
2271
2272/// Every single-select field of a board and every item's value of each.
2273#[derive(Debug, Clone, PartialEq, Eq)]
2274struct BoardSnapshot {
2275 // llmlint: ignore[invalid_states_unrepresentable] This private opaque GraphQL ID is
2276 // passed back as the mutation's project identity; a newtype could enforce no stronger
2277 // invariant because GitHub publishes no grammar for it.
2278 board_id: String,
2279 fields: BTreeMap<BoardField, SnapshotField>,
2280 /// Each board item's id, and its value of each field this setup owns that it holds one of.
2281 items: Vec<(String, BTreeMap<BoardField, AssignedStatusOption>)>,
2282}
2283
2284impl BoardSnapshot {
2285 /// Every item's value of `field`, in board order — the recovery data a drift refusal
2286 /// carries.
2287 fn assignments(&self, field: BoardField) -> Vec<StatusAssignment> {
2288 self.items
2289 .iter()
2290 .map(|(item_id, values)| StatusAssignment {
2291 item_id: item_id.clone(),
2292 option: values.get(&field).cloned(),
2293 })
2294 .collect()
2295 }
2296}
2297
2298impl GitHubProjectsSource {
2299 /// Report missing configured Status options and, when `apply` is true, add them with
2300 /// a whole-list mutation that preserves every existing id and verifies the result.
2301 ///
2302 /// # Errors
2303 ///
2304 /// Refuses a board without a single-select `Status` field. A post-write difference in
2305 /// any pre-existing option id or item assignment is refused with the complete pre-write
2306 /// assignment snapshot in the diagnostic for recovery.
2307 // llmlint: ignore[changed_behavior_has_e2e] The CLI journeys cover plan, no-op apply,
2308 // successful mutation, both drift refusals, source selection, missing Status, casing,
2309 // and paging. Transport errors remain the shared `graphql` boundary's behavior rather
2310 // than a new status-options behavior, and the pinned-schema test prevents valid GitHub
2311 // responses from entering the defensive malformed-response branches below.
2312 pub async fn status_options(
2313 &self,
2314 mode: StatusOptionsMode,
2315 ) -> Result<StatusOptionsReport, SourceError> {
2316 let before = self.status_snapshot().await?;
2317 let configured = self
2318 .statuses
2319 .targets
2320 .iter()
2321 // A terminal category's option is as configured as an open one's: a terminal
2322 // write validates it before closing and refuses when the board lacks it.
2323 .filter_map(|target| match target {
2324 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2325 Some(name.as_str().to_owned())
2326 }
2327 StatusTarget::Disabled => None,
2328 });
2329 let missing = configured
2330 .filter(|wanted| {
2331 !before
2332 .options
2333 .iter()
2334 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2335 })
2336 .collect::<Vec<_>>();
2337 let report = StatusOptionsReport {
2338 source: self.name.clone(),
2339 missing: missing.clone(),
2340 outcome: match (mode, missing.is_empty()) {
2341 (StatusOptionsMode::Plan, _) => StatusOptionsOutcome::Planned,
2342 (StatusOptionsMode::Apply, true) => StatusOptionsOutcome::Unchanged,
2343 (StatusOptionsMode::Apply, false) => StatusOptionsOutcome::Applied,
2344 },
2345 existing: before.options.clone(),
2346 };
2347 if mode == StatusOptionsMode::Plan || missing.is_empty() {
2348 return Ok(report);
2349 }
2350 let mut options = before
2351 .options
2352 .iter()
2353 .map(|option| {
2354 json!({
2355 "id": option.id, "name": option.name, "color": option.color,
2356 "description": option.description,
2357 })
2358 })
2359 .collect::<Vec<_>>();
2360 options.extend(missing.iter().map(|name| {
2361 json!({
2362 "name": name, "color": "GRAY", "description": ""
2363 })
2364 }));
2365 self.graphql(
2366 graphql::STATUS_OPTIONS_UPDATE,
2367 json!({"input": {
2368 "projectId": before.board_id, "fieldId": before.field_id,
2369 "singleSelectOptions": options,
2370 }}),
2371 )
2372 .await?;
2373 let after = self.status_snapshot().await?;
2374 let options_preserved = before
2375 .options
2376 .iter()
2377 .all(|old| after.options.iter().any(|new| new == old));
2378 let additions_present = missing.iter().all(|wanted| {
2379 after
2380 .options
2381 .iter()
2382 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2383 });
2384 if !options_preserved || !additions_present || after.assignments != before.assignments {
2385 let recovery = serde_json::to_string_pretty(&before.assignments).map_err(|error| {
2386 SourceError::Malformed {
2387 message: format!("cannot render pre-write Status recovery snapshot: {error}"),
2388 }
2389 })?;
2390 return Err(SourceError::Refused {
2391 message: format!(
2392 "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}"
2393 ),
2394 });
2395 }
2396 Ok(report)
2397 }
2398
2399 /// A fresh snapshot of the Status field and every board item's assignment of it.
2400 ///
2401 /// # Errors
2402 ///
2403 /// Refuses a board without a single-select `Status` field, and one the token cannot see.
2404 async fn status_snapshot(&self) -> Result<StatusSnapshot, SourceError> {
2405 // Status alone, as this operation has always read it: a `Priority` field is another
2406 // operation's, so nothing about it can refuse this one.
2407 let mut board = self.board_snapshot(&[BoardField::Status]).await?;
2408 let field = board
2409 .fields
2410 .remove(&BoardField::Status)
2411 .ok_or_else(|| self.no_status_field())?;
2412 Ok(StatusSnapshot {
2413 assignments: board.assignments(BoardField::Status),
2414 board_id: board.board_id,
2415 field_id: field.field_id,
2416 options: field.options,
2417 })
2418 }
2419
2420 /// The refusal a board with no `Status` field is answered with by the guarded setup.
2421 fn no_status_field(&self) -> SourceError {
2422 SourceError::Refused {
2423 message: format!("source {} board has no Status field", self.name),
2424 }
2425 }
2426
2427 // llmlint: ignore-block[changed_behavior_has_e2e] Valid snapshot shapes are exercised through
2428 // the real CLI loopback journey, including pagination. The individual malformed guards
2429 // are defensive validation of a schema-pinned third-party response, not separate user
2430 // journeys; drift and missing-field failures cover the operation's recovery behavior.
2431 /// A fresh snapshot of each of the `owned` fields on the board, with its options, and of
2432 /// every board item's value of each, walked to the end of the board's items. A field not
2433 /// in `owned` is read past whatever it holds.
2434 async fn board_snapshot(&self, owned: &[BoardField]) -> Result<BoardSnapshot, SourceError> {
2435 let mut after: Option<String> = None;
2436 let mut snapshot: Option<BoardSnapshot> = None;
2437 loop {
2438 let data = self
2439 .graphql(
2440 graphql::STATUS_OPTIONS_SNAPSHOT,
2441 json!({
2442 "owner": self.owner, "number": self.project_number,
2443 "first": MAX_PAGE_SIZE, "after": after, "nestedFirst": MAX_PAGE_SIZE,
2444 }),
2445 )
2446 .await?;
2447 let board = data
2448 .pointer("/owner/projectV2")
2449 .filter(|board| board.is_object())
2450 .ok_or_else(|| SourceError::Refused {
2451 message: format!(
2452 "source {} has no accessible GitHub Projects board",
2453 self.name
2454 ),
2455 })?;
2456 if board
2457 .pointer("/fields/pageInfo/hasNextPage")
2458 .and_then(Value::as_bool)
2459 != Some(false)
2460 {
2461 return Err(SourceError::Malformed {
2462 message:
2463 "GitHub project fields is incomplete or has malformed pageInfo.hasNextPage"
2464 .into(),
2465 });
2466 }
2467 let mut fields = BTreeMap::new();
2468 // Only the fields this setup owns, by name: a node the single-select fragment did not
2469 // match carries no name, and a person's own single-select field — a `Size`, a
2470 // `Team` — is none of this setup's business, so nothing about it can refuse one. A
2471 // `Status` or `Priority` field without its options is malformed, not absent.
2472 // 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.
2473 for (owned, field) in board
2474 .pointer("/fields/nodes")
2475 .and_then(Value::as_array)
2476 .ok_or_else(|| SourceError::Malformed {
2477 message: "GitHub project fields.nodes is not an array".into(),
2478 })?
2479 .iter()
2480 .filter_map(|field| {
2481 let named = BoardField::named(field.get("name")?.as_str()?)?;
2482 owned.contains(&named).then_some((named, field))
2483 })
2484 {
2485 let options = field
2486 .get("options")
2487 .and_then(Value::as_array)
2488 .ok_or_else(|| SourceError::Malformed {
2489 message: "GitHub single-select field options is not an array".into(),
2490 })?
2491 .iter()
2492 .map(|option| {
2493 Ok(StatusOption {
2494 id: StatusOptionId::try_from(required_str(option, "id")?.to_owned())
2495 .map_err(|message| SourceError::Malformed { message })?,
2496 name: ColumnName::try_from(required_str(option, "name")?.to_owned())
2497 .map_err(|message| SourceError::Malformed {
2498 message: format!(
2499 "GitHub single-select option name is invalid: {message}"
2500 ),
2501 })?,
2502 color: serde_json::from_value(
2503 option.get("color").cloned().unwrap_or(Value::Null),
2504 )
2505 .map_err(|error| {
2506 SourceError::Malformed {
2507 message: format!(
2508 "GitHub single-select option color is invalid: {error}"
2509 ),
2510 }
2511 })?,
2512 description: optional_str(option, "description")?
2513 .unwrap_or_default()
2514 .to_owned(),
2515 })
2516 })
2517 .collect::<Result<Vec<_>, SourceError>>()?;
2518 let snapshot = SnapshotField {
2519 field_id: required_nonblank_str(field, "id")?.to_owned(),
2520 options,
2521 };
2522 // A board's field names are unique, so a second one is an answer that cannot
2523 // say which field the setup would act on — refused rather than one chosen.
2524 if fields.insert(owned, snapshot).is_some() {
2525 return Err(SourceError::Malformed {
2526 message: format!(
2527 "GitHub answered two {} fields for this board",
2528 owned.name()
2529 ),
2530 });
2531 }
2532 }
2533 let board_id = required_nonblank_str(board, "id")?.to_owned();
2534 let current = snapshot.get_or_insert_with(|| BoardSnapshot {
2535 board_id,
2536 fields,
2537 items: Vec::new(),
2538 });
2539 let items = board
2540 .pointer("/items/nodes")
2541 .and_then(Value::as_array)
2542 .ok_or_else(|| SourceError::Malformed {
2543 message: "GitHub project items.nodes is not an array".into(),
2544 })?;
2545 for item in items {
2546 let field_values =
2547 item.get("fieldValues")
2548 .ok_or_else(|| SourceError::Malformed {
2549 message: "GitHub project item is missing fieldValues".into(),
2550 })?;
2551 if field_values
2552 .pointer("/pageInfo/hasNextPage")
2553 .and_then(Value::as_bool)
2554 != Some(false)
2555 {
2556 return Err(SourceError::Malformed {
2557 message: "GitHub project item fieldValues is incomplete or has malformed pageInfo.hasNextPage".into(),
2558 });
2559 }
2560 let values = item
2561 .pointer("/fieldValues/nodes")
2562 .and_then(Value::as_array)
2563 .ok_or_else(|| SourceError::Malformed {
2564 message: "GitHub project item fieldValues.nodes is not an array".into(),
2565 })?;
2566 let item_id = required_nonblank_str(item, "id")?;
2567 let mut assigned = BTreeMap::new();
2568 for value in values {
2569 let Some(field) = value
2570 .pointer("/field/name")
2571 .and_then(Value::as_str)
2572 .and_then(BoardField::named)
2573 .filter(|field| owned.contains(field))
2574 else {
2575 continue;
2576 };
2577 let held = assigned.insert(
2578 field,
2579 AssignedStatusOption {
2580 id: StatusOptionId::try_from(
2581 required_str(value, "optionId")?.to_owned(),
2582 )
2583 .map_err(|message| SourceError::Malformed { message })?,
2584 name: ColumnName::try_from(required_str(value, "name")?.to_owned())
2585 .map_err(|message| SourceError::Malformed {
2586 message: format!(
2587 "GitHub assigned {} name is invalid: {message}",
2588 field.name()
2589 ),
2590 })?,
2591 },
2592 );
2593 // An item holds one value of a field, so a second one leaves no way to
2594 // tell which it holds — and a verification or recovery built on either
2595 // could restore the wrong one.
2596 if held.is_some() {
2597 return Err(SourceError::Malformed {
2598 message: format!(
2599 "GitHub answered two {} values for board item {item_id}",
2600 field.name()
2601 ),
2602 });
2603 }
2604 }
2605 current.items.push((item_id.to_owned(), assigned));
2606 }
2607 let page = board.get("items").ok_or_else(|| SourceError::Malformed {
2608 message: "GitHub project is missing items".into(),
2609 })?;
2610 let has_next = page
2611 .pointer("/pageInfo/hasNextPage")
2612 .and_then(Value::as_bool)
2613 .ok_or_else(|| SourceError::Malformed {
2614 message: "GitHub project items.pageInfo.hasNextPage is not a boolean".into(),
2615 })?;
2616 if !has_next {
2617 break;
2618 }
2619 let next =
2620 required_nonblank_str(page.get("pageInfo").unwrap_or(&Value::Null), "endCursor")?;
2621 validate_cursor_progress(after.as_deref(), next)?;
2622 after = Some(next.to_owned());
2623 }
2624 snapshot.ok_or_else(|| SourceError::Malformed {
2625 message: "GitHub returned no board field snapshot".into(),
2626 })
2627 }
2628 // llmlint: ignore-end[changed_behavior_has_e2e]
2629
2630 /// Report every board field this source's configuration names and, with
2631 /// [`SetupMode::Apply`], set each up: add the options a field lacks, and create
2632 /// the `Priority` field when the board has none.
2633 ///
2634 /// The fields are `Status`, always, with the options `status_mapping` resolves to; and
2635 /// `Priority`, when `priority_mapping` is set, with its four mapped options — created in
2636 /// the order urgent, high, medium, low. An option a field already has keeps its id, name,
2637 /// color and description: the whole option list goes back with every existing id, because
2638 /// a re-minted id clears every item's value.
2639 ///
2640 /// # Errors
2641 ///
2642 /// Refuses a board without a single-select `Status` field. After an apply the board is
2643 /// read again, and a pre-existing option or any item's value of either field that moved is
2644 /// refused with the complete pre-write assignments in the diagnostic, for recovery.
2645 // llmlint: ignore[changed_behavior_has_e2e] The `sources fields` journeys drive plan,
2646 // unchanged apply, a created field, an added option to each field, drift refusal, a board
2647 // with no Status field and a non-github-projects source through the compiled CLI against
2648 // the loopback board. Transport errors are the shared `graphql` boundary's behavior.
2649 pub async fn fields(&self, mode: SetupMode) -> Result<FieldsReport, SourceError> {
2650 let owned: Vec<BoardField> = if self.priorities.is_some() {
2651 vec![BoardField::Status, BoardField::Priority]
2652 } else {
2653 vec![BoardField::Status]
2654 };
2655 let before = self.board_snapshot(&owned).await?;
2656 let mut plans = vec![FieldPlan {
2657 field: BoardField::Status,
2658 wanted: self
2659 .statuses
2660 .targets
2661 .iter()
2662 .filter_map(|target| match target {
2663 StatusTarget::Column(name) | StatusTarget::Terminal(name, _) => {
2664 Some(name.as_str().to_owned())
2665 }
2666 StatusTarget::Disabled => None,
2667 })
2668 .collect(),
2669 }];
2670 if !before.fields.contains_key(&BoardField::Status) {
2671 return Err(self.no_status_field());
2672 }
2673 if let Some(mapping) = &self.priorities {
2674 plans.push(FieldPlan {
2675 field: BoardField::Priority,
2676 wanted: mapping.names().map(str::to_owned).collect(),
2677 });
2678 }
2679 // The snapshot reads single-select fields alone, so a field it did not find may still
2680 // be on the board under the name, of another type: creating one beside it would fail
2681 // part way, or leave two fields of one name. Asked of the board's own field list, and
2682 // only when a field is missing.
2683 if plans
2684 .iter()
2685 .any(|plan| !before.fields.contains_key(&plan.field))
2686 {
2687 let board = self.board_fields().await?;
2688 for plan in plans
2689 .iter()
2690 .filter(|plan| !before.fields.contains_key(&plan.field))
2691 {
2692 if let Some(field) = Board::field(&board.fields, plan.field.name())? {
2693 return Err(SourceError::Refused {
2694 message: format!(
2695 "source {}'s board has a {} field that is not a single-select field \
2696 (it is a {}), so it cannot hold this source's options; next: rename \
2697 or remove that field, then run this again",
2698 self.name,
2699 plan.field.name(),
2700 optional_str(field, "__typename")?.unwrap_or("field of another type")
2701 ),
2702 });
2703 }
2704 }
2705 }
2706 let mut reports = Vec::new();
2707 for plan in &plans {
2708 let held = before.fields.get(&plan.field);
2709 let existing = held.map(|field| field.options.clone()).unwrap_or_default();
2710 let mut missing: Vec<String> = Vec::new();
2711 for wanted in &plan.wanted {
2712 let present = existing
2713 .iter()
2714 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2715 || missing
2716 .iter()
2717 .any(|named| named.eq_ignore_ascii_case(wanted));
2718 if !present {
2719 missing.push(wanted.clone());
2720 }
2721 }
2722 reports.push(FieldReport {
2723 field: plan.field,
2724 exists: held.is_some(),
2725 outcome: match (mode, held.is_some(), missing.is_empty()) {
2726 (SetupMode::Plan, _, _) => FieldOutcome::Planned,
2727 (SetupMode::Apply, true, true) => FieldOutcome::Unchanged,
2728 (SetupMode::Apply, true, false) => FieldOutcome::Applied,
2729 (SetupMode::Apply, false, _) => FieldOutcome::Created,
2730 },
2731 missing,
2732 existing,
2733 });
2734 }
2735 let report = FieldsReport {
2736 source: self.name.clone(),
2737 fields: reports,
2738 };
2739 let writes: Vec<&FieldReport> = report
2740 .fields
2741 .iter()
2742 .filter(|field| !field.missing.is_empty() || !field.exists)
2743 .collect();
2744 if mode == SetupMode::Plan || writes.is_empty() {
2745 return Ok(report);
2746 }
2747 let mut landed: Vec<&str> = Vec::new();
2748 for field in &writes {
2749 let added = field
2750 .missing
2751 .iter()
2752 .map(|name| json!({"name": name, "color": "GRAY", "description": ""}));
2753 let sent = match before.fields.get(&field.field) {
2754 Some(held) => {
2755 let mut options = held
2756 .options
2757 .iter()
2758 .map(|option| {
2759 json!({
2760 "id": option.id, "name": option.name, "color": option.color,
2761 "description": option.description,
2762 })
2763 })
2764 .collect::<Vec<_>>();
2765 options.extend(added);
2766 self.graphql(
2767 graphql::STATUS_OPTIONS_UPDATE,
2768 json!({"input": {
2769 "projectId": before.board_id, "fieldId": held.field_id,
2770 "singleSelectOptions": options,
2771 }}),
2772 )
2773 .await
2774 }
2775 None => {
2776 self.graphql(
2777 graphql::CREATE_FIELD,
2778 json!({"input": {
2779 "projectId": before.board_id, "dataType": "SINGLE_SELECT",
2780 "name": field.field.name(),
2781 "singleSelectOptions": added.collect::<Vec<_>>(),
2782 }}),
2783 )
2784 .await
2785 }
2786 };
2787 // A mutation that failed does not establish that GitHub left its field as it was,
2788 // so every failure from here on carries the recovery data a drift refusal does.
2789 match sent {
2790 Ok(_) => landed.push(field.field.name()),
2791 Err(error) => {
2792 let changed = if landed.is_empty() {
2793 String::new()
2794 } else {
2795 format!("changed the {} field and then ", landed.join(" and "))
2796 };
2797 return Err(SourceError::Refused {
2798 message: format!(
2799 "the guarded field setup {changed}failed on the {} field, which it may \
2800 have changed part way: {error}; the pre-write item assignments \
2801 are:\n{}",
2802 field.field.name(),
2803 recovery(&report, &before)?
2804 ),
2805 });
2806 }
2807 }
2808 }
2809 // The board has been written, so a verification read that fails leaves it unverified
2810 // rather than unchanged, and says what to put back.
2811 let after = match self.board_snapshot(&owned).await {
2812 Ok(after) => after,
2813 Err(error) => {
2814 return Err(SourceError::Refused {
2815 message: format!(
2816 "the guarded field setup changed the {} field and then could not read the \
2817 board back to verify it: {error}; the pre-write item assignments are:\n{}",
2818 landed.join(" and "),
2819 recovery(&report, &before)?
2820 ),
2821 });
2822 }
2823 };
2824 let mut moved = Vec::new();
2825 for field in &report.fields {
2826 let name = field.field.name();
2827 let now = after
2828 .fields
2829 .get(&field.field)
2830 .map(|held| held.options.as_slice())
2831 .unwrap_or_default();
2832 if !field.existing.iter().all(|old| now.contains(old)) {
2833 moved.push(format!(
2834 "a pre-existing {name} option id, name, color or description"
2835 ));
2836 }
2837 if !field.missing.iter().all(|wanted| {
2838 now.iter()
2839 .any(|option| option.name.as_str().eq_ignore_ascii_case(wanted))
2840 }) {
2841 moved.push(format!("an added {name} option"));
2842 }
2843 if after.assignments(field.field) != before.assignments(field.field) {
2844 moved.push(format!("an item's {name} value"));
2845 }
2846 }
2847 if !moved.is_empty() {
2848 return Err(SourceError::Refused {
2849 message: format!(
2850 "GitHub changed {} after the guarded field setup; the pre-write item \
2851 assignments are:\n{}",
2852 moved.join(", "),
2853 recovery(&report, &before)?
2854 ),
2855 });
2856 }
2857 Ok(report)
2858 }
2859
2860 /// Validate configuration and capture the named credential without exposing it.
2861 ///
2862 /// # Errors
2863 ///
2864 /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
2865 /// [`SourceError::Auth`] when the named credential is missing or empty.
2866 pub fn new(
2867 name: &SourceName,
2868 config: GitHubProjectsConfig,
2869 secrets: &dyn SecretResolver,
2870 ) -> Result<Self, SourceError> {
2871 Self::recording_into(name, config, secrets, Arc::new(Accounting::new()))
2872 }
2873
2874 /// The same, recording every request it sends into an accounting the caller holds too.
2875 ///
2876 /// [`Self::new`] is this with an accounting of its own. A caller that is also making
2877 /// its own calls to GitHub — a lane verifying a schema, sweeping residue or cleaning
2878 /// up — passes the one it records those into, so the session total accounts for the
2879 /// whole session rather than for this source's share of it.
2880 ///
2881 /// # Errors
2882 ///
2883 /// Exactly [`Self::new`]'s: [`SourceError::Config`] for a configuration this instance
2884 /// cannot use and [`SourceError::Auth`] when the named credential is missing or empty.
2885 pub fn recording_into(
2886 name: &SourceName,
2887 config: GitHubProjectsConfig,
2888 secrets: &dyn SecretResolver,
2889 ledger: Arc<Accounting>,
2890 ) -> Result<Self, SourceError> {
2891 if !valid_github_owner(&config.owner) {
2892 return Err(SourceError::Config {
2893 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
2894 });
2895 }
2896 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
2897 return Err(SourceError::Config {
2898 message: format!("project_number must be between 1 and {}", i32::MAX),
2899 });
2900 }
2901 if !valid_environment_name(&config.token_env) {
2902 return Err(SourceError::Config {
2903 message: "token_env must be a valid environment-variable name".into(),
2904 });
2905 }
2906 let repository = config
2907 .repository
2908 .as_deref()
2909 .map(RepositoryTarget::parse)
2910 .transpose()?;
2911 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
2912 message: format!("endpoint is not a valid URL: {e}"),
2913 })?;
2914 if endpoint.scheme() != "https"
2915 && !(endpoint.scheme() == "http"
2916 && endpoint
2917 .host_str()
2918 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
2919 {
2920 return Err(SourceError::Config {
2921 message:
2922 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
2923 .into(),
2924 });
2925 }
2926 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
2927 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),
2928 })?;
2929 Ok(Self {
2930 name: name.clone(),
2931 owner: config.owner,
2932 project_number: config.project_number,
2933 repository,
2934 endpoint,
2935 token,
2936 credential_name: config.token_env,
2937 statuses: StatusMapping::resolve(config.status_mapping, name)?,
2938 priorities: config
2939 .priority_mapping
2940 .map(|mapping| PriorityMapping::resolve(mapping, name))
2941 .transpose()?,
2942 client: Client::builder()
2943 .user_agent("onetaskgraph")
2944 .build()
2945 .map_err(|e| SourceError::Config {
2946 message: format!("cannot build HTTP client: {e}"),
2947 })?,
2948 created: Mutex::new(Vec::new()),
2949 pacing: Pacing::resolve(config.pacing, name)?,
2950 last_mutation: Mutex::new(None),
2951 board_cache: Mutex::new(None),
2952 search_cache: Mutex::new(None),
2953 fields_cache: Mutex::new(None),
2954 repository_cache: Mutex::new(BTreeMap::new()),
2955 ledger,
2956 })
2957 }
2958
2959 /// A snapshot of every request this source has sent, and what each cost.
2960 ///
2961 /// A value to hold and compare rather than a borrow of the accounting itself, so two
2962 /// of them can sit side by side. When this source was built with
2963 /// [`Self::recording_into`] the snapshot is the whole shared session, which is the
2964 /// point of building it that way.
2965 #[must_use]
2966 pub fn accounting(&self) -> accounting::Session {
2967 self.ledger.snapshot()
2968 }
2969
2970 /// Send one GraphQL document, pacing this source's own mutations and waiting out a
2971 /// rate limit rather than handing it straight back as an error.
2972 ///
2973 /// Retrying is safe for every document here, including the mutations, and the reason
2974 /// is that only a *refusal* is retried: [`Limiter::classify`] rules on a response
2975 /// GitHub sent, and a request GitHub refused for a rate limit did not run, so nothing
2976 /// this replays has already taken effect. An outcome this source cannot know — the
2977 /// send failed, or the body could not be read, so the mutation may well have landed —
2978 /// is [`Attempt::Failed`] in [`send_once`] and leaves this loop without a second
2979 /// attempt. A duplicate write would come from replaying one of those, and none is
2980 /// replayed.
2981 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
2982 let doing = operation_description(query);
2983 let mut waited = Duration::ZERO;
2984 let mut waits = 0_u32;
2985 let mut backoff = self.pacing.retry_backoff;
2986 loop {
2987 if is_mutation(query) {
2988 let spacing = self.reserve_mutation_slot();
2989 if !spacing.is_zero() {
2990 tokio::time::sleep(spacing).await;
2991 }
2992 }
2993 let attempt = self.send_once(query, &variables).await;
2994 if is_mutation(query) {
2995 self.finish_mutation();
2996 }
2997 let limited = match attempt {
2998 Ok(data) => return Ok(data),
2999 Err(Attempt::Failed(error)) => return Err(error),
3000 Err(Attempt::Limited(limited)) => limited,
3001 };
3002 // GitHub really does send `retry-after: 0`, and retrying at once is the one
3003 // move that extends a secondary limit, so a hint below the schedule's own next
3004 // wait is raised to it.
3005 let wait = match limited.hint {
3006 Some(hint) => Duration::from_secs(hint).max(backoff),
3007 None => backoff,
3008 };
3009 let remaining = self.pacing.retry_budget.saturating_sub(waited);
3010 // A wait of nothing spends none of the budget, so it is exhaustion rather
3011 // than a retry. `Pacing::resolve` rules out every way of configuring one
3012 // except a budget of zero, where reporting the first refusal is the ask.
3013 if wait.is_zero() || wait > remaining {
3014 return Err(limited.exhausted(
3015 doing,
3016 waits,
3017 waited,
3018 wait,
3019 self.pacing.retry_budget,
3020 ));
3021 }
3022 tokio::time::sleep(wait).await;
3023 waited += wait;
3024 waits += 1;
3025 backoff = backoff.saturating_mul(2);
3026 }
3027 }
3028
3029 /// The next moment a content-creating mutation may leave this source, as a wait from
3030 /// now.
3031 ///
3032 /// The slot is reserved under the lock and the waiting happens outside it, so two
3033 /// callers take two slots rather than the same one — and no lock is held across an
3034 /// await.
3035 ///
3036 /// The moment it is spaced from is the previous mutation's *completion*, which
3037 /// [`Self::finish_mutation`] records. See that method for why the release moment on its
3038 /// own is the wrong thing to measure from.
3039 fn reserve_mutation_slot(&self) -> Duration {
3040 if self.pacing.min_mutation_interval.is_zero() {
3041 return Duration::ZERO;
3042 }
3043 // A poisoned lock here costs pacing, not correctness, and refusing the write over
3044 // it would turn an earlier failure into a second one for no gain.
3045 let mut last = self
3046 .last_mutation
3047 .lock()
3048 .unwrap_or_else(std::sync::PoisonError::into_inner);
3049 let now = Instant::now();
3050 // `checked_add` rather than `+`: `Instant + Duration` panics on overflow, and
3051 // pacing is not worth a panic even at a bound `MAX_PACING_MS` already rules out.
3052 let at = last.map_or(now, |previous| {
3053 previous
3054 .checked_add(self.pacing.min_mutation_interval)
3055 .map_or(now, |earliest| earliest.max(now))
3056 });
3057 *last = Some(at);
3058 at.saturating_duration_since(now)
3059 }
3060
3061 /// Record that a content-creating mutation has finished, so the next one is spaced
3062 /// from here rather than from the moment this one was released.
3063 ///
3064 /// This source can only choose when a request *departs*; the limiter counts when it
3065 /// *arrives*, and the two differ by whatever the request spent in transit. Spacing one
3066 /// departure from the last therefore hands the limiter a gap of the interval less that
3067 /// transit, so a source pacing at 750 ms can still be seen arriving faster — which is
3068 /// exactly how a copy paced well inside a board's threshold was refused by it on a
3069 /// slower machine while passing on a quick one.
3070 ///
3071 /// Spacing from completion removes the subtraction rather than budgeting for it. The
3072 /// previous request had already arrived before its response came back, so its arrival
3073 /// is no later than this moment, and the next mutation is released at least the
3074 /// interval after this moment and arrives no earlier than it is released: the gap the
3075 /// limiter measures is therefore at least the interval, whatever transit costs and on
3076 /// whatever platform. The price is that a mutation's own round trip no longer counts
3077 /// towards its spacing, which makes this source slightly slower than the configured
3078 /// rate rather than slightly faster — the safe side of a limit that punishes being
3079 /// wrong by refusing reads for the next fifty minutes.
3080 ///
3081 /// A failed attempt is recorded too: a request refused by the limiter still arrived,
3082 /// and one that never left costs only a wait nobody needed.
3083 fn finish_mutation(&self) {
3084 if self.pacing.min_mutation_interval.is_zero() {
3085 return;
3086 }
3087 // A poisoned lock here costs pacing, not correctness, exactly as in the reservation.
3088 let mut last = self
3089 .last_mutation
3090 .lock()
3091 .unwrap_or_else(std::sync::PoisonError::into_inner);
3092 let now = Instant::now();
3093 // `max` rather than an assignment: a concurrent caller may already have reserved a
3094 // slot further out, and completing this request must never pull that slot back in.
3095 *last = Some(last.map_or(now, |reserved| reserved.max(now)));
3096 }
3097
3098 /// One HTTP attempt, classified into an answer, a rate limit to wait out, or a
3099 /// failure that waiting cannot help — and recorded, whichever of the three it was.
3100 ///
3101 /// This is the one place a request leaves this crate, which is why the accounting is
3102 /// here rather than at each of the callers: a read path added later is counted without
3103 /// anybody remembering to count it, and
3104 /// `the_session_report_counts_every_request_the_board_served_and_what_each_cost` fails
3105 /// when one is not.
3106 async fn send_once(&self, query: &str, variables: &Value) -> Result<Value, Attempt> {
3107 let Attempted {
3108 result,
3109 limits,
3110 reported_cost,
3111 } = self.attempt(query, variables).await;
3112 // No `otherwise` name: every document this source sends is one of its own, and the
3113 // inventory gate on `graphql::DOCUMENTS` is what keeps that true.
3114 let sending = accounting::Request::graphql(query, variables, None, reported_cost);
3115 let outcome = match &result {
3116 Ok(_) => accounting::Outcome::Answered,
3117 Err(Attempt::Limited(_)) => accounting::Outcome::RateLimited,
3118 Err(Attempt::Failed(_)) => accounting::Outcome::Refused,
3119 };
3120 self.ledger.record(sending.finished(outcome, limits));
3121 result
3122 }
3123
3124 /// The attempt itself, with what its response said about the rate limit alongside.
3125 ///
3126 /// The two are returned together rather than recorded here because every one of the
3127 /// early exits below is a different outcome, and a record written at each of them is a
3128 /// record one of them can be added without.
3129 async fn attempt(&self, query: &str, variables: &Value) -> Attempted {
3130 let mut limits = accounting::RateLimit::default();
3131 let mut reported_cost = None;
3132 let result = self
3133 .attempted(query, variables, &mut limits, &mut reported_cost)
3134 .await;
3135 Attempted {
3136 result,
3137 limits,
3138 reported_cost,
3139 }
3140 }
3141
3142 /// One HTTP attempt, filling in what its response said about the rate limit as it goes.
3143 async fn attempted(
3144 &self,
3145 query: &str,
3146 variables: &Value,
3147 limits: &mut accounting::RateLimit,
3148 reported_cost: &mut Option<u64>,
3149 ) -> Result<Value, Attempt> {
3150 let response = self
3151 .client
3152 .post(self.endpoint.clone())
3153 .bearer_auth(self.token.expose_secret())
3154 .json(&json!({"query": query, "variables": variables}))
3155 .send()
3156 .await
3157 .map_err(|e| {
3158 Attempt::Failed(SourceError::Unavailable {
3159 message: format!("GitHub GraphQL request failed: {e}"),
3160 })
3161 })?;
3162 let status = response.status();
3163 let header = |name: &str| whole_seconds(response.headers().get(name));
3164 *limits = accounting::RateLimit::read(|name| {
3165 response
3166 .headers()
3167 .get(name)
3168 .and_then(|value| value.to_str().ok())
3169 .map(str::to_owned)
3170 });
3171 // Exactly `0` is exhaustion and everything else — a count, an empty value, bytes
3172 // that are not text at all — is "not known to be exhausted". This never makes a
3173 // response a refusal on its own: it says which limiter a refusal is attributed to
3174 // and where its hint comes from, so a value this cannot read costs a hint rather
3175 // than an answer.
3176 let exhausted = response
3177 .headers()
3178 .get("x-ratelimit-remaining")
3179 .and_then(|value| value.to_str().ok())
3180 == Some("0");
3181 // `retry-after` is what GitHub asks for when it asks; when it does not and the
3182 // primary budget is spent, `x-ratelimit-reset` says when that budget comes back,
3183 // which is the same question answered as an absolute time. Nothing else here is a
3184 // hint, and a schedule is what answers a refusal that carries none.
3185 let hint = header("retry-after").or_else(|| {
3186 exhausted
3187 .then(|| header("x-ratelimit-reset"))
3188 .flatten()
3189 .map(|reset| reset.saturating_sub(Utc::now().timestamp().max(0).unsigned_abs()))
3190 });
3191 // Read before it is parsed, because the evidence which tells a secondary rate
3192 // limit from a rejected credential is in the body of a response whose status says
3193 // only "forbidden" — and a non-success response was never parsed at all.
3194 let body = response.text().await.map_err(|e| {
3195 Attempt::Failed(SourceError::Unavailable {
3196 message: format!("GitHub GraphQL response could not be read: {e}"),
3197 })
3198 })?;
3199 if let Some(limiter) = Limiter::classify(status, exhausted, &body) {
3200 return Err(Attempt::Limited(Limited { limiter, hint }));
3201 }
3202 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
3203 return Err(Attempt::Failed(SourceError::Auth {
3204 message: format!(
3205 "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"
3206 ),
3207 }));
3208 }
3209 if !status.is_success() {
3210 return Err(Attempt::Failed(SourceError::Unavailable {
3211 message: format!("GitHub GraphQL returned HTTP {status}"),
3212 }));
3213 }
3214 // GitHub reports what a call cost only when the document asked it to, and no
3215 // document this source sends does — so this is `None` here and carries the figure
3216 // for a caller whose own document selects `rateLimit { cost }`. What it must never
3217 // pick up is a `dryRun` probe's cost, which is some other document's.
3218 *reported_cost = serde_json::from_str::<Value>(&body)
3219 .ok()
3220 .as_ref()
3221 .and_then(|body| body.pointer("/data/rateLimit/cost"))
3222 .and_then(Value::as_u64);
3223 self.answer(&body).map_err(Attempt::Failed)
3224 }
3225
3226 /// What one successful HTTP response says, once its GraphQL errors are read.
3227 fn answer(&self, body: &str) -> Result<Value, SourceError> {
3228 let body: Value = serde_json::from_str(body).map_err(|e| SourceError::Malformed {
3229 message: format!("GitHub returned invalid JSON: {e}"),
3230 })?;
3231 let errors = body
3232 .get("errors")
3233 .map(|value| {
3234 value.as_array().ok_or_else(|| SourceError::Malformed {
3235 message: "GitHub response errors is not an array".into(),
3236 })
3237 })
3238 .transpose()?;
3239 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3240 let messages = errors
3241 .iter()
3242 .filter_map(|e| e.get("message").and_then(Value::as_str))
3243 .collect::<Vec<_>>()
3244 .join("; ");
3245 let message = if messages.is_empty() {
3246 "GitHub returned GraphQL errors".into()
3247 } else {
3248 messages
3249 };
3250 let normalized = message.to_ascii_lowercase();
3251 if normalized.contains("resource not accessible") || normalized.contains("scope") {
3252 return Err(SourceError::Auth {
3253 message: format!(
3254 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
3255 self.credential_name
3256 ),
3257 });
3258 }
3259 return Err(SourceError::Refused { message });
3260 }
3261 body.get("data")
3262 .filter(|data| data.is_object())
3263 .cloned()
3264 .ok_or_else(|| SourceError::Malformed {
3265 message: "GitHub response has no data object".into(),
3266 })
3267 }
3268
3269 // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
3270 // GraphQL cannot independently page them inside the outer item page. This source page is
3271 // deliberately bounded at that published maximum; the live drift journey exercises it.
3272 async fn board_page(
3273 &self,
3274 items_after: Option<&str>,
3275 items_first: u32,
3276 ) -> Result<Value, SourceError> {
3277 let data = self
3278 .graphql(
3279 graphql::BOARD,
3280 json!({"owner":self.owner,"number":self.project_number,
3281 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
3282 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
3283 )
3284 .await?;
3285 data.pointer("/owner/projectV2")
3286 .filter(|v| !v.is_null())
3287 .cloned()
3288 .ok_or_else(|| SourceError::Refused {
3289 message: format!(
3290 "GitHub project {}/{} was not found or is not visible to the token",
3291 self.owner, self.project_number
3292 ),
3293 })
3294 }
3295
3296 /// The search that finds the issues of this board, narrowed by `also` when it is
3297 /// given.
3298 ///
3299 /// `project:owner/number` is what scopes a search to one board, and `is:issue` is what
3300 /// keeps pull requests out of it: GitHub's `ISSUE` search type covers both, and a pull
3301 /// request is somebody's change rather than a unit of plan. `-has:parent` is *not*
3302 /// here on purpose — GitHub accepts it and silently ignores it, so a project is told
3303 /// from a task by the `parent` field each issue carries rather than by the search.
3304 fn board_search(&self, also: Option<&str>) -> String {
3305 let scope = format!("project:{}/{} is:issue", self.owner, self.project_number);
3306 match also {
3307 Some(also) => format!("{scope} {also}"),
3308 None => scope,
3309 }
3310 }
3311
3312 /// One issue this source reached directly, as the board item a read of the board would
3313 /// have produced — or `None` when this board does not hold it.
3314 ///
3315 /// The board half of an issue rides along on `Issue.projectItems`, so the value handed
3316 /// to [`Self::resolve`] is the very shape a `ProjectV2.items` read gives it: the board
3317 /// item's own id, that item's field values, and the issue as its content. One resolver
3318 /// for both routes is what makes an issue read through a search, through its own node
3319 /// id, or through its project's sub-issues report the same title, the same status, the
3320 /// same labels and the same qualified id.
3321 ///
3322 /// An issue with no entry for *this* board is not this source's to report, which is
3323 /// what keeps an id naming some other repository's issue from being answered as an item
3324 /// of this board. That answer is given about an **exhausted** connection and never
3325 /// about an unread page: the entry is looked for on the page in hand, and only if that
3326 /// page reports more of the connection, in [`Self::board_membership`]'s walk of the
3327 /// rest of it.
3328 async fn resolve_issue(&self, issue: &Value) -> Result<Option<Resolved>, SourceError> {
3329 if optional_str(issue, "__typename")? != Some("Issue") {
3330 return Ok(None);
3331 }
3332 let memberships = issue
3333 .get("projectItems")
3334 .ok_or_else(|| SourceError::Malformed {
3335 message: "GitHub issue is missing projectItems".into(),
3336 })?;
3337 let nodes = memberships
3338 .get("nodes")
3339 .and_then(Value::as_array)
3340 .ok_or_else(|| SourceError::Malformed {
3341 message: "GitHub issue projectItems.nodes is not an array".into(),
3342 })?;
3343 let held = match self.board_entry(nodes) {
3344 Some(held) => held.clone(),
3345 None => {
3346 let info = memberships
3347 .get("pageInfo")
3348 .ok_or_else(|| SourceError::Malformed {
3349 message: "GitHub issue projectItems has no pageInfo".into(),
3350 })?;
3351 // The page held no entry for this board. Whether that means the issue is
3352 // not on it is a question about the rest of the connection, and only a
3353 // connection with no rest answers it here.
3354 if !required_bool(info, "hasNextPage")? {
3355 return Ok(None);
3356 }
3357 let cursor = required_str(info, "endCursor")?;
3358 validate_cursor_progress(None, cursor)?;
3359 let issue_id = required_str(issue, "id")?;
3360 match self.board_membership(issue_id, cursor).await? {
3361 Some(held) => held,
3362 None => return Ok(None),
3363 }
3364 }
3365 };
3366 let item = json!({
3367 "id": required_str(&held, "id")?,
3368 "project": held.get("project"),
3369 "fieldValues": held.get("fieldValues"),
3370 "content": issue,
3371 });
3372 self.resolve(&item)
3373 }
3374
3375 /// This board's own entry among one page of an issue's `Issue.projectItems`.
3376 ///
3377 /// One spelling of *which membership is this board's*, so the page a read carries and
3378 /// the pages [`Self::board_membership`] walks are searched by the same rule.
3379 fn board_entry<'a>(&self, nodes: &'a [Value]) -> Option<&'a Value> {
3380 nodes.iter().find(|node| {
3381 node.pointer("/project/number").and_then(Value::as_u64)
3382 == Some(u64::from(self.project_number))
3383 })
3384 }
3385
3386 /// The rest of one issue's board memberships, from `after`, for this board's entry.
3387 ///
3388 /// The recovery read: a page of memberships that holds no entry for this board says
3389 /// nothing about the memberships past it, so the connection is walked to exhaustion
3390 /// before an issue is reported as one this board does not hold. `Ok(None)` is that
3391 /// positive answer — the whole connection was read and no entry named this board —
3392 /// rather than a failure, and the walk is held to
3393 /// [`validate_cursor_progress`] like every other page walk here, so a source answering
3394 /// with a cursor that does not advance is refused instead of spun on.
3395 async fn board_membership(
3396 &self,
3397 issue: &str,
3398 after: &str,
3399 ) -> Result<Option<Value>, SourceError> {
3400 let mut after = after.to_owned();
3401 loop {
3402 let data = self
3403 .graphql(
3404 graphql::ISSUE_BOARD_ITEMS,
3405 json!({"id":issue,"first":MAX_PAGE_SIZE,"after":after,
3406 "nestedFirst":NESTED_PAGE_SIZE}),
3407 )
3408 .await?;
3409 let Some(connection) = data
3410 .pointer("/node/projectItems")
3411 .filter(|value| !value.is_null())
3412 else {
3413 // The id resolved to nothing, or to something with no memberships to walk —
3414 // which is the same answer as a connection holding no entry for this board.
3415 return Ok(None);
3416 };
3417 let nodes = connection
3418 .get("nodes")
3419 .and_then(Value::as_array)
3420 .ok_or_else(|| SourceError::Malformed {
3421 message: "GitHub issue projectItems.nodes is not an array".into(),
3422 })?;
3423 if let Some(held) = self.board_entry(nodes) {
3424 return Ok(Some(held.clone()));
3425 }
3426 let info = connection
3427 .get("pageInfo")
3428 .ok_or_else(|| SourceError::Malformed {
3429 message: "GitHub issue projectItems has no pageInfo".into(),
3430 })?;
3431 let next = required_bool(info, "hasNextPage")?
3432 .then(|| required_str(info, "endCursor"))
3433 .transpose()?;
3434 match next {
3435 Some(next) => {
3436 validate_cursor_progress(Some(&after), next)?;
3437 after = next.to_owned();
3438 }
3439 None => return Ok(None),
3440 }
3441 }
3442 }
3443
3444 /// One page of a board-scoped issue search, and where the next page resumes.
3445 async fn search_page(
3446 &self,
3447 search: &str,
3448 first: u32,
3449 after: Option<&str>,
3450 ) -> Result<(Vec<Resolved>, Option<String>), SourceError> {
3451 let data = self
3452 .graphql(
3453 graphql::SEARCH_ISSUES,
3454 json!({"search":search,"type":"ISSUE","first":first.min(MAX_PAGE_SIZE),
3455 "after":after,"nestedFirst":NESTED_PAGE_SIZE,
3456 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3457 )
3458 .await?;
3459 let connection = data.get("search").ok_or_else(|| SourceError::Malformed {
3460 message: "GitHub search response has no search connection".into(),
3461 })?;
3462 let mut found = Vec::new();
3463 for node in connection
3464 .get("nodes")
3465 .and_then(Value::as_array)
3466 .ok_or_else(|| SourceError::Malformed {
3467 message: "GitHub search nodes is not an array".into(),
3468 })?
3469 {
3470 if let Some(resolved) = self.resolve_issue(node).await? {
3471 found.push(resolved);
3472 }
3473 }
3474 let info = connection
3475 .get("pageInfo")
3476 .ok_or_else(|| SourceError::Malformed {
3477 message: "GitHub search connection has no pageInfo".into(),
3478 })?;
3479 let next = required_bool(info, "hasNextPage")?
3480 .then(|| required_str(info, "endCursor"))
3481 .transpose()?
3482 .map(str::to_owned);
3483 if let Some(next) = &next {
3484 validate_cursor_progress(after, next)?;
3485 }
3486 Ok((found, next))
3487 }
3488
3489 /// Every issue this board holds, completed with what this run wrote.
3490 ///
3491 /// The completion is not an optimisation and it is not a cache: GitHub's issue search
3492 /// is an index and is eventually consistent, so an issue this run created seconds ago
3493 /// can be absent from it, and a project listed straight after being written would
3494 /// otherwise be missing from its own board. What is added back is only what this
3495 /// process itself wrote, out of [`Self::created`], which lives and dies with the
3496 /// process.
3497 async fn board_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3498 let found = self.searched_issues().await?;
3499 self.completed_with_written(found, |_| true)
3500 }
3501
3502 /// Every issue this board's own search reports, walked to exhaustion, read once per
3503 /// source.
3504 ///
3505 /// The uncompleted half of [`Self::board_issues`], separated because [`Self::board`]
3506 /// needs it too and the two would otherwise walk the same search twice in one command.
3507 /// See [`Self::search_cache`] for why holding it is the same bargain holding the board
3508 /// is.
3509 async fn searched_issues(&self) -> Result<Vec<Resolved>, SourceError> {
3510 let cached = self.search_cache()?.clone();
3511 if let Some(held) = cached {
3512 return Ok(held);
3513 }
3514 let mut after: Option<String> = None;
3515 let mut found = Vec::new();
3516 let search = self.board_search(None);
3517 loop {
3518 let (page, next) = self
3519 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3520 .await?;
3521 found.extend(page);
3522 match next {
3523 Some(next) => after = Some(next),
3524 None => break,
3525 }
3526 }
3527 *self.search_cache()? = Some(found.clone());
3528 Ok(found)
3529 }
3530
3531 /// This process's own view of the board's issues, or the refusal a poisoned lock is.
3532 fn search_cache(
3533 &self,
3534 ) -> Result<std::sync::MutexGuard<'_, Option<Vec<Resolved>>>, SourceError> {
3535 self.search_cache
3536 .lock()
3537 .map_err(|_| SourceError::Unavailable {
3538 message: "this source's view of the board's issues was left inconsistent by an \
3539 earlier failure; next: run the command again"
3540 .into(),
3541 })
3542 }
3543
3544 /// `found`, with everything this run wrote that `keep` accepts and the read did not
3545 /// report.
3546 ///
3547 /// See [`Self::created`] and [`Self::board_issues`] for why a read has to be completed
3548 /// at all: the search index is behind, and a node read of an item filed moments ago can
3549 /// be too.
3550 fn completed_with_written(
3551 &self,
3552 mut found: Vec<Resolved>,
3553 keep: impl Fn(&Resolved) -> bool,
3554 ) -> Result<Vec<Resolved>, SourceError> {
3555 for own in self.created()?.iter().filter(|own| keep(own)) {
3556 if !found.iter().any(|item| item.id == own.id) {
3557 found.push(own.clone());
3558 }
3559 }
3560 Ok(found)
3561 }
3562
3563 /// What resolving one node id reached.
3564 ///
3565 /// Three answers rather than an `Option`, because a board *draft* is none of the other
3566 /// two: it is not an issue, so the issue fragment reads nothing of it, and a read of one
3567 /// is completed by a read of the draft itself rather than reported as nothing.
3568 async fn reach(&self, id: &NativeId) -> Result<Reached, SourceError> {
3569 let asked = self
3570 .graphql(
3571 graphql::ISSUE,
3572 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3573 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3574 )
3575 .await;
3576 let data = match asked {
3577 Ok(data) => data,
3578 // A string that is not a node id at all is not a failure to report: it is an id
3579 // this board does not hold, which is what every read of one already answers.
3580 Err(error) if unresolvable_node(&error) => return Ok(Reached::Nothing),
3581 Err(error) => return Err(error),
3582 };
3583 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
3584 return Ok(Reached::Nothing);
3585 };
3586 if optional_str(node, "__typename")? == Some("DraftIssue") {
3587 return Ok(Reached::Draft);
3588 }
3589 Ok(match self.resolve_issue(node).await? {
3590 Some(item) => Reached::Held(Box::new(item)),
3591 None => Reached::Nothing,
3592 })
3593 }
3594
3595 /// One item of this board by its own id, whatever kind it is.
3596 ///
3597 /// Resolved from the identifier alone: no search, board-wide or otherwise. What this
3598 /// run wrote is read first, because a node read of an item created moments ago can
3599 /// still be behind the board field values written onto it — see [`Self::created`].
3600 async fn item_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3601 if let Some(own) = self.created()?.iter().find(|own| own.id == *id) {
3602 return Ok(Some(own.clone()));
3603 }
3604 match self.reach(id).await? {
3605 Reached::Held(item) => Ok(Some(*item)),
3606 Reached::Nothing => Ok(None),
3607 Reached::Draft => self.draft_by_id(id).await,
3608 }
3609 }
3610
3611 /// One board draft by its own id, with the board item it sits in — or `None` when no
3612 /// item of this board is that draft's.
3613 ///
3614 /// The same decision [`Self::resolve_issue`] makes for an issue, over the draft's own
3615 /// `projectV2Items`: an entry naming this board is what makes it this board's. GitHub
3616 /// links a draft to one board item, so the page this read carries is the whole of that
3617 /// connection, and a page that reports more than it holds is refused rather than read
3618 /// as an answer about memberships nobody read.
3619 async fn draft_by_id(&self, id: &NativeId) -> Result<Option<Resolved>, SourceError> {
3620 let data = self
3621 .graphql(
3622 graphql::DRAFT,
3623 json!({"id":id.0,"nestedFirst":NESTED_PAGE_SIZE,
3624 "boardItems":BOARD_ITEMS_PAGE_SIZE}),
3625 )
3626 .await?;
3627 // Gone between the two reads is an answer — the draft is no longer there. Anything
3628 // else than the draft [`Self::reach`] was just told this id is, is not one.
3629 let Some(draft) = data.get("node").filter(|node| !node.is_null()) else {
3630 return Ok(None);
3631 };
3632 if optional_str(draft, "__typename")? != Some("DraftIssue") {
3633 return Err(SourceError::Malformed {
3634 message: format!(
3635 "GitHub answered {} as a draft and then as something else",
3636 id.0
3637 ),
3638 });
3639 }
3640 if required_str(draft, "id")? != id.0 {
3641 return Err(SourceError::Malformed {
3642 message: format!("GitHub answered a different draft for {}", id.0),
3643 });
3644 }
3645 let memberships = draft
3646 .get("projectV2Items")
3647 .ok_or_else(|| SourceError::Malformed {
3648 message: format!("GitHub draft {} is missing projectV2Items", id.0),
3649 })?;
3650 let nodes = memberships
3651 .get("nodes")
3652 .and_then(Value::as_array)
3653 .ok_or_else(|| SourceError::Malformed {
3654 message: format!("GitHub draft {} projectV2Items.nodes is not an array", id.0),
3655 })?;
3656 let info = memberships
3657 .get("pageInfo")
3658 .ok_or_else(|| SourceError::Malformed {
3659 message: format!("GitHub draft {} projectV2Items has no pageInfo", id.0),
3660 })?;
3661 // Read whether or not this board's entry is on the page: a page claiming more than
3662 // the one item GitHub links a draft to is a malformed answer either way.
3663 if required_bool(info, "hasNextPage")? || nodes.len() > 1 {
3664 return Err(SourceError::Malformed {
3665 message: format!(
3666 "GitHub draft {} reports more board items than the one GitHub links a draft \
3667 to",
3668 id.0
3669 ),
3670 });
3671 }
3672 if let Some(node) = nodes.first()
3673 && node
3674 .pointer("/project/number")
3675 .and_then(Value::as_u64)
3676 .is_none()
3677 {
3678 return Err(SourceError::Malformed {
3679 message: format!(
3680 "GitHub draft {} board item has no numeric project number",
3681 id.0
3682 ),
3683 });
3684 }
3685 let Some(held) = self.board_entry(nodes) else {
3686 return Ok(None);
3687 };
3688 if required_str(
3689 held.get("project").ok_or_else(|| SourceError::Malformed {
3690 message: format!("GitHub draft {} board item has no project", id.0),
3691 })?,
3692 "id",
3693 )? != self.board_fields().await?.id.as_str()
3694 {
3695 return Ok(None);
3696 }
3697 let item = json!({
3698 "id": required_str(held, "id")?,
3699 "project": held.get("project"),
3700 "fieldValues": held.get("fieldValues"),
3701 "content": draft,
3702 });
3703 self.resolve(&item)
3704 }
3705
3706 /// The board's own id and field definitions, for a write whose item does not carry
3707 /// them — never its items.
3708 ///
3709 /// A board this command has already listed supplies them, since it read them beside its
3710 /// items; otherwise they come from [`graphql::BOARD_FIELDS`], once per command. Neither
3711 /// is consulted about which items the board holds: see the module documentation for
3712 /// why a question about one known item is answered by reading that item.
3713 async fn board_fields(&self) -> Result<BoardFields, SourceError> {
3714 if let Some(board) = self.board_cache()?.as_ref() {
3715 return Ok(BoardFields {
3716 id: BoardId::parse(&board.id)?,
3717 fields: board.fields.clone(),
3718 });
3719 }
3720 if let Some(held) = self.fields_cache()?.clone() {
3721 return Ok(held);
3722 }
3723 let data = self
3724 .graphql(
3725 graphql::BOARD_FIELDS,
3726 json!({"owner":self.owner,"number":self.project_number,
3727 "nestedFirst":NESTED_PAGE_SIZE}),
3728 )
3729 .await?;
3730 let board = data
3731 .pointer("/boardFields/projectV2")
3732 .filter(|value| !value.is_null())
3733 .ok_or_else(|| SourceError::Refused {
3734 message: format!(
3735 "GitHub project {}/{} was not found or is not visible to the token",
3736 self.owner, self.project_number
3737 ),
3738 })?;
3739 let read = BoardFields {
3740 id: BoardId::parse(required_str(board, "id")?)?,
3741 fields: board.get("fields").cloned().unwrap_or(Value::Null),
3742 };
3743 *self.fields_cache()? = Some(read.clone());
3744 Ok(read)
3745 }
3746
3747 /// This process's own view of the board's fields, or the refusal a poisoned lock is.
3748 fn fields_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<BoardFields>>, SourceError> {
3749 self.fields_cache
3750 .lock()
3751 .map_err(|_| SourceError::Unavailable {
3752 message: "this source's view of the board's fields was left inconsistent by an \
3753 earlier failure; next: run the command again"
3754 .into(),
3755 })
3756 }
3757
3758 /// What a write to `item` needs of the board, read off that item when it says enough and
3759 /// off [`Self::board_fields`] when it does not.
3760 ///
3761 /// A node read of an item names its board and carries the definition of every field it
3762 /// holds a value of — so an item naming its board, holding a value of the origin field,
3763 /// and, when the write carries a status, holding a `Status` value, needs no read of the
3764 /// board at all. **Nothing the item does not say is guessed:** a field it holds no value
3765 /// of may still be on the board, and a view reading it as absent would refuse a write the
3766 /// board can take or skip a field write the board needs, so such an item — and a create,
3767 /// which has no item yet — takes the board's fields from their own read instead.
3768 async fn fields_for(
3769 &self,
3770 item: Option<&Resolved>,
3771 writes_status: bool,
3772 selects_priority: bool,
3773 ) -> Result<BoardFields, SourceError> {
3774 if let Some(item) = item
3775 && let Some(board_id) = item.named_board()
3776 && item.defines(ORIGIN_FIELD)
3777 && (!writes_status || item.defines("Status"))
3778 && (!selects_priority || item.defines(PRIORITY_FIELD))
3779 {
3780 return Ok(BoardFields {
3781 id: board_id,
3782 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
3783 });
3784 }
3785 self.board_fields().await
3786 }
3787
3788 /// Everything filed under one issue of this board, walked to exhaustion — or `None`
3789 /// when that id names nothing here with a sub-issue relationship to walk.
3790 ///
3791 /// `None` and an empty answer are different: `None` is *this is not an issue of this
3792 /// GitHub*, which is what sends a project selector on to be read as a name, and an
3793 /// empty vector is a project that holds nothing.
3794 async fn sub_issues(&self, id: &NativeId) -> Result<Option<Vec<Resolved>>, SourceError> {
3795 let mut after: Option<String> = None;
3796 let mut children = Vec::new();
3797 loop {
3798 let asked = self
3799 .graphql(
3800 graphql::SUB_ISSUES,
3801 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after,
3802 "nestedFirst":NESTED_PAGE_SIZE,
3803 "boardItems":BOARD_ITEMS_PAGE_SIZE,"duplicates":true}),
3804 )
3805 .await;
3806 let data = match asked {
3807 Ok(data) => data,
3808 // A string that is not a node id at all is not a failure to report: it is
3809 // the ordinary answer to a selector naming a project by its name.
3810 Err(error) if unresolvable_node(&error) => return Ok(None),
3811 Err(error) => return Err(error),
3812 };
3813 let Some(connection) = data
3814 .pointer("/node/subIssues")
3815 .filter(|value| !value.is_null())
3816 else {
3817 // No such node, or one with no sub-issue relationship — a board draft is
3818 // the one this board can really hold.
3819 return Ok(None);
3820 };
3821 for node in connection
3822 .get("nodes")
3823 .and_then(Value::as_array)
3824 .ok_or_else(|| SourceError::Malformed {
3825 message: "GitHub subIssues.nodes is not an array".into(),
3826 })?
3827 {
3828 if let Some(resolved) = self.resolve_issue(node).await? {
3829 children.push(resolved);
3830 }
3831 }
3832 let info = connection
3833 .get("pageInfo")
3834 .ok_or_else(|| SourceError::Malformed {
3835 message: "GitHub subIssues connection has no pageInfo".into(),
3836 })?;
3837 let next = required_bool(info, "hasNextPage")?
3838 .then(|| required_str(info, "endCursor"))
3839 .transpose()?;
3840 match next {
3841 Some(next) => {
3842 validate_cursor_progress(after.as_deref(), next)?;
3843 after = Some(next.to_owned());
3844 }
3845 None => return Ok(Some(children)),
3846 }
3847 }
3848 }
3849
3850 /// Which issue of this board a project *name* is, or `None` when none is.
3851 ///
3852 /// One bounded query which filters on that name at the server, rather than a walk of
3853 /// every issue the board holds. The name is compared again here: the qualifier narrows
3854 /// what GitHub sends, and this source decides what it names.
3855 async fn project_by_name(&self, name: &str) -> Result<Option<NativeId>, SourceError> {
3856 let search = self.board_search(Some(&title_qualifier(name)));
3857 let (candidates, _) = self.search_page(&search, MAX_PAGE_SIZE, None).await?;
3858 Ok(candidates
3859 .into_iter()
3860 .find(|item| {
3861 item.kind == BoardKind::Work(ItemKind::Project)
3862 && item.title.eq_ignore_ascii_case(name)
3863 })
3864 .map(|item| item.id))
3865 }
3866
3867 /// Everything filed under one project of this board: the sub-issues of the issue that
3868 /// project is.
3869 ///
3870 /// Tasks *and* documents, because a document filed under a project is a sub-issue of it
3871 /// too — the caller keeps the kind it asked for. Nothing about this grows as the board
3872 /// gains projects, or as another project gains tasks.
3873 ///
3874 /// A qualified id names the issue and is asked for its sub-issues directly: one
3875 /// request, no search of any kind. Only a selector GitHub cannot resolve that way is
3876 /// read as a project *name*, which costs the one bounded search
3877 /// [`Self::project_by_name`] makes.
3878 async fn project_children(&self, selector: &NativeId) -> Result<Vec<Resolved>, SourceError> {
3879 let (project, children) = match self.sub_issues(selector).await? {
3880 Some(children) => (selector.clone(), children),
3881 None => match self.project_by_name(&selector.0).await? {
3882 Some(project) => {
3883 let children = self.sub_issues(&project).await?.unwrap_or_default();
3884 (project, children)
3885 }
3886 None => return Ok(Vec::new()),
3887 },
3888 };
3889 self.completed_with_written(children, |own| own.parent.as_ref() == Some(&project))
3890 }
3891
3892 /// Every issue of this board GitHub's issue search reports updated at or after `since`,
3893 /// completed with what this run wrote — the candidates a comment-activity read confirms.
3894 ///
3895 /// Scoped by the board and by nothing else: `project:<owner>/<number>` reaches every issue
3896 /// on the board whatever repository, and whatever owner, it lives in, so no repository or
3897 /// owner qualifier is added and none is needed. What makes the `updated:` qualifier
3898 /// sufficient is a fact about GitHub rather than about this source: a comment written on an
3899 /// issue **and a comment edited on it** both move that issue's `updatedAt`. The credentialed
3900 /// journey `an_edited_comment_moves_its_issue_and_is_selected_since` in `tests/journey`
3901 /// re-takes that fact on every run of the lane, so a change on GitHub's side fails there
3902 /// rather than silently narrowing a caller's answer.
3903 ///
3904 /// The instant is written to the second, rounded down, which can only widen what the
3905 /// search returns; confirmation against each candidate's own comments is what makes the
3906 /// answer exact. The search is an index that lags a write by a second or two — the module
3907 /// documentation records it — so a caller that asks again from its last instant should
3908 /// overlap the two by more than that.
3909 async fn updated_since(&self, since: DateTime<Utc>) -> Result<Vec<Resolved>, SourceError> {
3910 let qualifier = format!("updated:>={}", since.format("%Y-%m-%dT%H:%M:%S+00:00"));
3911 let search = self.board_search(Some(&qualifier));
3912 let mut after: Option<String> = None;
3913 let mut found = Vec::new();
3914 loop {
3915 let (page, next) = self
3916 .search_page(&search, MAX_PAGE_SIZE, after.as_deref())
3917 .await?;
3918 found.extend(page);
3919 match next {
3920 Some(next) => after = Some(next),
3921 None => break,
3922 }
3923 }
3924 self.completed_with_written(found, |_| true)
3925 }
3926
3927 /// Whether `item` has a comment created or last edited at or after `since` — always, when
3928 /// there is no instant to hold it to.
3929 ///
3930 /// The candidate's own `updatedAt` is read first, because a comment written or edited at
3931 /// or after the instant moved it there: an issue not updated since holds no such comment,
3932 /// and its comments are never asked for. Otherwise its comments are walked, oldest first,
3933 /// only as far as the first that matches. A board draft is not an issue and has no
3934 /// comments, so it never matches.
3935 async fn commented_since(
3936 &self,
3937 item: &Resolved,
3938 since: Option<DateTime<Utc>>,
3939 ) -> Result<bool, SourceError> {
3940 let Some(since) = since else {
3941 return Ok(true);
3942 };
3943 if item.content_kind == ContentKind::DraftIssue
3944 || item.updated_at.is_some_and(|updated| updated < since)
3945 {
3946 return Ok(false);
3947 }
3948 let query = TaskQuery {
3949 commented_since: Some(since),
3950 ..TaskQuery::default()
3951 };
3952 let mut after: Option<String> = None;
3953 loop {
3954 let data = self
3955 .graphql(
3956 graphql::ISSUE_COMMENTS,
3957 json!({"id":item.id.0,"first":MAX_PAGE_SIZE,"after":after}),
3958 )
3959 .await?;
3960 let Some(connection) = data
3961 .get("node")
3962 .filter(|value| !value.is_null())
3963 .and_then(|node| node.get("comments"))
3964 .filter(|value| !value.is_null())
3965 else {
3966 // Removed since the search reported it: no longer an issue with comments.
3967 return Ok(false);
3968 };
3969 let comments = optional_nodes(Some(connection), "issue comments")?
3970 .into_iter()
3971 .flatten()
3972 .map(comment_from)
3973 .collect::<Result<Vec<_>, _>>()?;
3974 if query.comments_match(&comments) {
3975 return Ok(true);
3976 }
3977 match next_cursor(connection)? {
3978 Some(next) => {
3979 validate_cursor_progress(after.as_deref(), &next.0)?;
3980 after = Some(next.0);
3981 }
3982 None => return Ok(false),
3983 }
3984 }
3985 }
3986
3987 /// Every item on the board: the union of both enumerations GitHub offers of one.
3988 ///
3989 /// Neither contains the other, so neither is dropped — only `ProjectV2.items` lists a
3990 /// board **draft** and reads the board's own fields beside its items, and only the search
3991 /// reports an item that connection is behind on. The module documentation is where the lag and the
3992 /// measurements behind it are written down.
3993 ///
3994 /// A search result is admitted on the same terms as any other issue this source reaches
3995 /// directly — [`Self::resolve_issue`] keeps it only if that issue's own `projectItems`
3996 /// names *this* board — so an issue the index still believes is here after it was taken
3997 /// off is refused rather than reported.
3998 ///
3999 /// See [`Self::board_cache`]. Both completions happen on every call rather than once,
4000 /// which is what the cache could otherwise have broken.
4001 async fn board(&self) -> Result<Board, SourceError> {
4002 let cached = self.board_cache()?.clone();
4003 let mut board = match cached {
4004 Some(board) => board,
4005 None => {
4006 let read = self.read_board().await?;
4007 *self.board_cache()? = Some(read.clone());
4008 read
4009 }
4010 };
4011 for held in self.searched_issues().await? {
4012 if !board.items.iter().any(|item| item.id == held.id) {
4013 board.items.push(held);
4014 }
4015 }
4016 for own in self.created()?.iter() {
4017 if !board.items.iter().any(|item| item.id == own.id) {
4018 board.items.push(own.clone());
4019 }
4020 }
4021 Ok(board)
4022 }
4023
4024 /// This process's own view of the board, or the refusal a poisoned lock is.
4025 fn board_cache(&self) -> Result<std::sync::MutexGuard<'_, Option<Board>>, SourceError> {
4026 self.board_cache
4027 .lock()
4028 .map_err(|_| SourceError::Unavailable {
4029 message: "this source's view of the board was left inconsistent by an earlier \
4030 failure; next: run the command again"
4031 .into(),
4032 })
4033 }
4034
4035 /// Bring this process's own view of the board up to an item it has just written.
4036 ///
4037 /// A created item goes to `created`, which is what completes a board read GitHub's own
4038 /// eventual consistency has left behind. An item that was already there is replaced
4039 /// where it sits, so a second write of it in the same command reads its real parent
4040 /// rather than the one it had before the first write.
4041 ///
4042 /// "Where it sits" is three places, and missing an earlier one leaves a stale record
4043 /// that wins: an item this same run created is held in `created` and not in the cached
4044 /// board, and `board` completes the cached board *from* `created`, so replacing only
4045 /// the cached copy of such an item replaces nothing and the read still reports the
4046 /// title it was created with. The search is the third, and it is the one an item the
4047 /// board's own projection is behind on sits in *alone* — which is exactly the item this
4048 /// source is least able to re-read, so leaving it out would put the stale title back on
4049 /// the only items the completion in [`Self::board`] exists for.
4050 fn remember_written(&self, item: Resolved, created: bool) -> Result<(), SourceError> {
4051 if created {
4052 self.created()?.push(item);
4053 return Ok(());
4054 }
4055 {
4056 let mut own = self.created()?;
4057 if let Some(held) = own.iter_mut().find(|held| held.id == item.id) {
4058 *held = item;
4059 return Ok(());
4060 }
4061 }
4062 if let Some(board) = self.board_cache()?.as_mut()
4063 && let Some(held) = board.items.iter_mut().find(|held| held.id == item.id)
4064 {
4065 *held = item.clone();
4066 }
4067 if let Some(found) = self.search_cache()?.as_mut()
4068 && let Some(held) = found.iter_mut().find(|held| held.id == item.id)
4069 {
4070 *held = item;
4071 }
4072 Ok(())
4073 }
4074
4075 /// Forget one item this process has just deleted, from every half of its own view.
4076 fn forget(&self, id: &NativeId) -> Result<(), SourceError> {
4077 self.created()?.retain(|own| own.id != *id);
4078 if let Some(board) = self.board_cache()?.as_mut() {
4079 board.items.retain(|item| item.id != *id);
4080 }
4081 if let Some(found) = self.search_cache()?.as_mut() {
4082 found.retain(|item| item.id != *id);
4083 }
4084 Ok(())
4085 }
4086
4087 /// Every page of the board, read from GitHub.
4088 async fn read_board(&self) -> Result<Board, SourceError> {
4089 let mut after: Option<String> = None;
4090 let mut items = Vec::new();
4091 let mut board;
4092 loop {
4093 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
4094 for item in page
4095 .pointer("/items/nodes")
4096 .and_then(Value::as_array)
4097 .ok_or_else(|| SourceError::Malformed {
4098 message: "GitHub project items.nodes is not an array".into(),
4099 })?
4100 {
4101 if let Some(resolved) = self.resolve(item)? {
4102 items.push(resolved);
4103 }
4104 }
4105 let info = page
4106 .pointer("/items/pageInfo")
4107 .ok_or_else(|| SourceError::Malformed {
4108 message: "GitHub project items have no pageInfo".into(),
4109 })?;
4110 let has_next = required_bool(info, "hasNextPage")?;
4111 let next = has_next
4112 .then(|| required_str(info, "endCursor"))
4113 .transpose()?;
4114 board = page.clone();
4115 match next {
4116 Some(next) => {
4117 validate_cursor_progress(after.as_deref(), next)?;
4118 after = Some(next.to_owned());
4119 }
4120 None => break,
4121 }
4122 }
4123 Ok(Board {
4124 id: required_str(&board, "id")?.to_owned(),
4125 fields: board.get("fields").cloned().unwrap_or(Value::Null),
4126 items,
4127 })
4128 }
4129
4130 /// The items this source has created, for completing a board read that is behind.
4131 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
4132 self.created.lock().map_err(|_| SourceError::Unavailable {
4133 message: "this source's record of what it created in this run was left \
4134 inconsistent by an earlier failure; next: run the command again"
4135 .into(),
4136 })
4137 }
4138
4139 /// One board item as this source reports it, or `None` for content it ignores.
4140 ///
4141 /// A pull request is neither a project nor a task — it is somebody's change, not a
4142 /// unit of plan — and an item whose content the token cannot see has nothing to
4143 /// report at all.
4144 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
4145 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
4146 message: "GitHub project item is missing content".into(),
4147 })?;
4148 if content.is_null() {
4149 return Ok(None);
4150 }
4151 let content_kind = match required_str(content, "__typename")? {
4152 "Issue" => ContentKind::Issue,
4153 "DraftIssue" => ContentKind::DraftIssue,
4154 _ => return Ok(None),
4155 };
4156 let field_values = item
4157 .get("fieldValues")
4158 .ok_or_else(|| SourceError::Malformed {
4159 message: "GitHub project item is missing fieldValues".into(),
4160 })?;
4161 complete_connection(field_values, "project item field values", NESTED_PAGE_SIZE)?;
4162 let nodes = field_values
4163 .get("nodes")
4164 .and_then(Value::as_array)
4165 .ok_or_else(|| SourceError::Malformed {
4166 message: "GitHub project item fieldValues.nodes is not an array".into(),
4167 })?;
4168 if let Some(labels) = content.get("labels") {
4169 complete_connection(labels, "content labels", NESTED_PAGE_SIZE)?;
4170 }
4171 let raw_body = optional_str(content, "body")?.map(str::to_owned);
4172 let (body, slot) = metadata_body(raw_body.clone())?;
4173 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
4174 .map(|id| NativeId(id.to_owned()));
4175 // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
4176 // to read one from; it is a task, and never a project.
4177 let sub_issues = match content_kind {
4178 ContentKind::Issue => sub_issue_total(content)?,
4179 ContentKind::DraftIssue => 0,
4180 };
4181 let content_id = required_str(content, "id")?;
4182 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
4183 message: format!("GitHub issue {content_id}: {message}"),
4184 })?;
4185 let raw_title = required_str(content, "title")?;
4186 // The design prefix is read *first*, before either of the two rules that separate
4187 // a project from a task. A document is not work whatever sub-issues it has and
4188 // whatever marker it carries, and reading the prefix later would make a design
4189 // issue with none of either an empty project.
4190 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
4191 BoardKind::Document
4192 } else if parent.is_some() {
4193 // Being a sub-issue wins outright, and no marker overrides it: an issue filed
4194 // under a project is that project's task even when it has sub-issues of its
4195 // own.
4196 BoardKind::Work(ItemKind::Task)
4197 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
4198 BoardKind::Work(ItemKind::Project)
4199 } else {
4200 BoardKind::Work(ItemKind::Task)
4201 };
4202 // The title a person wrote, which for a document is the one without the prefix —
4203 // the same way `content` above is the body without this source's metadata slot.
4204 let title = match kind {
4205 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
4206 BoardKind::Work(_) => raw_title.to_owned(),
4207 };
4208 let own_repository = content
4209 .pointer("/repository/nameWithOwner")
4210 .and_then(Value::as_str)
4211 .map(|origin| Repository::try_from(format!("{}/{origin}", RepositoryTarget::HOST)))
4212 .transpose()
4213 .map_err(|message| SourceError::Malformed { message })?;
4214 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
4215 Repository::from_metadata(&slot)
4216 .map_err(|message| SourceError::Malformed { message })?
4217 } else {
4218 own_repository.clone().into_iter().collect()
4219 };
4220 let id = NativeId(content_id.to_owned());
4221 // Read only for a task, because only a task has either list: a project or a
4222 // document holding one of these keys holds nothing this source reports, and the
4223 // keys are left out of its caller-visible metadata all the same.
4224 let (delivers, delivered_by) = if kind == BoardKind::Work(ItemKind::Task) {
4225 let listed = |key: &str| {
4226 TaskRef::from_value(key, &id, Some(&self.name), slot.get(key))
4227 .map_err(|message| SourceError::Malformed { message })
4228 };
4229 (
4230 listed(TaskRef::DELIVERS_KEY)?,
4231 listed(TaskRef::DELIVERED_BY_KEY)?,
4232 )
4233 } else {
4234 (Vec::new(), Vec::new())
4235 };
4236 let (option, closed, reason) = Self::status_parts(nodes, content)?;
4237 let priority = self.held_priority(nodes)?;
4238 Ok(Some(Resolved {
4239 item_id: required_str(item, "id")?.to_owned(),
4240 id,
4241 content_kind,
4242 kind,
4243 title,
4244 body: body.filter(|value| !value.is_empty()),
4245 raw_body,
4246 status: self.statuses.status(option, closed, reason),
4247 option: option.map(str::to_owned),
4248 priority,
4249 closed,
4250 delivers,
4251 delivered_by,
4252 labels: labels(content)?,
4253 parent,
4254 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
4255 number: match content_kind {
4256 ContentKind::Issue => Some(issue_number(content)?),
4257 // A draft is filed in no repository, so nothing ever numbered it:
4258 // `DraftIssue` declares no `number` at all, exactly as it declares no
4259 // `subIssuesSummary` the branch above reads.
4260 ContentKind::DraftIssue => None,
4261 },
4262 url: optional_str(content, "url")?.map(str::to_owned),
4263 created_at: optional_time(content, "createdAt")?,
4264 updated_at: optional_time(content, "updatedAt")?,
4265 own_repository,
4266 repositories,
4267 slot,
4268 // Present when the item was reached through its own issue, whose board entry
4269 // names the board; a read of the board's own items has the board already. An
4270 // empty id names nothing a field write could address, so it is read as absent and
4271 // the write goes back to reading the board.
4272 board_id: item
4273 .pointer("/project/id")
4274 .and_then(Value::as_str)
4275 .filter(|id| !id.is_empty())
4276 .map(str::to_owned),
4277 fields: field_definitions(nodes),
4278 }))
4279 }
4280
4281 /// What one board item's `Priority` field says, through this instance's mapping.
4282 ///
4283 /// An instance with no mapping holds no priority, so every item reads as `none` whatever
4284 /// its board holds. With one, no value is `none`, a mapped option is its level, and an
4285 /// option the mapping does not name is kept as itself — never read as a level or as
4286 /// `none` — for a read of the task to report by name.
4287 fn held_priority(&self, field_values: &[Value]) -> Result<HeldPriority, SourceError> {
4288 let Some(mapping) = &self.priorities else {
4289 return Ok(HeldPriority::Read(Priority::None));
4290 };
4291 // A value of the field that names no option — a text field someone called `Priority` —
4292 // is malformed rather than `none`: reading it as no priority would let the next copy
4293 // clear one a person set.
4294 let Some(option) = field_values
4295 .iter()
4296 .find(|value| {
4297 value.pointer("/field/name").and_then(Value::as_str) == Some(PRIORITY_FIELD)
4298 })
4299 .map(|value| required_str(value, "name"))
4300 .transpose()?
4301 else {
4302 return Ok(HeldPriority::Read(Priority::None));
4303 };
4304 Ok(mapping.priority_of(option).map_or_else(
4305 || HeldPriority::Unmapped(option.to_owned()),
4306 HeldPriority::Read,
4307 ))
4308 }
4309
4310 /// What one board item's status is read from: its `Status` option, whether its issue
4311 /// is closed, and the reason it was closed with. [`StatusMapping::status`] turns the
4312 /// three into the status it reports.
4313 fn status_parts<'a>(
4314 field_values: &'a [Value],
4315 content: &'a Value,
4316 ) -> Result<(Option<&'a str>, bool, Option<&'a str>), SourceError> {
4317 let option = field_values
4318 .iter()
4319 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
4320 .map(|value| required_str(value, "name"))
4321 .transpose()?;
4322 let closed = optional_str(content, "state")? == Some("CLOSED");
4323 Ok((option, closed, optional_str(content, "stateReason")?))
4324 }
4325
4326 /// The board Status option this write selects, or the refusal that says why not.
4327 ///
4328 /// The mapped option is required for both open and terminal targets. A terminal write
4329 /// validates it before changing either representation, so it can never fall back to
4330 /// closing an issue whose board cannot display the matching status.
4331 ///
4332 /// Answers the field's id, the option's id, and the option's name as the board spells
4333 /// it — which is the name a read of the item reports once it sits there.
4334 fn column_for(
4335 &self,
4336 fields: &Value,
4337 status: &Status,
4338 target: &StatusTarget,
4339 ) -> Result<Option<(String, String, String)>, SourceError> {
4340 let wanted = match target {
4341 StatusTarget::Column(wanted) | StatusTarget::Terminal(wanted, _) => wanted.as_str(),
4342 StatusTarget::Disabled => return Ok(None),
4343 };
4344 let missing = |detail: &str| SourceError::Refused {
4345 message: format!(
4346 "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",
4347 category_name(status.category),
4348 self.name,
4349 category_name(status.category)
4350 ),
4351 };
4352 let Some(field) = Board::field(fields, "Status")? else {
4353 return Err(missing("this board has no Status field"));
4354 };
4355 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4356 return Err(missing(
4357 "this board's Status field is not a single-select field",
4358 ));
4359 }
4360 let option = field
4361 .get("options")
4362 .and_then(Value::as_array)
4363 .and_then(|options| {
4364 options.iter().find(|option| {
4365 option
4366 .get("name")
4367 .and_then(Value::as_str)
4368 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4369 })
4370 });
4371 match option {
4372 None => Err(missing("this board does not have it")),
4373 Some(option) => Ok(Some((
4374 required_str(field, "id")?.to_owned(),
4375 required_str(option, "id")?.to_owned(),
4376 required_str(option, "name")?.to_owned(),
4377 ))),
4378 }
4379 }
4380
4381 /// The refusal a status that closes an issue is answered with over a board draft.
4382 fn closes_a_draft(&self, category: StatusCategory) -> SourceError {
4383 SourceError::Refused {
4384 message: format!(
4385 "status {} of source {} closes the item's issue, and GitHub draft items have \
4386 no open or closed state",
4387 category_name(category),
4388 self.name
4389 ),
4390 }
4391 }
4392
4393 /// What a status write to one item needs of the board: the board's id and the
4394 /// definition of its `Status` field, read off the item when the item says both.
4395 ///
4396 /// The same reasoning as [`Self::fields_for`]: a node read of the item names its board,
4397 /// and its `Status` value carries that field's definition, options and all. An item that
4398 /// does not say — no board id, or no `Status` value to read the field off — takes them
4399 /// from [`Self::board_fields`], which reads no item.
4400 async fn status_board(&self, item: &Resolved) -> Result<BoardFields, SourceError> {
4401 if item.defines("Status")
4402 && let Some(board_id) = item.named_board()
4403 {
4404 return Ok(BoardFields {
4405 id: board_id,
4406 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4407 });
4408 }
4409 self.board_fields().await
4410 }
4411
4412 /// Set one task's status and nothing else; see [`TaskSource::set_task_status`].
4413 async fn set_status(
4414 &self,
4415 id: &NativeId,
4416 category: StatusCategory,
4417 ) -> Result<Option<Status>, SourceError> {
4418 // Refused before anything is read, in the words a write of the same status is.
4419 let target = self.resolved_target(category)?;
4420 let Some(mut item) = self
4421 .item_by_id(id)
4422 .await?
4423 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4424 else {
4425 return Ok(None);
4426 };
4427 let board = self.status_board(&item).await?;
4428 let wanted = Status {
4429 category,
4430 name: category_name(category).to_owned(),
4431 };
4432 let (field, option, name) = self
4433 .column_for(&board.fields, &wanted, &target)?
4434 .ok_or_else(|| SourceError::Malformed {
4435 message: format!(
4436 "status {} of source {} names no board Status option",
4437 category_name(category),
4438 self.name
4439 ),
4440 })?;
4441 match &target {
4442 StatusTarget::Terminal(_, reason) => {
4443 if item.content_kind == ContentKind::DraftIssue {
4444 return Err(self.closes_a_draft(category));
4445 }
4446 self.set_item_field(
4447 board.id.as_str(),
4448 &item.item_id,
4449 &field,
4450 json!({"singleSelectOptionId": option}),
4451 )
4452 .await?;
4453 self.update_content(
4454 ContentKind::Issue,
4455 &item.id,
4456 json!({"stateInput": state_input(Some(&target))}),
4457 )
4458 .await?;
4459 item.closed = true;
4460 item.status = self
4461 .statuses
4462 .status(Some(&name), true, Some(reason.reason()));
4463 item.option = Some(name);
4464 }
4465 StatusTarget::Column(_) => {
4466 // An option is what an open item's status is, so a closed issue is reopened
4467 // first — sitting closed in the column, it would read back as closed. A draft has
4468 // no state to reopen.
4469 if item.content_kind == ContentKind::Issue && item.closed {
4470 self.update_content(
4471 ContentKind::Issue,
4472 &item.id,
4473 json!({"stateInput": state_input(Some(&target))}),
4474 )
4475 .await?;
4476 item.closed = false;
4477 }
4478 self.set_item_field(
4479 board.id.as_str(),
4480 &item.item_id,
4481 &field,
4482 json!({"singleSelectOptionId": option}),
4483 )
4484 .await?;
4485 item.status = self.statuses.status(Some(&name), false, None);
4486 item.option = Some(name);
4487 }
4488 StatusTarget::Disabled => unreachable!("resolved_target refused a disabled status"),
4489 }
4490 let status = item.status.clone();
4491 self.remember_written(item, false)?;
4492 Ok(Some(status))
4493 }
4494
4495 /// Replace one task's `delivered_by` and nothing else; see
4496 /// [`TaskSource::set_delivered_by`].
4497 ///
4498 /// One update of the body, which differs from the body GitHub holds only inside the
4499 /// metadata slot — see [`with_slot`]. A body that would not change is not sent at all.
4500 async fn replace_delivered_by(
4501 &self,
4502 id: &NativeId,
4503 delivered_by: &[TaskRef],
4504 ) -> Result<Option<()>, SourceError> {
4505 let entries = TaskRef::listed(
4506 TaskRef::DELIVERED_BY_KEY,
4507 id,
4508 Some(&self.name),
4509 delivered_by.to_vec(),
4510 )
4511 .map_err(|message| SourceError::Refused { message })?;
4512 let Some(mut item) = self
4513 .item_by_id(id)
4514 .await?
4515 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4516 else {
4517 return Ok(None);
4518 };
4519 let mut slot = item.slot.clone();
4520 set_task_list(&mut slot, TaskRef::DELIVERED_BY_KEY, &entries);
4521 self.write_slot(&mut item, &slot).await?;
4522 item.delivered_by = entries;
4523 self.remember_written(item, false)?;
4524 Ok(Some(()))
4525 }
4526
4527 /// Set one caller key of the metadata slot of one issue of `kind`, and nothing else;
4528 /// see [`TaskSource::set_task_metadata`].
4529 ///
4530 /// `None` when this board holds no item by that id, or holds one of another kind. The
4531 /// answer is the item as this source now reads it, so what a caller is told the key
4532 /// holds is what the slot holds.
4533 ///
4534 /// A key already holding the value is answered without a write, compared as JSON rather
4535 /// than as the body's bytes: a slot a person spelled with other whitespace would
4536 /// otherwise be re-encoded, which is a write that changes nothing the caller asked for.
4537 async fn set_slot_key(
4538 &self,
4539 id: &NativeId,
4540 kind: BoardKind,
4541 key: &MetadataKey,
4542 value: &Value,
4543 ) -> Result<Option<Resolved>, SourceError> {
4544 let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
4545 return Ok(None);
4546 };
4547 if item.slot.get(key.as_str()) == Some(value) {
4548 return Ok(Some(item));
4549 }
4550 let mut slot = item.slot.clone();
4551 slot.insert(key.as_str().to_owned(), value.clone());
4552 self.write_slot(&mut item, &slot).await?;
4553 self.remember_written(item.clone(), false)?;
4554 Ok(Some(item))
4555 }
4556
4557 /// Put `slot` in one item's metadata slot with a single update of its body, and bring
4558 /// `item` up to what that write left.
4559 ///
4560 /// The body sent differs from the body GitHub holds only inside the slot — see
4561 /// [`with_slot`] — and a body that would not change is not sent at all. It goes through
4562 /// the mutation the item's content takes, so a board draft's body is written with
4563 /// `updateProjectV2DraftIssue` exactly as an issue's is with `updateIssue`.
4564 async fn write_slot(
4565 &self,
4566 item: &mut Resolved,
4567 slot: &BTreeMap<String, Value>,
4568 ) -> Result<(), SourceError> {
4569 let held = item.raw_body.clone().unwrap_or_default();
4570 let body = with_slot(&held, slot)?;
4571 if body != held {
4572 self.update_content(item.content_kind, &item.id, json!({"body": body}))
4573 .await?;
4574 }
4575 let (visible, slot) = metadata_body(Some(body.clone()))?;
4576 item.body = visible.filter(|value| !value.is_empty());
4577 item.raw_body = Some(body);
4578 item.slot = slot;
4579 Ok(())
4580 }
4581
4582 /// This instance's target for a category, refusing one it has disabled.
4583 ///
4584 /// Nothing here mutates the board's option set to make room for a status. GitHub
4585 /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
4586 /// overwrite existing options"*, so no addition is additive and a mistake destroys the
4587 /// field and every item's status.
4588 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
4589 let target = self.statuses.target(category).clone();
4590 if target != StatusTarget::Disabled {
4591 return Ok(target);
4592 }
4593 Err(SourceError::Refused {
4594 message: if category == StatusCategory::Draft {
4595 format!(
4596 "status draft is disabled for source {}: draft is incompatible with this \
4597 integration because GitHub draft issues cannot have sub-issues, and this \
4598 source stores a project's tasks as its issue's sub-issues",
4599 self.name
4600 )
4601 } else if category == StatusCategory::Unknown {
4602 format!(
4603 "status {} is disabled for source {}; set status_mapping.{} of this source \
4604 to one board Status option name; every word classified unknown is written \
4605 to that one option",
4606 category_name(category),
4607 self.name,
4608 category_name(category)
4609 )
4610 } else {
4611 format!(
4612 "status {} is disabled for source {}; set status_mapping.{} of this source \
4613 to a board Status option name",
4614 category_name(category),
4615 self.name,
4616 category_name(category)
4617 )
4618 },
4619 })
4620 }
4621
4622 /// What writing `priority` does to one item's `Priority` field on this board, or the
4623 /// refusal naming what the board lacks.
4624 ///
4625 /// `none` is no value, so it clears the field — and asks nothing of an item that holds
4626 /// none already, or of an item not created yet. Every other priority selects the option
4627 /// the mapping names, matched case-insensitively; a board with no `Priority` field, or
4628 /// without that option, is refused rather than given one: reads and writes never create
4629 /// a field or an option.
4630 fn priority_write(
4631 &self,
4632 fields: &Value,
4633 existing: Option<&Resolved>,
4634 priority: Priority,
4635 ) -> Result<Option<PriorityWrite>, SourceError> {
4636 let Some(mapping) = &self.priorities else {
4637 return Err(self.holds_no_priority());
4638 };
4639 let Some(wanted) = mapping.option(priority) else {
4640 if !existing.is_some_and(Resolved::holds_priority) {
4641 return Ok(None);
4642 }
4643 let field =
4644 Board::field(fields, PRIORITY_FIELD)?.ok_or_else(|| SourceError::Malformed {
4645 message: format!(
4646 "an item holding a {PRIORITY_FIELD} value was read without that field"
4647 ),
4648 })?;
4649 return Ok(Some(PriorityWrite::Clear {
4650 field: required_str(field, "id")?.to_owned(),
4651 }));
4652 };
4653 let missing = |detail: &str| SourceError::Refused {
4654 message: format!(
4655 "priority {priority} of source {} needs the board {PRIORITY_FIELD} option \
4656 {wanted:?}, and {detail}; run `onetaskgraph sources fields {} --apply` to add \
4657 it, or point priority_mapping.{priority} of this source at an option the board \
4658 has",
4659 self.name, self.name
4660 ),
4661 };
4662 let Some(field) = Board::field(fields, PRIORITY_FIELD)? else {
4663 return Err(missing(&format!(
4664 "this board has no {PRIORITY_FIELD} field"
4665 )));
4666 };
4667 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
4668 return Err(missing(&format!(
4669 "this board's {PRIORITY_FIELD} field is not a single-select field"
4670 )));
4671 }
4672 // An options list that is absent or not a list is an answer this source cannot read,
4673 // not a board lacking the option: `sources fields --apply` is no remedy for it.
4674 let option = field
4675 .get("options")
4676 .and_then(Value::as_array)
4677 .ok_or_else(|| SourceError::Malformed {
4678 message: format!("GitHub {PRIORITY_FIELD} field options is not an array"),
4679 })?
4680 .iter()
4681 .find(|option| {
4682 option
4683 .get("name")
4684 .and_then(Value::as_str)
4685 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
4686 })
4687 .ok_or_else(|| missing("this board does not have it"))?;
4688 Ok(Some(PriorityWrite::Select {
4689 field: required_str(field, "id")?.to_owned(),
4690 option: required_str(option, "id")?.to_owned(),
4691 }))
4692 }
4693
4694 /// Apply one priority write to one board item.
4695 async fn write_priority(
4696 &self,
4697 board_id: &str,
4698 item_id: &str,
4699 write: &PriorityWrite,
4700 ) -> Result<(), SourceError> {
4701 match write {
4702 PriorityWrite::Select { field, option } => {
4703 self.set_item_field(
4704 board_id,
4705 item_id,
4706 field,
4707 json!({"singleSelectOptionId": option}),
4708 )
4709 .await
4710 }
4711 PriorityWrite::Clear { field } => {
4712 let data = self
4713 .graphql(
4714 graphql::CLEAR_FIELD,
4715 json!({"input":{"projectId":board_id,"itemId":item_id,"fieldId":field}}),
4716 )
4717 .await?;
4718 let returned = data
4719 .pointer("/clearProjectV2ItemFieldValue/projectV2Item")
4720 .ok_or_else(|| SourceError::Malformed {
4721 message: "GitHub field clear returned no project item".into(),
4722 })?;
4723 if required_str(returned, "id")? != item_id {
4724 return Err(SourceError::Malformed {
4725 message: "GitHub field clear returned the wrong project item".into(),
4726 });
4727 }
4728 Ok(())
4729 }
4730 }
4731 }
4732
4733 /// The refusal a priority is answered with by an instance configured with no
4734 /// `priority_mapping`, which holds none.
4735 fn holds_no_priority(&self) -> SourceError {
4736 SourceError::Refused {
4737 message: format!(
4738 "source {} holds no task priority: its configuration sets no priority_mapping; \
4739 next: set priority_mapping on this source, then run `onetaskgraph sources \
4740 fields {} --apply` to set its board up",
4741 self.name, self.name
4742 ),
4743 }
4744 }
4745
4746 /// Set one task's priority and nothing else; see [`TaskSource::set_task_priority`].
4747 ///
4748 /// One field write — a select, or a clear for `none` — and no title, body, label, state
4749 /// or `Status` request. Clearing a priority an item does not hold sends nothing.
4750 async fn set_priority(
4751 &self,
4752 id: &NativeId,
4753 priority: Priority,
4754 ) -> Result<Option<Priority>, SourceError> {
4755 if self.priorities.is_none() {
4756 return Err(self.holds_no_priority());
4757 }
4758 let Some(item) = self
4759 .item_by_id(id)
4760 .await?
4761 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4762 else {
4763 return Ok(None);
4764 };
4765 if priority == Priority::None && !item.holds_priority() {
4766 return Ok(Some(priority));
4767 }
4768 // The item's own read carries the field's definition whenever it holds a value of
4769 // it, which a clear always does; a select onto an item holding none reads the board.
4770 let board = match item.named_board() {
4771 Some(id) if item.defines(PRIORITY_FIELD) => BoardFields {
4772 id,
4773 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4774 },
4775 _ => self.board_fields().await?,
4776 };
4777 let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? else {
4778 return Ok(Some(priority));
4779 };
4780 self.write_priority(board.id.as_str(), &item.item_id, &write)
4781 .await?;
4782 // Read back rather than echoed: the answer is what the board now holds, read by the
4783 // item's own id — strongly consistent, unlike a search — and past what this run
4784 // remembers writing, so a write the board did not keep is reported as it stands.
4785 let read = match self.reach(id).await? {
4786 Reached::Held(item) => Some(*item),
4787 Reached::Draft => self.draft_by_id(id).await?,
4788 Reached::Nothing => None,
4789 }
4790 .ok_or_else(|| SourceError::Malformed {
4791 message: format!("task {id} was written and then could not be read back"),
4792 })?;
4793 let answer = read.task()?.priority;
4794 self.remember_written(read, false)?;
4795 Ok(Some(answer))
4796 }
4797
4798 /// Replace one task's visible body and nothing else; see
4799 /// [`TaskSource::set_task_content`].
4800 ///
4801 /// One update of the body, which differs from the body GitHub holds only outside the
4802 /// metadata slot — the slot is kept byte for byte, so every caller key and every list
4803 /// this source keeps there reads back as it was. A body that would not change is not
4804 /// sent at all.
4805 async fn replace_content(
4806 &self,
4807 id: &NativeId,
4808 content: &str,
4809 ) -> Result<Option<()>, SourceError> {
4810 let Some(mut item) = self
4811 .item_by_id(id)
4812 .await?
4813 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4814 else {
4815 return Ok(None);
4816 };
4817 let held = item.raw_body.clone().unwrap_or_default();
4818 let body = with_content(&held, content)?;
4819 // Checked before anything is sent: content ending in what this source reads as its own
4820 // metadata slot would read back as metadata rather than as the content it was.
4821 let (visible, slot) = metadata_body(Some(body.clone()))?;
4822 if visible.as_deref().unwrap_or_default() != content || slot != item.slot {
4823 return Err(SourceError::Refused {
4824 message: format!(
4825 "this content ends in what source {} reads as its own metadata slot \
4826 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
4827 as content; next: remove that trailing block from the content",
4828 self.name
4829 ),
4830 });
4831 }
4832 if body != held {
4833 self.update_content(item.content_kind, &item.id, json!({"body": body}))
4834 .await?;
4835 }
4836 item.body = visible.filter(|value| !value.is_empty());
4837 item.raw_body = Some(body);
4838 item.slot = slot;
4839 self.remember_written(item, false)?;
4840 Ok(Some(()))
4841 }
4842
4843 /// Apply one targeted update to one task; see [`TaskSource::update_task`].
4844 ///
4845 /// One read of the item, and then only what differs from it: at most one `updateIssue`
4846 /// carrying the title, the body — visible content and metadata slot together — and a
4847 /// state change, at most one `Status` option write and one `Priority` field write, and the
4848 /// `blockedBy` additions and removals the named edges differ by. A terminal status selects
4849 /// its option and then closes, as a whole write does; an open one reopens and then selects
4850 /// its option, as [`Self::set_status`] does. The origin field is never written: an update
4851 /// is of an item that already exists, whose origin is what it is.
4852 ///
4853 /// The task answered is the item as those writes left it, built from the read and what was
4854 /// sent rather than read again — the same record a later read in this run answers from.
4855 async fn targeted_update(
4856 &self,
4857 id: &NativeId,
4858 update: &TaskUpdate,
4859 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
4860 // Everything this source can refuse without reading the item is refused first, in the
4861 // words a whole write of the same fields is refused with.
4862 update.consistent()?;
4863 if update
4864 .title
4865 .as_deref()
4866 .is_some_and(|title| title.starts_with(DESIGN_TITLE_PREFIX))
4867 {
4868 return Err(SourceError::Refused {
4869 message: format!(
4870 "the title of this task begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
4871 spells a document, so it would read back as one rather than as a task; \
4872 retitle it",
4873 self.name
4874 ),
4875 });
4876 }
4877 if let Some(delivers) = &update.delivers {
4878 TaskRef::listed(
4879 TaskRef::DELIVERS_KEY,
4880 id,
4881 Some(&self.name),
4882 delivers.clone(),
4883 )
4884 .map_err(|message| SourceError::Refused { message })?;
4885 }
4886 if self.priorities.is_none()
4887 && update
4888 .priority
4889 .is_some_and(|priority| priority != Priority::None)
4890 {
4891 return Err(self.holds_no_priority());
4892 }
4893 let target = update
4894 .status
4895 .as_ref()
4896 .map(|status| self.resolved_target(status.category))
4897 .transpose()?;
4898 let Some(mut item) = self
4899 .item_by_id(id)
4900 .await?
4901 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
4902 else {
4903 return Ok(None);
4904 };
4905 let before = item.task()?;
4906
4907 let mut status_move = None;
4908 if let (Some(status), Some(target)) = (&update.status, target) {
4909 let board = self.status_board(&item).await?;
4910 let (field, option, name) = self
4911 .column_for(&board.fields, status, &target)?
4912 .ok_or_else(|| SourceError::Malformed {
4913 message: format!(
4914 "status {} of source {} names no board Status option",
4915 category_name(status.category),
4916 self.name
4917 ),
4918 })?;
4919 let terminal = matches!(target, StatusTarget::Terminal(_, _));
4920 if terminal && item.content_kind == ContentKind::DraftIssue {
4921 return Err(self.closes_a_draft(status.category));
4922 }
4923 let landed = match &target {
4924 StatusTarget::Terminal(_, reason) => {
4925 self.statuses
4926 .status(Some(&name), true, Some(reason.reason()))
4927 }
4928 _ => self.statuses.status(Some(&name), false, None),
4929 };
4930 let option_moves = item
4931 .option
4932 .as_deref()
4933 .is_none_or(|held| !held.eq_ignore_ascii_case(&name));
4934 let state_moves = item.content_kind == ContentKind::Issue
4935 && (item.closed != terminal || (terminal && item.status != landed));
4936 if let Some(moves) = Moves::of(option_moves, state_moves) {
4937 status_move = Some(StatusMove {
4938 board: board.id,
4939 field,
4940 option,
4941 name,
4942 target,
4943 landed,
4944 moves,
4945 });
4946 }
4947 }
4948
4949 let mut priority_move = None;
4950 if let Some(priority) = update.priority
4951 && self.priorities.is_some()
4952 && item.priority != HeldPriority::Read(priority)
4953 {
4954 let board = match item.named_board() {
4955 Some(board) if item.defines(PRIORITY_FIELD) => BoardFields {
4956 id: board,
4957 fields: json!({"nodes": item.fields, "pageInfo": {"hasNextPage": false}}),
4958 },
4959 _ => self.board_fields().await?,
4960 };
4961 if let Some(write) = self.priority_write(&board.fields, Some(&item), priority)? {
4962 priority_move = Some((board.id, write, priority));
4963 }
4964 }
4965
4966 // Resolved before the body is composed, because a far end `blockedBy` cannot name is
4967 // recorded in the slot, and the slot travels in the one body update below.
4968 let edges = match &update.depends_on {
4969 Some(edges) => Some(
4970 self.partition_edges(BoardKind::Work(ItemKind::Task), item.content_kind, edges)
4971 .await?,
4972 ),
4973 None => None,
4974 };
4975
4976 let mut slot = item.slot.clone();
4977 for (key, value) in &update.metadata_set {
4978 slot.insert(key.as_str().to_owned(), value.clone());
4979 }
4980 for key in &update.metadata_remove {
4981 slot.remove(key.as_str());
4982 }
4983 if let Some(delivers) = &update.delivers {
4984 set_task_list(&mut slot, TaskRef::DELIVERS_KEY, delivers);
4985 }
4986 if let Some((_, recorded)) = &edges {
4987 record_edges(&mut slot, recorded);
4988 }
4989 let held = item.raw_body.clone().unwrap_or_default();
4990 let content = match &update.content {
4991 Some(content) => with_content(&held, content)?,
4992 None => held.clone(),
4993 };
4994 // A slot holding what it held is kept byte for byte, compared as JSON rather than as
4995 // the body's bytes, as a metadata write compares it: a slot a person spelled with
4996 // other whitespace would otherwise be re-encoded, which is a write nobody asked for.
4997 let body = if slot == item.slot {
4998 content
4999 } else {
5000 with_slot(&content, &slot)?
5001 };
5002 // Checked before anything is sent, as a content write checks it: content ending in
5003 // what this source reads as its own slot would read back as metadata.
5004 let (visible, read) = metadata_body(Some(body.clone()))?;
5005 let wanted = update.content.as_deref().or(item.body.as_deref());
5006 if visible.as_deref().unwrap_or_default() != wanted.unwrap_or_default() || read != slot {
5007 return Err(SourceError::Refused {
5008 message: format!(
5009 "this content ends in what source {} reads as its own metadata slot \
5010 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5011 as content; next: remove that trailing block from the content",
5012 self.name
5013 ),
5014 });
5015 }
5016 let recorded_moves =
5017 slot.get(DependencyEdge::RECORDED_KEY) != item.slot.get(DependencyEdge::RECORDED_KEY);
5018
5019 // One `updateIssue` carries all three, because every mutation spends the secondary
5020 // limiter and the title, body and state are one mutation's inputs.
5021 let mut fields = serde_json::Map::new();
5022 if let Some(title) = update.title.as_ref().filter(|title| **title != item.title) {
5023 fields.insert("title".to_owned(), json!(title));
5024 }
5025 if body != held {
5026 fields.insert("body".to_owned(), json!(body));
5027 }
5028 if let Some(moving) = status_move.as_ref().filter(|moving| moving.moves.state()) {
5029 fields.insert("stateInput".to_owned(), state_input(Some(&moving.target)));
5030 }
5031 let terminal = status_move
5032 .as_ref()
5033 .is_some_and(|moving| matches!(moving.target, StatusTarget::Terminal(_, _)));
5034 // A terminal option is selected before the issue closes, so a close never lands on an
5035 // item whose board cannot show it; an open one after the issue reopens.
5036 if terminal {
5037 self.select_option(&item, status_move.as_ref()).await?;
5038 }
5039 if !fields.is_empty() {
5040 self.update_content(item.content_kind, &item.id, Value::Object(fields))
5041 .await?;
5042 }
5043 if !terminal {
5044 self.select_option(&item, status_move.as_ref()).await?;
5045 }
5046 if let Some((board, write, _)) = &priority_move {
5047 self.write_priority(board.as_str(), &item.item_id, write)
5048 .await?;
5049 }
5050 let mut blocked_by_moved = false;
5051 if let Some((native, _)) = &edges
5052 && item.content_kind == ContentKind::Issue
5053 {
5054 blocked_by_moved = self.reconcile_blocked_by(&item.id, native).await?;
5055 }
5056
5057 if let Some(title) = &update.title {
5058 item.title.clone_from(title);
5059 }
5060 item.body = visible.filter(|value| !value.is_empty());
5061 item.raw_body = (!body.is_empty() || item.raw_body.is_some()).then_some(body);
5062 item.slot = slot;
5063 if let Some(delivers) = &update.delivers {
5064 item.delivers.clone_from(delivers);
5065 }
5066 if let Some(moving) = status_move {
5067 item.closed = matches!(moving.target, StatusTarget::Terminal(_, _))
5068 && item.content_kind == ContentKind::Issue;
5069 item.status = moving.landed;
5070 item.option = Some(moving.name);
5071 }
5072 if let Some((_, _, priority)) = priority_move {
5073 item.priority = HeldPriority::Read(priority);
5074 }
5075 let task = item.task()?;
5076 let mut written = update.changed(&before, &task);
5077 if blocked_by_moved || recorded_moves {
5078 written.insert(UpdatedField::DependsOn);
5079 }
5080 self.remember_written(item, false)?;
5081 Ok(Some(TaskUpdateOutcome {
5082 task,
5083 written,
5084 delivers_before: before.delivers,
5085 }))
5086 }
5087
5088 /// Select the `Status` option one targeted update moves an item to, when it moves it.
5089 async fn select_option(
5090 &self,
5091 item: &Resolved,
5092 moving: Option<&StatusMove>,
5093 ) -> Result<(), SourceError> {
5094 let Some(moving) = moving.filter(|moving| moving.moves.option()) else {
5095 return Ok(());
5096 };
5097 self.set_item_field(
5098 moving.board.as_str(),
5099 &item.item_id,
5100 &moving.field,
5101 json!({"singleSelectOptionId": moving.option}),
5102 )
5103 .await
5104 }
5105
5106 /// Replace one issue's visible body and its [`MetadataKey::TEMPLATE_KEY`] slot entry
5107 /// together, and nothing else; see [`TaskSource::set_task_rendering`].
5108 ///
5109 /// One update of the body: the content outside the slot, and inside it that one entry,
5110 /// every other entry kept as it was. This source keeps no template answers — an issue has
5111 /// no room beside itself that is not its body, and answers written there would duplicate
5112 /// what the content already says and count against GitHub's body limit — so `answers`
5113 /// reaches nothing here. A body that would not change is not sent at all.
5114 async fn replace_rendering(
5115 &self,
5116 id: &NativeId,
5117 kind: BoardKind,
5118 content: &str,
5119 provenance: &Value,
5120 ) -> Result<Option<()>, SourceError> {
5121 let Some(mut item) = self.item_by_id(id).await?.filter(|item| item.kind == kind) else {
5122 return Ok(None);
5123 };
5124 let held = item.raw_body.clone().unwrap_or_default();
5125 let mut slot = item.slot.clone();
5126 slot.insert(MetadataKey::TEMPLATE_KEY.to_owned(), provenance.clone());
5127 let body = with_slot(&with_content(&held, content)?, &slot)?;
5128 // Checked before anything is sent, as a content write checks it.
5129 let (visible, read) = metadata_body(Some(body.clone()))?;
5130 if visible.as_deref().unwrap_or_default() != content || read != slot {
5131 return Err(SourceError::Refused {
5132 message: format!(
5133 "this content ends in what source {} reads as its own metadata slot \
5134 ({METADATA_OPEN:?}), so part of it would read back as metadata rather than \
5135 as content; next: remove that trailing block from the template",
5136 self.name
5137 ),
5138 });
5139 }
5140 if body != held {
5141 self.update_content(item.content_kind, &item.id, json!({"body": body}))
5142 .await?;
5143 }
5144 item.body = visible.filter(|value| !value.is_empty());
5145 item.raw_body = Some(body);
5146 item.slot = read;
5147 self.remember_written(item, false)?;
5148 Ok(Some(()))
5149 }
5150
5151 async fn set_item_field(
5152 &self,
5153 board_id: &str,
5154 item_id: &str,
5155 field_id: &str,
5156 value: Value,
5157 ) -> Result<(), SourceError> {
5158 let data = self
5159 .graphql(
5160 graphql::UPDATE_FIELD,
5161 json!({"input":{
5162 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
5163 }}),
5164 )
5165 .await?;
5166 let returned = data
5167 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
5168 .ok_or_else(|| SourceError::Malformed {
5169 message: "GitHub field update returned no project item".into(),
5170 })?;
5171 if required_str(returned, "id")? != item_id {
5172 return Err(SourceError::Malformed {
5173 message: "GitHub field update returned the wrong project item".into(),
5174 });
5175 }
5176 Ok(())
5177 }
5178
5179 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
5180 let mut after: Option<String> = None;
5181 let mut ids = Vec::new();
5182 loop {
5183 let data = self
5184 .graphql(
5185 graphql::ISSUE_DEPENDENCIES,
5186 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
5187 )
5188 .await?;
5189 let connection =
5190 data.pointer("/node/blockedBy")
5191 .ok_or_else(|| SourceError::Malformed {
5192 message: "GitHub dependency response has no blockedBy connection".into(),
5193 })?;
5194 ids.extend(
5195 connection
5196 .get("nodes")
5197 .and_then(Value::as_array)
5198 .ok_or_else(|| SourceError::Malformed {
5199 message: "GitHub dependency response nodes is not an array".into(),
5200 })?
5201 .iter()
5202 .map(|value| required_str(value, "id").map(str::to_owned))
5203 .collect::<Result<Vec<_>, _>>()?,
5204 );
5205 let next = next_cursor(connection)?;
5206 if let Some(next) = &next {
5207 validate_cursor_progress(after.as_deref(), &next.0)?;
5208 }
5209 after = next.map(|cursor| cursor.0);
5210 if after.is_none() {
5211 return Ok(ids);
5212 }
5213 }
5214 }
5215
5216 async fn dependencies(
5217 &self,
5218 id: &NativeId,
5219 near_kind: ItemKind,
5220 direction: Direction,
5221 page: &PageRequest,
5222 ) -> Result<Page<DependencyEdge>, SourceError> {
5223 validate_page(page)?;
5224 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
5225 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
5226 let recorded = recorded_offset(cursor, direction)?;
5227 // Asked for even in the recorded phase, whose page reads nothing from the
5228 // connection: `__typename` is what says whether this item has a native
5229 // relationship at all, and that is what decides which far ends the reserved key is
5230 // allowed to hold.
5231 let data = self
5232 .graphql(
5233 graphql::ISSUE_DEPENDENCIES,
5234 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
5235 "after":if recorded.is_some() {None} else {cursor}}),
5236 )
5237 .await?;
5238 let node =
5239 data.get("node")
5240 .filter(|v| !v.is_null())
5241 .ok_or_else(|| SourceError::Refused {
5242 message: format!(
5243 "GitHub item {} was not found or does not support dependencies",
5244 id.0
5245 ),
5246 })?;
5247 let connection_name = match direction {
5248 Direction::DependsOn => "blockedBy",
5249 Direction::DependedOnBy => "blocking",
5250 };
5251 // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
5252 // named natively and the reserved key may hold any far end. An issue's connections
5253 // hold issues, and this source reads them at the near item's own level.
5254 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
5255 if let Some(offset) = recorded {
5256 return Ok(recorded_page(
5257 self.recorded_edges(id, near_kind, direction, natively_names, node)
5258 .await?,
5259 offset,
5260 limit,
5261 ));
5262 }
5263 if natively_names.is_none() {
5264 return Ok(recorded_page(
5265 self.recorded_edges(id, near_kind, direction, natively_names, node)
5266 .await?,
5267 0,
5268 limit,
5269 ));
5270 }
5271 let connection = node
5272 .get(connection_name)
5273 .ok_or_else(|| SourceError::Malformed {
5274 message: "GitHub dependency response is missing its connection".into(),
5275 })?;
5276 let nodes = connection
5277 .get("nodes")
5278 .and_then(Value::as_array)
5279 .ok_or_else(|| SourceError::Malformed {
5280 message: "GitHub dependency response nodes is not an array".into(),
5281 })?;
5282 // `from` depends on `to`, always. GitHub spells the same relationship from either
5283 // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
5284 // it — so the near item is `from` in one direction and `to` in the other.
5285 let items = nodes
5286 .iter()
5287 .map(|value| {
5288 let related = NativeId(required_str(value, "id")?.into());
5289 let related_kind = related_kind(value)?;
5290 let (from, to) = match direction {
5291 Direction::DependsOn => (
5292 DependencyEndpoint::from_native(id.clone(), near_kind),
5293 DependencyEndpoint::from_native(related, related_kind),
5294 ),
5295 Direction::DependedOnBy => (
5296 DependencyEndpoint::from_native(related, related_kind),
5297 DependencyEndpoint::from_native(id.clone(), near_kind),
5298 ),
5299 };
5300 Ok(DependencyEdge {
5301 from,
5302 to,
5303 kind: DependencyKind::Blocks,
5304 })
5305 })
5306 .collect::<Result<Vec<_>, SourceError>>()?;
5307 let mut next = next_cursor(connection)?;
5308 if let Some(next) = &next {
5309 validate_cursor_progress(cursor, &next.0)?;
5310 }
5311 if next.is_none()
5312 && !self
5313 .recorded_edges(id, near_kind, direction, natively_names, node)
5314 .await?
5315 .is_empty()
5316 {
5317 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
5318 }
5319 Ok(Page { items, next })
5320 }
5321
5322 /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
5323 /// a far end in another source has to live: no GitHub issue relationship can name one.
5324 ///
5325 /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
5326 /// source never writes one down.
5327 ///
5328 /// The metadata lives in the item's own body slot, and `node` is the dependency read's
5329 /// own answer, which carries an issue's body — so an issue's recorded edges cost no
5330 /// request beyond the read already made, and reading the board for them would be a
5331 /// walk of every item for one field of one. A draft has no body in that answer, because
5332 /// a draft is not an issue, so a draft's are read off its own read by id — never off a
5333 /// listing of the board, which can be behind on the very item asked about.
5334 async fn recorded_edges(
5335 &self,
5336 id: &NativeId,
5337 near_kind: ItemKind,
5338 direction: Direction,
5339 natively_names: Option<ItemKind>,
5340 node: &Value,
5341 ) -> Result<Vec<DependencyEdge>, SourceError> {
5342 if direction != Direction::DependsOn {
5343 return Ok(Vec::new());
5344 }
5345 let slot = match node.get("body") {
5346 Some(body) if natively_names.is_some() => {
5347 metadata_body(body.as_str().map(str::to_owned))?.1
5348 }
5349 _ => {
5350 let Some(item) = self.item_by_id(id).await? else {
5351 return Ok(Vec::new());
5352 };
5353 item.slot
5354 }
5355 };
5356 DependencyEdge::recorded(&slot, id, near_kind, &self.name, natively_names)
5357 .map_err(|message| SourceError::Malformed { message })
5358 }
5359
5360 fn configured_repository(&self) -> Result<&RepositoryTarget, SourceError> {
5361 self.repository
5362 .as_ref()
5363 .ok_or_else(|| SourceError::Refused {
5364 message: format!(
5365 "source {} has no repository configured, and a GitHub Projects board has no \
5366 repository of its own to create an issue in; set repository: owner/name on \
5367 this source",
5368 self.name
5369 ),
5370 })
5371 }
5372
5373 /// The repository one new issue is created in, under the rule [`RepositoryTarget`]
5374 /// states.
5375 ///
5376 /// The fallback is demanded first, whichever arm answers: a write without a configured
5377 /// repository is refused naming the field exactly as it was before the rule existed,
5378 /// so a source that could not write before cannot write now, rather than writing for
5379 /// the one item whose own field happens to decide it.
5380 ///
5381 /// Everything this refuses is refused before `createIssue`, so a refusal leaves no
5382 /// issue behind: an entry that is not a repository on [`RepositoryTarget::HOST`], an
5383 /// entry owned by someone other than the owner of the parent issue's repository —
5384 /// GitHub accepts a sub-issue from another repository of the same owner and from no
5385 /// other, so `addSubIssue` would refuse it after the issue existed — a parent the
5386 /// board does not hold, and a parent that is a draft, which GitHub gives no sub-issues,
5387 /// both of which `addSubIssue` would likewise refuse too late. Whether the entry exists
5388 /// and is visible to the token is checked where its node id is resolved, still before
5389 /// `createIssue`. The parent is read by its own id through [`Self::item_by_id`] — never
5390 /// looked up in a listing of the board, which can be minutes behind an issue its own
5391 /// `projectItems` already places on it — and that read answers first from this process's
5392 /// own record, so a project created moments ago in this command answers though GitHub
5393 /// has not caught up.
5394 async fn creation_target(
5395 &self,
5396 incoming: &Incoming<'_>,
5397 ) -> Result<RepositoryTarget, SourceError> {
5398 let fallback = self.configured_repository()?;
5399 let what = |incoming: &Incoming<'_>| {
5400 format!(
5401 "{} {:?}",
5402 incoming.written.kind().describes(),
5403 incoming.title
5404 )
5405 };
5406 let parent = match incoming.parent {
5407 Some(parent) => Some(self.item_by_id(parent).await?.ok_or_else(|| {
5408 SourceError::Refused {
5409 message: format!(
5410 "GitHub project issue {} was not found on the board of source {}, so {} \
5411 cannot be filed under it",
5412 parent.0,
5413 self.name,
5414 what(incoming)
5415 ),
5416 }
5417 })?),
5418 None => None,
5419 };
5420 let parents_repository = parent
5421 .as_ref()
5422 .map(|parent| {
5423 // A draft is on the board and so is found, but it has no repository to
5424 // place a task in and GitHub gives it no sub-issues, so `addSubIssue`
5425 // would refuse the task only once `createIssue` had made it.
5426 if parent.content_kind == ContentKind::DraftIssue {
5427 return Err(SourceError::Refused {
5428 message: format!(
5429 "GitHub project item {} on the board of source {} is a draft, \
5430 which cannot have sub-issues, so {} cannot be filed under it",
5431 parent.id.0,
5432 self.name,
5433 what(incoming)
5434 ),
5435 });
5436 }
5437 // An issue's repository is where a sub-issue is placed and whose owner it
5438 // is compared against, so a parent whose repository this source cannot
5439 // spell as `owner/name` — GitHub's login grammar is wider than this
5440 // source's floor — is one nothing can be filed under.
5441 parent
5442 .own_repository
5443 .as_ref()
5444 .and_then(|origin| RepositoryTarget::from_origin(origin).ok())
5445 .ok_or_else(|| SourceError::Malformed {
5446 message: format!(
5447 "GitHub project issue {} on the board of source {} is in {}, which \
5448 is not a {}/owner/name repository this source can place {} in",
5449 parent.id.0,
5450 self.name,
5451 parent
5452 .own_repository
5453 .as_ref()
5454 .map_or("no repository", Repository::as_str),
5455 RepositoryTarget::HOST,
5456 what(incoming)
5457 ),
5458 })
5459 })
5460 .transpose()?;
5461 match incoming.repositories {
5462 [named] => {
5463 let target =
5464 RepositoryTarget::from_origin(named).map_err(|_| SourceError::Refused {
5465 message: format!(
5466 "{} names repository {}, which is not a {}/owner/name repository \
5467 source {} can create an issue in; name one that is, or name none",
5468 what(incoming),
5469 named.as_str(),
5470 RepositoryTarget::HOST,
5471 self.name
5472 ),
5473 })?;
5474 if let Some(parents) = &parents_repository
5475 && parents.owner != target.owner
5476 {
5477 return Err(SourceError::Refused {
5478 message: format!(
5479 "{} names repository {}, owned by {}, but its project's issue is in \
5480 {}, owned by {}, and GitHub files a sub-issue only in a repository \
5481 of the same owner as its parent issue; name a repository of {}, or \
5482 name none",
5483 what(incoming),
5484 target.slug(),
5485 target.owner,
5486 parents.slug(),
5487 parents.owner,
5488 parents.owner
5489 ),
5490 });
5491 }
5492 Ok(target)
5493 }
5494 _ => Ok(parents_repository.unwrap_or_else(|| fallback.clone())),
5495 }
5496 }
5497
5498 /// The node id of the repository `incoming` is being created in, or the refusal naming
5499 /// the item and the repository the token cannot see.
5500 ///
5501 /// Resolved once per command per repository; see [`Self::repository_cache`].
5502 async fn repository_id(
5503 &self,
5504 repository: &RepositoryTarget,
5505 incoming: &Incoming<'_>,
5506 ) -> Result<String, SourceError> {
5507 if let Some(id) = self.repository_cache()?.get(repository).cloned() {
5508 return Ok(id);
5509 }
5510 let data = self
5511 .graphql(
5512 graphql::REPOSITORY,
5513 json!({"owner":repository.owner,"name":repository.name}),
5514 )
5515 .await?;
5516 let node = data
5517 .get("repository")
5518 .filter(|value| !value.is_null())
5519 .ok_or_else(|| SourceError::Refused {
5520 message: format!(
5521 "GitHub repository {} was not found or is not visible to the token, so {} \
5522 {:?} cannot be created in it",
5523 repository.slug(),
5524 incoming.written.kind().describes(),
5525 incoming.title
5526 ),
5527 })?;
5528 let id = required_str(node, "id")?.to_owned();
5529 self.repository_cache()?
5530 .insert(repository.clone(), id.clone());
5531 Ok(id)
5532 }
5533
5534 fn repository_cache(
5535 &self,
5536 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<RepositoryTarget, String>>, SourceError> {
5537 self.repository_cache
5538 .lock()
5539 .map_err(|_| SourceError::Unavailable {
5540 message: "this source's record of the destination repository was left \
5541 inconsistent by an earlier failure; next: run the command again"
5542 .into(),
5543 })
5544 }
5545
5546 /// Create or update one board item, whichever kind it is.
5547 async fn write_item(
5548 &self,
5549 incoming: &Incoming<'_>,
5550 target: Option<&NativeId>,
5551 depends_on: &[DependencyEdge],
5552 ) -> Result<NativeId, SourceError> {
5553 // Refused before anything is read or written: a task or a project titled the way
5554 // this board spells a document would land as an issue this same source reads back
5555 // as a document, so the field this destination cannot carry is named rather than
5556 // written and silently reclassified.
5557 if let Written::Work(kind, _) = incoming.written
5558 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
5559 {
5560 return Err(SourceError::Refused {
5561 message: format!(
5562 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
5563 spells a document, so it would read back as one rather than as a {}; \
5564 retitle it, or copy it as a document",
5565 kind.marker(),
5566 self.name,
5567 kind.marker()
5568 ),
5569 });
5570 }
5571 // The destination is read by its own id, and whether this board holds it is decided
5572 // by that read — its own `projectItems` — rather than by whether a listing of the
5573 // board happens to include it yet. See the module documentation.
5574 let existing = match target {
5575 Some(target) => {
5576 Some(
5577 self.item_by_id(target)
5578 .await?
5579 .ok_or_else(|| SourceError::Refused {
5580 message: format!("GitHub destination item {} was not found", target.0),
5581 })?,
5582 )
5583 }
5584 None => None,
5585 };
5586 let existing = existing.as_ref();
5587 let board = self
5588 .fields_for(
5589 existing,
5590 incoming.written.status().is_some(),
5591 incoming
5592 .priority
5593 .is_some_and(|priority| priority != Priority::None),
5594 )
5595 .await?;
5596 let status_target = incoming
5597 .written
5598 .status()
5599 .map(|status| self.resolved_target(status.category))
5600 .transpose()?;
5601 let column = match (incoming.written.status(), status_target.as_ref()) {
5602 (Some(status), Some(target)) => self.column_for(&board.fields, status, target)?,
5603 _ => None,
5604 };
5605 // Resolved before anything is created, for the reason the column above is: a
5606 // priority this board has no option for is refused while nothing has been written.
5607 let priority_write = match incoming.priority {
5608 Some(priority) => self.priority_write(&board.fields, existing, priority)?,
5609 None => None,
5610 };
5611 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
5612 if content_kind == ContentKind::DraftIssue {
5613 if let (Some(StatusTarget::Terminal(_, _)), Some(status)) =
5614 (status_target.as_ref(), incoming.written.status())
5615 {
5616 return Err(self.closes_a_draft(status.category));
5617 }
5618 if incoming.parent.is_some() {
5619 return Err(SourceError::Refused {
5620 message: "GitHub draft items cannot be a project's sub-issue".into(),
5621 });
5622 }
5623 }
5624 match existing {
5625 Some(item) if content_kind == ContentKind::Issue => {
5626 if item.labels != incoming.labels {
5627 return Err(SourceError::Refused {
5628 message: "GitHub issue labels differ from the labels being written".into(),
5629 });
5630 }
5631 }
5632 _ => {
5633 if !incoming.labels.is_empty() {
5634 return Err(SourceError::Refused {
5635 message: "GitHub items created by this destination carry no labels".into(),
5636 });
5637 }
5638 }
5639 }
5640
5641 // An existing issue is never moved; a new one is created where the rule says. The
5642 // repository the issue really lives in is what the slot below is written against,
5643 // so a single entry that is where the issue is created travels as no key at all,
5644 // and the read side derives it back from the issue.
5645 let (own_repository, creation_target) = match existing {
5646 Some(item) => (item.own_repository.clone(), None),
5647 None => {
5648 let target = self.creation_target(incoming).await?;
5649 let origin = Repository::try_from(target.origin())
5650 .map_err(|message| SourceError::Config { message })?;
5651 (Some(origin), Some(target))
5652 }
5653 };
5654 let (native, fallback) = self
5655 .partition_edges(incoming.written.kind(), content_kind, depends_on)
5656 .await?;
5657 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
5658 let body = compose_body(incoming.content, &slot)?;
5659 // Read before anything is created, for the reason the field below is: a value
5660 // this destination cannot store has to refuse, and refusing after `createIssue`
5661 // would leave an issue behind that nothing asked for. The engine writes a
5662 // qualified id here; a caller handing this key anything else is told so rather
5663 // than having it silently stored as no origin at all.
5664 // 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.
5665 let origin = match incoming.metadata.get(ORIGIN_KEY) {
5666 None => "",
5667 Some(Value::String(origin)) => origin.as_str(),
5668 Some(other) => {
5669 return Err(SourceError::Refused {
5670 message: format!(
5671 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
5672 is {other}"
5673 ),
5674 });
5675 }
5676 };
5677 // Resolved before anything is created: a board that cannot carry the copy origin
5678 // has to refuse the write, and refusing it after `createIssue` would leave an
5679 // issue behind that nothing asked for.
5680 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
5681 Some(field) => {
5682 if required_str(field, "__typename")? != "ProjectV2Field" {
5683 return Err(SourceError::Refused {
5684 message: format!(
5685 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
5686 ),
5687 });
5688 }
5689 Some(required_str(field, "id")?.to_owned())
5690 }
5691 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
5692 return Err(SourceError::Refused {
5693 message: format!(
5694 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
5695 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
5696 the board"
5697 ),
5698 });
5699 }
5700 None => None,
5701 };
5702
5703 let Landed {
5704 content_id,
5705 item_id,
5706 url,
5707 number,
5708 } = match existing {
5709 Some(item) => {
5710 self.update_existing(item, incoming, &body, status_target.as_ref())
5711 .await?;
5712 Landed {
5713 content_id: item.id.clone(),
5714 item_id: item.item_id.clone(),
5715 url: item.url.clone(),
5716 number: item.number,
5717 }
5718 }
5719 None => {
5720 let target = creation_target
5721 .as_ref()
5722 .ok_or_else(|| SourceError::Malformed {
5723 message: "a new item was decided without a repository to create it in"
5724 .into(),
5725 })?;
5726 self.create_and_file_issue(board.id.as_str(), target, incoming, &body)
5727 .await?
5728 }
5729 };
5730
5731 let written_option = column.as_ref().map(|(_, _, name)| name.clone());
5732 let column = column.map(|(field, option, _)| (field, option));
5733 // Creating an item here is several calls — `createIssue`, `addProjectV2ItemById`,
5734 // then each board field, the parent and the dependencies — and GitHub can fail at
5735 // any of them. Everything this source can refuse *before* the first of those is
5736 // already checked above, so what is left is GitHub itself failing part way. When it
5737 // does over an item this call created, the issue is taken back: a write that
5738 // refused must not leave an item behind that nobody asked for, and one that does
5739 // makes the retry create a second.
5740 let landed = self
5741 .finish_write(
5742 board.id.as_str(),
5743 incoming,
5744 &content_id,
5745 &item_id,
5746 content_kind,
5747 existing,
5748 origin_field.as_deref(),
5749 origin,
5750 column,
5751 status_target.as_ref(),
5752 priority_write.as_ref(),
5753 &native,
5754 )
5755 .await;
5756 if let Err(error) = landed {
5757 if existing.is_none() {
5758 // Best effort, and the write's own failure is what the caller is told: a
5759 // refusal naming the tidy-up would hide why the write failed at all.
5760 let _ = self.delete_issue(&content_id).await;
5761 }
5762 return Err(error);
5763 }
5764
5765 let written_status = match (incoming.written.status(), status_target.as_ref()) {
5766 (Some(_), Some(StatusTarget::Terminal(_, reason))) => {
5767 self.statuses
5768 .status(written_option.as_deref(), true, Some(reason.reason()))
5769 }
5770 (Some(_), Some(StatusTarget::Column(_))) => {
5771 self.statuses.status(written_option.as_deref(), false, None)
5772 }
5773 (Some(status), _) => status.clone(),
5774 (None, _) => Status {
5775 category: StatusCategory::Unknown,
5776 name: "Open".to_owned(),
5777 },
5778 };
5779
5780 // So the rest of this command reads what it just did rather than what the board
5781 // said before it. See `remember_written` for which half takes it.
5782 let remembered = Resolved {
5783 item_id,
5784 id: content_id.clone(),
5785 content_kind,
5786 kind: incoming.written.kind(),
5787 title: incoming.title.to_owned(),
5788 // The visible half of the body this write composed, split back off it the
5789 // way a read splits it — so what this record reports is what a read of the
5790 // same issue reports, rather than the person's text with the metadata slot
5791 // still on the end of it.
5792 body: metadata_body(body.clone())?.0,
5793 raw_body: body.clone(),
5794 // A document has no status of its own; what it reads back as is whatever
5795 // the issue's own state says, which is what a re-read reports.
5796 status: written_status,
5797 option: written_option.or_else(|| existing.and_then(|item| item.option.clone())),
5798 priority: match incoming.priority {
5799 Some(priority) => HeldPriority::Read(priority),
5800 None => existing.map_or(HeldPriority::Read(Priority::None), |item| {
5801 item.priority.clone()
5802 }),
5803 },
5804 // What `state_input` asked for: closed for a terminal target, open for any other
5805 // status, and the issue's own state left as it was by a document write.
5806 closed: content_kind == ContentKind::Issue
5807 && match status_target.as_ref() {
5808 Some(StatusTarget::Terminal(_, _)) => true,
5809 Some(_) => false,
5810 None => existing.is_some_and(|item| item.closed),
5811 },
5812 delivers: incoming.delivers.to_vec(),
5813 delivered_by: incoming.delivered_by.to_vec(),
5814 labels: incoming.labels.to_vec(),
5815 parent: incoming.parent.cloned(),
5816 origin: (!origin.is_empty()).then(|| origin.to_owned()),
5817 number,
5818 // In the update path this is the item's own url, read off `existing` where the
5819 // record above was bound, so one expression serves both halves.
5820 url,
5821 created_at: existing.and_then(|item| item.created_at),
5822 updated_at: existing.and_then(|item| item.updated_at),
5823 own_repository,
5824 repositories: incoming.repositories.to_vec(),
5825 slot,
5826 board_id: Some(board.id.as_str().to_owned()),
5827 fields: board
5828 .fields
5829 .get("nodes")
5830 .and_then(Value::as_array)
5831 .cloned()
5832 .unwrap_or_default(),
5833 };
5834 self.remember_written(remembered, existing.is_none())?;
5835 Ok(content_id)
5836 }
5837
5838 /// Everything a write does after the item exists: its board fields, its parent, and
5839 /// its dependencies.
5840 ///
5841 /// Split out of `write_item` so there is one place a failure past the point of no
5842 /// return is caught, rather than a tidy-up repeated at each `?` above.
5843 // llmlint: ignore[suppressions_justified] This is the tail of `write_item` lifted out
5844 // so there is one place a failure past the point of no return is caught, and its
5845 // arguments are exactly the values that tail already had in scope. Bundling them into a
5846 // struct would describe no concept — it would be "the arguments of this function" — and
5847 // would put the whole of `write_item`'s locals behind one more indirection.
5848 #[allow(clippy::too_many_arguments)]
5849 async fn finish_write(
5850 &self,
5851 board_id: &str,
5852 incoming: &Incoming<'_>,
5853 content_id: &NativeId,
5854 item_id: &str,
5855 content_kind: ContentKind,
5856 existing: Option<&Resolved>,
5857 origin_field: Option<&str>,
5858 origin: &str,
5859 column: Option<(String, String)>,
5860 status_target: Option<&StatusTarget>,
5861 priority: Option<&PriorityWrite>,
5862 native: &[String],
5863 ) -> Result<(), SourceError> {
5864 if let Some(field_id) = origin_field {
5865 self.set_item_field(board_id, item_id, field_id, json!({"text":origin}))
5866 .await?;
5867 }
5868
5869 if let Some((field_id, option_id)) = column {
5870 self.set_item_field(
5871 board_id,
5872 item_id,
5873 &field_id,
5874 json!({"singleSelectOptionId":option_id}),
5875 )
5876 .await?;
5877 }
5878
5879 if let Some(priority) = priority {
5880 self.write_priority(board_id, item_id, priority).await?;
5881 }
5882
5883 if content_kind == ContentKind::Issue
5884 && matches!(status_target, Some(StatusTarget::Terminal(_, _)))
5885 {
5886 self.update_content(
5887 ContentKind::Issue,
5888 content_id,
5889 json!({"stateInput":state_input(status_target)}),
5890 )
5891 .await?;
5892 }
5893
5894 if content_kind == ContentKind::Issue {
5895 self.reparent(
5896 existing.and_then(|item| item.parent.clone()),
5897 content_id,
5898 incoming.parent,
5899 )
5900 .await?;
5901 // A document takes part in no dependency graph, so writing one neither reads
5902 // nor changes the issue's own `blockedBy` relationships. Reconciling them
5903 // against the empty list a document write carries would *delete* whatever
5904 // relationships a person had made on that issue, which is a write nobody
5905 // asked for.
5906 if incoming.written.kind() != BoardKind::Document {
5907 self.reconcile_blocked_by(content_id, native).await?;
5908 }
5909 }
5910 Ok(())
5911 }
5912
5913 /// Delete one issue, which takes its board item with it.
5914 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
5915 let data = self
5916 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
5917 .await?;
5918 data.pointer("/deleteIssue/repository")
5919 .filter(|value| !value.is_null())
5920 .ok_or_else(|| SourceError::Malformed {
5921 message: "GitHub issue deletion returned no repository".into(),
5922 })?;
5923 self.forget(id)?;
5924 Ok(())
5925 }
5926
5927 /// Remove one item this copy created, so a copy that could not finish leaves the board
5928 /// as it found it.
5929 ///
5930 /// Deleting the issue takes its board item with it, so there is no second mutation to
5931 /// keep in step. An id the board does not hold is not an error: the item is already
5932 /// gone, which is the state this asks for. Which that is, is decided by reading the item
5933 /// by its own id — a listing of the board can still be missing an item it holds, and
5934 /// reading that as *already gone* would leave behind the very item this was asked to
5935 /// take back.
5936 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
5937 let Some(item) = self.item_by_id(id).await? else {
5938 return Ok(());
5939 };
5940 if item.content_kind == ContentKind::DraftIssue {
5941 return Err(SourceError::Refused {
5942 message: format!(
5943 "GitHub item {} is a draft, and this source removes an item by deleting \
5944 its issue; next: remove it from the board by hand",
5945 id.0
5946 ),
5947 });
5948 }
5949 let data = self
5950 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
5951 .await?;
5952 data.pointer("/deleteIssue/repository")
5953 .filter(|value| !value.is_null())
5954 .ok_or_else(|| SourceError::Malformed {
5955 message: "GitHub issue deletion returned no repository".into(),
5956 })?;
5957 self.forget(id)?;
5958 Ok(())
5959 }
5960
5961 /// The issue a comment call on `task` is about, or `None` when this board holds no such
5962 /// task.
5963 ///
5964 /// Resolved exactly as [`TaskSource::get_task`] resolves it, so the comment verbs and a
5965 /// read of the task cannot disagree about which ids name one: a project or a document of
5966 /// this board is not a task here either.
5967 ///
5968 /// A **draft** is a task with nowhere to keep a comment, because GitHub keeps comments on
5969 /// issues and a draft is not one. It is refused rather than answered with an empty page,
5970 /// which would read as a task nobody has commented on yet.
5971 async fn commented_issue(&self, task: &NativeId) -> Result<Option<NativeId>, SourceError> {
5972 let Some(item) = self
5973 .item_by_id(task)
5974 .await?
5975 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
5976 else {
5977 return Ok(None);
5978 };
5979 if item.content_kind == ContentKind::DraftIssue {
5980 return Err(SourceError::Refused {
5981 message: format!(
5982 "task {} of source {} is a draft item on the board, and GitHub keeps \
5983 comments on issues alone, so a draft has none to read or write; next: \
5984 convert the draft to an issue on the board, then comment on the issue it \
5985 becomes",
5986 task.0, self.name
5987 ),
5988 });
5989 }
5990 Ok(Some(item.id))
5991 }
5992
5993 /// Whether the comment `comment` is one of `issue`'s own.
5994 ///
5995 /// Read before an edit or a removal is sent, because GitHub's comment mutations take the
5996 /// comment's id and nothing else: a comment id given against the wrong task would
5997 /// otherwise change a comment on some other issue entirely. An id that names nothing, or
5998 /// names something that is not an issue comment, is a comment this task does not have —
5999 /// which is what GitHub refusing to resolve it means too.
6000 async fn comment_is_on(
6001 &self,
6002 issue: &NativeId,
6003 comment: &NativeId,
6004 ) -> Result<bool, SourceError> {
6005 let asked = self
6006 .graphql(graphql::COMMENT_ISSUE, json!({"id":comment.0}))
6007 .await;
6008 let data = match asked {
6009 Ok(data) => data,
6010 Err(error) if unresolvable_node(&error) => return Ok(false),
6011 Err(error) => return Err(error),
6012 };
6013 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
6014 return Ok(false);
6015 };
6016 if optional_str(node, "__typename")? != Some("IssueComment") {
6017 return Ok(false);
6018 }
6019 let on = node.get("issue").ok_or_else(|| SourceError::Malformed {
6020 message: format!("GitHub issue comment {} names no issue", comment.0),
6021 })?;
6022 Ok(required_str(on, "id")? == issue.0)
6023 }
6024
6025 /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
6026 async fn partition_edges(
6027 &self,
6028 near_kind: BoardKind,
6029 near_content: ContentKind,
6030 depends_on: &[DependencyEdge],
6031 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
6032 let mut native = Vec::new();
6033 let mut fallback = Vec::new();
6034 for edge in depends_on {
6035 let same_source = edge
6036 .to
6037 .source()
6038 .is_none_or(|source| source == self.name.as_str());
6039 // A qualified id's source segment runs to its *first* colon — `GlobalId` and
6040 // `DependencyEndpoint::source` both read it that way — and a native id may hold
6041 // colons of its own, so the far end is everything after that one separator.
6042 // Splitting at the last would truncate `work:urn:task:7` to `7`.
6043 let far_id = if edge.to.is_qualified() {
6044 edge.to
6045 .id()
6046 .split_once(':')
6047 .map_or(edge.to.id(), |(_, native)| native)
6048 } else {
6049 edge.to.id()
6050 };
6051 // A same-source far end is read by its own id, exactly as the item it is a far end
6052 // of is: whether this board holds it is that read's answer, never a listing's.
6053 let far = if same_source {
6054 Some(
6055 self.item_by_id(&NativeId(far_id.to_owned()))
6056 .await?
6057 .ok_or_else(|| SourceError::Refused {
6058 message: format!("GitHub dependency item {far_id} was not found"),
6059 })?,
6060 )
6061 } else {
6062 None
6063 };
6064 let far = far.as_ref();
6065 // The caller says which kind the far end is, and this board holds the far end
6066 // itself, so a disagreement is settled here rather than stored: recorded, the
6067 // wrong kind would read back as a cross-level edge that never existed; written
6068 // natively, it would name a relationship of a different level than the caller
6069 // asked for.
6070 //
6071 // A far end this board holds as a *document* fails the same comparison and is
6072 // refused by the same sentence: `ItemKind` has no document variant because
6073 // nothing may point at one, so no caller can name it correctly and the refusal
6074 // is the only honest answer.
6075 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
6076 return Err(SourceError::Refused {
6077 message: format!(
6078 "GitHub dependency item {far_id} is a {} of this board, and this item \
6079 names it as a {}; record the kind it is",
6080 disagreeing.kind.describes(),
6081 edge.to.kind.marker()
6082 ),
6083 });
6084 }
6085 // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
6086 // however the far end is spelled — and one classified native here would be
6087 // written nowhere at all, because a draft's native reconciliation never runs.
6088 let native_here = near_content == ContentKind::Issue
6089 && far.is_some_and(|far| {
6090 far.content_kind == ContentKind::Issue
6091 && BoardKind::Work(edge.to.kind) == near_kind
6092 });
6093 if native_here {
6094 native.push(far_id.to_owned());
6095 } else {
6096 fallback.push(edge.clone());
6097 }
6098 }
6099 Ok((native, fallback))
6100 }
6101
6102 async fn update_existing(
6103 &self,
6104 item: &Resolved,
6105 incoming: &Incoming<'_>,
6106 body: &Option<String>,
6107 status_target: Option<&StatusTarget>,
6108 ) -> Result<(), SourceError> {
6109 let title = incoming.written_title();
6110 let mut fields = match item.content_kind {
6111 ContentKind::DraftIssue => json!({"title":title,"body":body}),
6112 ContentKind::Issue => json!({"title":title,"body":body,
6113 "stateInput":state_input(status_target)}),
6114 };
6115 if matches!(status_target, Some(StatusTarget::Terminal(_, _))) {
6116 fields
6117 .as_object_mut()
6118 .expect("update fields are an object")
6119 .remove("stateInput");
6120 }
6121 self.update_content(item.content_kind, &item.id, fields)
6122 .await
6123 }
6124
6125 /// Update one board item's content with exactly `fields` beside its id, through the
6126 /// mutation its kind takes: `updateIssue` for an issue, `updateProjectV2DraftIssue` for
6127 /// a draft.
6128 ///
6129 /// Every input field either mutation leaves out is a field GitHub leaves as it is, which
6130 /// is what lets a narrow write carry the one thing it changes and nothing else.
6131 async fn update_content(
6132 &self,
6133 kind: ContentKind,
6134 id: &NativeId,
6135 fields: Value,
6136 ) -> Result<(), SourceError> {
6137 let (operation, id_key, pointer) = match kind {
6138 ContentKind::DraftIssue => (
6139 graphql::UPDATE_DRAFT,
6140 "draftIssueId",
6141 "/updateProjectV2DraftIssue/draftIssue",
6142 ),
6143 ContentKind::Issue => (graphql::UPDATE_ISSUE, "id", "/updateIssue/issue"),
6144 };
6145 let mut input = fields;
6146 input[id_key] = json!(id.0);
6147 let data = self.graphql(operation, json!({"input":input})).await?;
6148 let returned = data
6149 .pointer(pointer)
6150 .ok_or_else(|| SourceError::Malformed {
6151 message: "GitHub item update returned no item".into(),
6152 })?;
6153 if required_str(returned, "id")? != id.0 {
6154 return Err(SourceError::Malformed {
6155 message: "GitHub item update returned the wrong item".into(),
6156 });
6157 }
6158 Ok(())
6159 }
6160
6161 /// Creates one issue, files it on the board, and reports what a read of it would say:
6162 /// its content id, its board item id, and the web address GitHub gave it.
6163 ///
6164 /// Two calls rather than one: `createIssue` needs a repository and answers with an
6165 /// issue that is on no board, and `addProjectV2ItemById` is what puts it there. A
6166 /// terminal status is not written here: `finish_write` selects its option first and
6167 /// closes the issue after, so a close never lands on an item whose board cannot show it.
6168 ///
6169 /// The address and the number come back here because this is the only place either is
6170 /// known before GitHub's own board read catches up — an item this run created answers
6171 /// the reads that follow it out of the record below, and one remembered without them
6172 /// would report no location and no key for the rest of the run.
6173 async fn create_and_file_issue(
6174 &self,
6175 board_id: &str,
6176 repository: &RepositoryTarget,
6177 incoming: &Incoming<'_>,
6178 body: &Option<String>,
6179 ) -> Result<Landed, SourceError> {
6180 let repository_id = self.repository_id(repository, incoming).await?;
6181 let data = self
6182 .graphql(
6183 graphql::CREATE_ISSUE,
6184 json!({"input":{
6185 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
6186 }}),
6187 )
6188 .await?;
6189 let created = data
6190 .pointer("/createIssue/issue")
6191 .filter(|value| !value.is_null())
6192 .ok_or_else(|| SourceError::Malformed {
6193 message: "GitHub issue creation returned no issue".into(),
6194 })?;
6195 let content_id = NativeId(required_str(created, "id")?.to_owned());
6196 // Optional although GitHub's schema makes it non-null: the issue exists by now, so
6197 // a response without it is not worth failing a landed write over — the item simply
6198 // reports no location until the board read catches up, which is what it did before.
6199 let url = optional_str(created, "url")?.map(str::to_owned);
6200 // The issue exists from here on, so an unreadable number and a refused board
6201 // filing below each try, best effort, to take it back: an issue in the repository
6202 // that is on no board is an item nobody asked for and nothing here would find again.
6203 //
6204 // Its number is optional on the same terms its address is — a landed write is not
6205 // worth failing over a member that came back missing, and such an item reports no
6206 // handle until a board read catches up. A number that is *present* and is not an
6207 // unsigned integer is still a response this source cannot read.
6208 let number = match created_issue_number(created) {
6209 Ok(number) => number,
6210 Err(error) => {
6211 let _ = self.delete_issue(&content_id).await;
6212 return Err(error);
6213 }
6214 };
6215 let added = match self
6216 .graphql(
6217 graphql::ADD_TO_BOARD,
6218 json!({"input":{"projectId":board_id,"contentId":content_id.0}}),
6219 )
6220 .await
6221 {
6222 Ok(added) => added,
6223 Err(error) => {
6224 let _ = self.delete_issue(&content_id).await;
6225 return Err(error);
6226 }
6227 };
6228 let item = added
6229 .pointer("/addProjectV2ItemById/item")
6230 .filter(|value| !value.is_null())
6231 .ok_or_else(|| SourceError::Malformed {
6232 message: "GitHub board addition returned no project item".into(),
6233 })?;
6234 Ok(Landed {
6235 content_id,
6236 item_id: required_str(item, "id")?.to_owned(),
6237 url,
6238 number,
6239 })
6240 }
6241
6242 /// Move one issue under the project it now belongs to, or out of the one it left.
6243 async fn reparent(
6244 &self,
6245 held: Option<NativeId>,
6246 child: &NativeId,
6247 wanted: Option<&NativeId>,
6248 ) -> Result<(), SourceError> {
6249 if held.as_ref() == wanted {
6250 return Ok(());
6251 }
6252 if let Some(held) = &held {
6253 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
6254 .await?;
6255 }
6256 if let Some(wanted) = wanted {
6257 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
6258 .await?;
6259 }
6260 Ok(())
6261 }
6262
6263 async fn sub_issue(
6264 &self,
6265 operation: &str,
6266 parent: &NativeId,
6267 child: &NativeId,
6268 root: &str,
6269 ) -> Result<(), SourceError> {
6270 let data = self
6271 .graphql(
6272 operation,
6273 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
6274 )
6275 .await?;
6276 let issue =
6277 data.pointer(&format!("/{root}/issue"))
6278 .ok_or_else(|| SourceError::Malformed {
6279 message: "GitHub sub-issue update returned no issue".into(),
6280 })?;
6281 let sub =
6282 data.pointer(&format!("/{root}/subIssue"))
6283 .ok_or_else(|| SourceError::Malformed {
6284 message: "GitHub sub-issue update returned no sub-issue".into(),
6285 })?;
6286 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
6287 return Err(SourceError::Malformed {
6288 message: "GitHub sub-issue update returned the wrong issues".into(),
6289 });
6290 }
6291 Ok(())
6292 }
6293
6294 /// Bring one issue's `blockedBy` to exactly `native`, sending only the difference, and say
6295 /// whether there was one.
6296 async fn reconcile_blocked_by(
6297 &self,
6298 content_id: &NativeId,
6299 native: &[String],
6300 ) -> Result<bool, SourceError> {
6301 let current = self.native_dependency_ids(content_id).await?;
6302 let mut changed = false;
6303 for (operation, far_id) in current
6304 .iter()
6305 .filter(|id| !native.contains(id))
6306 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
6307 .chain(
6308 native
6309 .iter()
6310 .filter(|id| !current.contains(id))
6311 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
6312 )
6313 {
6314 let data = self
6315 .graphql(
6316 operation,
6317 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
6318 )
6319 .await?;
6320 let root = if operation == graphql::ADD_BLOCKED_BY {
6321 "addBlockedBy"
6322 } else {
6323 "removeBlockedBy"
6324 };
6325 let issue =
6326 data.pointer(&format!("/{root}/issue"))
6327 .ok_or_else(|| SourceError::Malformed {
6328 message: "GitHub dependency update returned no issue".into(),
6329 })?;
6330 let blocker = data
6331 .pointer(&format!("/{root}/blockingIssue"))
6332 .ok_or_else(|| SourceError::Malformed {
6333 message: "GitHub dependency update returned no blocking issue".into(),
6334 })?;
6335 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
6336 {
6337 return Err(SourceError::Malformed {
6338 message: "GitHub dependency update returned the wrong issues".into(),
6339 });
6340 }
6341 changed = true;
6342 }
6343 Ok(changed)
6344 }
6345}
6346
6347/// What resolving one node id reached; see [`GitHubProjectsSource::reach`].
6348enum Reached {
6349 /// An issue this board holds, resolved into everything this source reports about it.
6350 Held(Box<Resolved>),
6351 /// Nothing this board holds: no such node, or a node on some other board.
6352 Nothing,
6353 /// A board draft, which [`graphql::ISSUE`] reaches and reads nothing of, so it is read
6354 /// again by [`GitHubProjectsSource::draft_by_id`].
6355 Draft,
6356}
6357
6358/// What GitHub says when a string is not a node id it can resolve.
6359///
6360/// Matched because it is the ordinary answer to a project selector naming a project by its
6361/// *name*, and reporting that as a failure would make naming one impossible. It is read
6362/// off the refusal GitHub sent, never guessed from the shape of the string: this source
6363/// does not define the syntax of a GitHub node id and would be wrong about it.
6364const UNRESOLVABLE_NODE: &str = "could not resolve to a node";
6365
6366/// Whether this refusal is GitHub saying the id names no node at all.
6367fn unresolvable_node(error: &SourceError) -> bool {
6368 matches!(error, SourceError::Refused { message }
6369 if message.to_ascii_lowercase().contains(UNRESOLVABLE_NODE))
6370}
6371
6372/// One project name, as a search qualifier which filters on it at the server.
6373///
6374/// Quoted so the whole title is one phrase rather than a bag of words, with the two
6375/// characters GitHub's own quoting grammar gives a meaning inside a quoted phrase escaped
6376/// the way it documents. A title matched here is still compared for equality afterwards:
6377/// the qualifier narrows what the server sends, and this source decides what it names.
6378fn title_qualifier(name: &str) -> String {
6379 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
6380 format!("in:title \"{escaped}\"")
6381}
6382
6383/// The board, and every item on it this source reports.
6384#[derive(Clone)]
6385struct Board {
6386 id: String,
6387 fields: Value,
6388 items: Vec<Resolved>,
6389}
6390
6391/// What a write needs of the board and nothing more: its node id and its field
6392/// definitions, in the shape a read of the board's own `fields` gives them.
6393///
6394/// Deliberately no items. A write decides which item it writes, which parent it files
6395/// under and which far ends it names by reading each of them by its own id; this is the
6396/// half of the board those reads cannot carry, and holding no item is what keeps it from
6397/// ever being asked whether an item is there.
6398#[derive(Clone)]
6399struct BoardFields {
6400 id: BoardId,
6401 fields: Value,
6402}
6403
6404/// A board's node id: what a field write and `addProjectV2ItemById` address.
6405///
6406/// Never blank, because a blank one addresses no board — so an id GitHub answers blank is
6407/// refused where it is read, and one an item names blank is read as not named at all.
6408#[derive(Clone)]
6409struct BoardId(String);
6410
6411/// Where one write left its item, for the record the rest of the command reads it out of.
6412///
6413/// A named record rather than a tuple because the update arm and the create arm each fill
6414/// all four, and two `Option`s of different meaning side by side in a tuple are two
6415/// positions a reader has to count.
6416struct Landed {
6417 /// The issue's own node id, which is the [`NativeId`] this source reports.
6418 content_id: NativeId,
6419 /// The board item's id, which is what a field write addresses.
6420 // 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.
6421 item_id: String,
6422 /// The web address GitHub gave the issue, when it gave one.
6423 // 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.
6424 url: Option<String>,
6425 /// The issue's number on its repository, when GitHub reported one.
6426 number: Option<u64>,
6427}
6428
6429impl BoardId {
6430 fn parse(id: &str) -> Result<Self, SourceError> {
6431 if id.trim().is_empty() {
6432 return Err(SourceError::Malformed {
6433 message: "GitHub named a board with a blank node id".into(),
6434 });
6435 }
6436 Ok(Self(id.to_owned()))
6437 }
6438
6439 fn as_str(&self) -> &str {
6440 &self.0
6441 }
6442}
6443
6444impl Board {
6445 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
6446 complete_connection(fields, "project fields", NESTED_PAGE_SIZE)?;
6447 let nodes = fields
6448 .get("nodes")
6449 .and_then(Value::as_array)
6450 .ok_or_else(|| SourceError::Malformed {
6451 message: "GitHub project fields.nodes is not an array".into(),
6452 })?;
6453 Ok(nodes
6454 .iter()
6455 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
6456 }
6457}
6458
6459/// One board item, resolved into everything this source reports about it.
6460#[derive(Clone)]
6461struct Resolved {
6462 item_id: String,
6463 id: NativeId,
6464 content_kind: ContentKind,
6465 kind: BoardKind,
6466 title: String,
6467 body: Option<String>,
6468 /// The body exactly as GitHub holds it, metadata slot and all, which is what a write
6469 /// that changes the slot alone has to keep byte for byte outside it.
6470 raw_body: Option<String>,
6471 status: Status,
6472 /// The name of the board `Status` option this item sits in, as the board spells it.
6473 option: Option<String>,
6474 /// What its `Priority` field says, read through this instance's mapping.
6475 priority: HeldPriority,
6476 /// Whether this item's issue is closed. A draft has no such state and is never closed.
6477 closed: bool,
6478 /// The tasks this one delivers, read out of its slot. Empty for anything not a task.
6479 delivers: Vec<TaskRef>,
6480 /// Every task that delivers this one, read out of its slot. Empty for anything not a
6481 /// task.
6482 delivered_by: Vec<TaskRef>,
6483 labels: Vec<Label>,
6484 parent: Option<NativeId>,
6485 // 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.
6486 origin: Option<String>,
6487 /// The issue's own number on its repository, as GitHub reports it.
6488 ///
6489 /// `None` in exactly two cases: a draft, which has no number at all — `DraftIssue`
6490 /// declares none, and a draft is not filed in a repository to be numbered by one — and
6491 /// an issue this run created whose creating mutation answered without one, which is a
6492 /// response GitHub's own schema says cannot happen and which a landed write is not
6493 /// worth failing over. An `Issue` read off the board always has one.
6494 number: Option<u64>,
6495 url: Option<String>,
6496 created_at: Option<DateTime<Utc>>,
6497 updated_at: Option<DateTime<Utc>>,
6498 own_repository: Option<Repository>,
6499 repositories: Vec<Repository>,
6500 slot: BTreeMap<String, Value>,
6501 /// The node id of the board this item sits on, when the read that reached it said.
6502 board_id: Option<String>,
6503 /// The definition of every board field this item holds a value of, in the shape a read
6504 /// of the board's own `fields` gives one.
6505 ///
6506 /// Only the fields this item has a value in: a field it holds nothing of is not here,
6507 /// which says nothing about whether the board has it.
6508 fields: Vec<Value>,
6509}
6510
6511impl Resolved {
6512 /// The board this item's own read names it on, when that read named one this source can
6513 /// address.
6514 fn named_board(&self) -> Option<BoardId> {
6515 self.board_id
6516 .as_deref()
6517 .and_then(|id| BoardId::parse(id).ok())
6518 }
6519
6520 /// Whether this item holds a value of the board field called `name`, and so carries
6521 /// that field's definition. `false` says nothing about whether the board has the field.
6522 fn defines(&self, name: &str) -> bool {
6523 self.fields
6524 .iter()
6525 .any(|field| field.get("name").and_then(Value::as_str) == Some(name))
6526 }
6527
6528 /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
6529 /// in a field of its own, and none of the five keys that are only an encoding.
6530 ///
6531 /// The two delivery keys are left out for every kind, not only for a task: they are
6532 /// the encoding of [`Task::delivers`] and [`Task::delivered_by`], and a project or a
6533 /// document carrying one holds nothing a caller's own metadata could mean by it.
6534 fn metadata(&self) -> BTreeMap<String, Value> {
6535 let mut metadata = self.slot.clone();
6536 metadata.remove(Repository::METADATA_KEY);
6537 metadata.remove(DependencyEdge::RECORDED_KEY);
6538 metadata.remove(ItemKind::METADATA_KEY);
6539 metadata.remove(TaskRef::DELIVERS_KEY);
6540 metadata.remove(TaskRef::DELIVERED_BY_KEY);
6541 if let Some(origin) = &self.origin {
6542 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
6543 }
6544 metadata
6545 }
6546
6547 /// Where this item is, as a link a reader can open.
6548 ///
6549 /// A board is a hosted place and every issue on it has a web address, so that address
6550 /// is what "where is this?" means here — and [`Location::Url`] is what says which kind
6551 /// of place it is, so a reader knows to open it rather than to read a file out. It
6552 /// does not replace or derive from `url`: the field goes on reporting exactly what it
6553 /// reported before, and this says what that address *is*.
6554 ///
6555 /// An item GitHub gave no `url` for — a draft has none — reports no location at all
6556 /// rather than a third variant, which is the contract's "the source did not say". An
6557 /// issue this run created is not one of those: its address comes back from the
6558 /// creating mutation, so it is somewhere a reader can open from the moment it exists
6559 /// rather than from whenever the board read catches up.
6560 fn location(&self) -> Option<Location> {
6561 self.url.clone().map(Location::Url)
6562 }
6563
6564 /// The short handle this board's backend shows people for a task: the issue's number
6565 /// alone, as a decimal string.
6566 ///
6567 /// The number alone rather than `owner/repo#1043`, because that is the contract's
6568 /// value for this backend. A draft has no number and so no handle, which is the
6569 /// contract's *absent* rather than a handle of some other shape — and the native
6570 /// [`Task::id`] here is the issue's GraphQL node id, which this neither replaces nor
6571 /// derives from.
6572 fn key(&self) -> Option<String> {
6573 self.number.map(|number| number.to_string())
6574 }
6575
6576 /// Whether its `Priority` field holds a value at all, mapped or not.
6577 fn holds_priority(&self) -> bool {
6578 self.priority != HeldPriority::Read(Priority::None)
6579 }
6580
6581 /// The task this item is.
6582 ///
6583 /// Fails for an item whose `Priority` field holds an option the mapping does not name:
6584 /// reading that as a level would be a guess, and reading it as `none` would let the next
6585 /// copy clear a priority a person set.
6586 fn task(&self) -> Result<Task, SourceError> {
6587 let priority = match &self.priority {
6588 HeldPriority::Read(priority) => *priority,
6589 HeldPriority::Unmapped(option) => {
6590 return Err(SourceError::Malformed {
6591 message: format!(
6592 "task {}{} sits in the board {PRIORITY_FIELD} option {option:?}, which \
6593 this source's priority_mapping does not name, so its priority cannot be \
6594 read; next: name {option:?} under priority_mapping, or move the item to \
6595 a mapped option",
6596 self.id,
6597 self.number
6598 .map(|number| format!(" (#{number})"))
6599 .unwrap_or_default()
6600 ),
6601 });
6602 }
6603 };
6604 Ok(Task {
6605 id: self.id.clone(),
6606 key: self.key(),
6607 title: self.title.clone(),
6608 content: self.body.clone(),
6609 status: self.status.clone(),
6610 priority,
6611 labels: self.labels.clone(),
6612 project: self.parent.clone(),
6613 url: self.url.clone(),
6614 location: self.location(),
6615 created_at: self.created_at,
6616 updated_at: self.updated_at,
6617 metadata: self.metadata(),
6618 repositories: self.repositories.clone(),
6619 delivers: self.delivers.clone(),
6620 delivered_by: self.delivered_by.clone(),
6621 })
6622 }
6623
6624 fn project(&self) -> Project {
6625 Project {
6626 id: self.id.clone(),
6627 title: self.title.clone(),
6628 content: self.body.clone(),
6629 status: self.status.clone(),
6630 labels: self.labels.clone(),
6631 url: self.url.clone(),
6632 location: self.location(),
6633 created_at: self.created_at,
6634 updated_at: self.updated_at,
6635 metadata: self.metadata(),
6636 repositories: self.repositories.clone(),
6637 }
6638 }
6639
6640 /// The same issue as a document: the project it is filed under, and no status and no
6641 /// dependencies, because a document is not work.
6642 fn document(&self) -> Document {
6643 Document {
6644 id: self.id.clone(),
6645 title: self.title.clone(),
6646 content: self.body.clone(),
6647 project: self.parent.clone(),
6648 labels: self.labels.clone(),
6649 url: self.url.clone(),
6650 location: self.location(),
6651 created_at: self.created_at,
6652 updated_at: self.updated_at,
6653 metadata: self.metadata(),
6654 repositories: self.repositories.clone(),
6655 }
6656 }
6657}
6658
6659/// Where one targeted update moves an item's status, and which of its two halves move.
6660struct StatusMove {
6661 /// The board the item's `Status` field is on.
6662 board: BoardId,
6663 /// The `Status` field's id.
6664 field: String,
6665 /// The option's id.
6666 option: String,
6667 /// The option's name, as the board spells it.
6668 name: String,
6669 /// What the status asks of the issue's state.
6670 target: StatusTarget,
6671 /// The status the item reads as once it is there.
6672 landed: Status,
6673 /// Which of the status's two halves differ from what the item holds.
6674 moves: Moves,
6675}
6676
6677/// Which halves of an item's status one targeted update moves: its `Status` option, the open or
6678/// closed state of its issue, or both. A status neither half of which differs is no move at all,
6679/// and is not a value of this type.
6680#[derive(Clone, Copy, PartialEq, Eq)]
6681enum Moves {
6682 /// The option alone.
6683 Option,
6684 /// The issue's state alone: open, closed, or closed with another reason.
6685 State,
6686 /// Both.
6687 Both,
6688}
6689
6690impl Moves {
6691 /// What differs, or `None` when nothing does.
6692 const fn of(option: bool, state: bool) -> Option<Self> {
6693 match (option, state) {
6694 (true, true) => Some(Self::Both),
6695 (true, false) => Some(Self::Option),
6696 (false, true) => Some(Self::State),
6697 (false, false) => None,
6698 }
6699 }
6700
6701 /// Whether the option moves.
6702 const fn option(self) -> bool {
6703 matches!(self, Self::Option | Self::Both)
6704 }
6705
6706 /// Whether the issue's state moves.
6707 const fn state(self) -> bool {
6708 matches!(self, Self::State | Self::Both)
6709 }
6710}
6711
6712/// What one write is, and the status that comes with being it.
6713///
6714/// One value rather than a [`BoardKind`] beside an `Option<Status>`: a document has no
6715/// status and a task or a project always has one, so "a document carrying a status" and
6716/// "a task carrying none" are states a write cannot be in rather than states every use
6717/// site below has to defend against.
6718enum Written<'a> {
6719 /// A document, which is not work and so has no status at all.
6720 Document,
6721 /// A task or a project, and the status it is being written with.
6722 Work(ItemKind, &'a Status),
6723}
6724
6725impl Written<'_> {
6726 /// Which of the board's three kinds this write is.
6727 const fn kind(&self) -> BoardKind {
6728 match self {
6729 Self::Document => BoardKind::Document,
6730 Self::Work(kind, _) => BoardKind::Work(*kind),
6731 }
6732 }
6733
6734 /// The status this write carries. A document carries none, so a write of one says
6735 /// nothing about the issue's open or closed state and selects no board `Status`
6736 /// option.
6737 const fn status(&self) -> Option<&Status> {
6738 match self {
6739 Self::Document => None,
6740 Self::Work(_, status) => Some(status),
6741 }
6742 }
6743}
6744
6745/// The item being written, in the one shape all three write methods reach.
6746struct Incoming<'a> {
6747 written: Written<'a>,
6748 /// The title a person wrote. A document's goes onto the issue with
6749 /// [`DESIGN_TITLE_PREFIX`] put back, so a round trip returns the title that went in.
6750 title: &'a str,
6751 content: Option<&'a str>,
6752 labels: &'a [Label],
6753 metadata: &'a BTreeMap<String, Value>,
6754 repositories: &'a [Repository],
6755 parent: Option<&'a NativeId>,
6756 /// [`Task::delivers`], already checked. Empty for a project or a document, which is
6757 /// what keeps either key out of their slot.
6758 delivers: &'a [TaskRef],
6759 /// [`Task::delivered_by`], already checked. Empty for a project or a document.
6760 delivered_by: &'a [TaskRef],
6761 /// [`Task::priority`], for a task written to an instance that holds one; `None` for a
6762 /// project, a document, and every write to an instance with no `priority_mapping` —
6763 /// which is what keeps such a write's requests exactly what they were before.
6764 priority: Option<Priority>,
6765}
6766
6767/// What one write does to an item's `Priority` field.
6768enum PriorityWrite {
6769 /// Select this option of this field.
6770 Select {
6771 /// The `Priority` field's id.
6772 field: String,
6773 /// The mapped option's id.
6774 option: String,
6775 },
6776 /// Clear the field's value, which is what `none` is.
6777 Clear {
6778 /// The `Priority` field's id.
6779 field: String,
6780 },
6781}
6782
6783impl Incoming<'_> {
6784 /// The title this write puts on the issue.
6785 fn written_title(&self) -> String {
6786 match self.written {
6787 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
6788 Written::Work(..) => self.title.to_owned(),
6789 }
6790 }
6791}
6792
6793#[derive(Clone, Copy, PartialEq, Eq)]
6794enum ContentKind {
6795 DraftIssue,
6796 Issue,
6797}
6798
6799/// What one board issue is: a document, or the work an [`ItemKind`] names.
6800///
6801/// A type of this source's own rather than an `ItemKind` with a third variant, because
6802/// `ItemKind` names what a dependency endpoint points at and nothing may point at a
6803/// document — the contract keeps a document out of that enum deliberately. Holding the
6804/// board's three answers in one value is what makes every place that asks "which is this?"
6805/// answer all three, rather than a `document: bool` beside a `kind` that means nothing for
6806/// two thirds of the board.
6807#[derive(Clone, Copy, PartialEq, Eq)]
6808enum BoardKind {
6809 /// An issue whose title begins [`DESIGN_TITLE_PREFIX`].
6810 Document,
6811 /// Every other issue, and every draft.
6812 Work(ItemKind),
6813}
6814
6815impl BoardKind {
6816 /// How a refusal names this kind to the person reading it.
6817 const fn describes(self) -> &'static str {
6818 match self {
6819 Self::Document => "document",
6820 Self::Work(kind) => kind.marker(),
6821 }
6822 }
6823}
6824
6825/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
6826///
6827/// This is the local Markdown source's `labels_match`, spelled the same way on purpose:
6828/// the shared cross-source journeys assert one answer to one question, so two sources
6829/// that disagree about what "carries the label bug" means fail them.
6830fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
6831 let holds = |name: &String| {
6832 labels
6833 .iter()
6834 .any(|label| label.name.eq_ignore_ascii_case(name))
6835 };
6836 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
6837 && filter.all_of.iter().all(holds)
6838 && !filter.none_of.iter().any(holds)
6839}
6840
6841/// Whether `category` is one of `statuses`. An empty list is unfiltered rather than
6842/// "keeps nothing", which is what lets a `Vec<StatusCategory>` spell no filter at all.
6843fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
6844 statuses.is_empty() || statuses.contains(&category)
6845}
6846
6847/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
6848///
6849/// `content` is the item's own prose — the body with this source's trailing metadata
6850/// comment already taken off — so a search never matches an encoding the author of the
6851/// issue never wrote.
6852fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
6853 let terms = query.terms.to_lowercase();
6854 let in_title = title.to_lowercase().contains(&terms);
6855 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
6856 match query.fields {
6857 TextFields::Title => in_title,
6858 TextFields::Content => in_content,
6859 TextFields::TitleOrContent => in_title || in_content,
6860 }
6861}
6862
6863/// Whether `task` satisfies `query`, with `project` deciding the project predicate.
6864///
6865/// The project predicate is passed separately because a read narrowed to one project has
6866/// already answered it by asking *that project* for its own items — and re-applying it
6867/// there would compare the caller's selector, which may be a project's **name**, against
6868/// the id of the project that name resolved to, and keep nothing. Every other read passes
6869/// `query.project` and applies it here, which is what keeps `projects` a predicate this
6870/// source really does apply.
6871fn task_matches(task: &Task, query: &TaskQuery, project: &ProjectFilter) -> bool {
6872 labels_match(&task.labels, &query.labels)
6873 && status_matches(task.status.category, &query.statuses)
6874 && (query.priorities.is_empty() || query.priorities.contains(&task.priority))
6875 && match project {
6876 ProjectFilter::Any => true,
6877 ProjectFilter::Orphans => task.project.is_none(),
6878 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
6879 }
6880 && query
6881 .text
6882 .as_ref()
6883 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
6884}
6885
6886fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
6887 labels_match(&project.labels, &query.labels)
6888 && status_matches(project.status.category, &query.statuses)
6889 && query
6890 .text
6891 .as_ref()
6892 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
6893}
6894
6895/// The same three predicates a task query carries, minus the status filter.
6896///
6897/// A document is not work, so it has no status for one to compare against and the query
6898/// type carries none. The project predicate is the same one — a design issue filed under a
6899/// project issue is in that project, and one filed under nothing is in none — so it is
6900/// spelled the same way here rather than answered differently.
6901fn document_matches(document: &Document, query: &DocumentQuery, project: &ProjectFilter) -> bool {
6902 labels_match(&document.labels, &query.labels)
6903 && match project {
6904 ProjectFilter::Any => true,
6905 ProjectFilter::Orphans => document.project.is_none(),
6906 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
6907 }
6908 && query
6909 .text
6910 .as_ref()
6911 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
6912}
6913
6914#[async_trait::async_trait]
6915impl TaskSource for GitHubProjectsSource {
6916 fn kind(&self) -> &'static str {
6917 KIND
6918 }
6919 fn capabilities(&self) -> Capabilities {
6920 Capabilities {
6921 projects: Support::Native,
6922 documents: Support::Native,
6923 comments: Support::Native,
6924 priority: if self.priorities.is_some() {
6925 Support::Native
6926 } else {
6927 Support::Unsupported
6928 },
6929 filter_by_priority: Support::Native,
6930 filter_by_comment_activity: Support::Native,
6931 orphan_tasks: Support::Native,
6932 filter_by_label: Support::Native,
6933 filter_by_status: Support::Native,
6934 search_title: Support::Native,
6935 search_content: Support::Native,
6936 task_dependencies: DependencySupport::BothDirections,
6937 project_dependencies: DependencySupport::BothDirections,
6938 max_page_size: MAX_PAGE_SIZE,
6939 }
6940 }
6941 async fn health(&self) -> Result<Health, SourceError> {
6942 let board = self.board_page(None, 1).await?;
6943 Ok(Health {
6944 reachable: true,
6945 detail: Some(format!(
6946 "reading GitHub project {}/{} ({})",
6947 self.owner,
6948 self.project_number,
6949 required_str(&board, "title")?
6950 )),
6951 })
6952 }
6953 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
6954 self.item_by_id(id)
6955 .await?
6956 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6957 .map(|item| item.task())
6958 .transpose()
6959 }
6960 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
6961 Ok(self
6962 .item_by_id(id)
6963 .await?
6964 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
6965 .map(|item| item.project()))
6966 }
6967 async fn query_tasks(
6968 &self,
6969 query: &TaskQuery,
6970 page: &PageRequest,
6971 ) -> Result<Page<Task>, SourceError> {
6972 validate_page(page)?;
6973 // A read narrowed to one project asks that project for its own tasks, so nothing
6974 // about it costs what the rest of the board holds. A read narrowed to comment
6975 // activity asks the board's own issue search for the issues updated since, which is
6976 // every issue a comment could have been written or edited on since. Every other task
6977 // read is a question about the whole board and is answered by reading it.
6978 let (held, membership) = match (&query.project, query.commented_since) {
6979 (ProjectFilter::Is(project), _) => (
6980 self.project_children(project).await?,
6981 // Answered by where these items came from; see `task_matches`.
6982 &ProjectFilter::Any,
6983 ),
6984 (ProjectFilter::Any | ProjectFilter::Orphans, Some(since)) => {
6985 (self.updated_since(since).await?, &query.project)
6986 }
6987 (ProjectFilter::Any | ProjectFilter::Orphans, None) => {
6988 (self.board().await?.items, &query.project)
6989 }
6990 };
6991 // Filtered before paged: a page of a filtered result is a page of the survivors,
6992 // never the survivors of a page.
6993 let mut tasks = Vec::new();
6994 for item in held
6995 .iter()
6996 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
6997 {
6998 let task = item.task()?;
6999 if task_matches(&task, query, membership)
7000 && self.commented_since(item, query.commented_since).await?
7001 {
7002 tasks.push(task);
7003 }
7004 }
7005 Ok(offset_page(
7006 tasks,
7007 numeric_cursor(page.cursor.as_ref())?,
7008 page.limit.min(MAX_PAGE_SIZE) as usize,
7009 ))
7010 }
7011 async fn query_projects(
7012 &self,
7013 query: &ProjectQuery,
7014 page: &PageRequest,
7015 ) -> Result<Page<Project>, SourceError> {
7016 validate_page(page)?;
7017 // The projects a board holds are found by an issue search scoped to that board,
7018 // never by walking the board's own item connection: what tells a project from a
7019 // task is the `parent` each issue carries, which costs nothing to read.
7020 let projects = self
7021 .board_issues()
7022 .await?
7023 .iter()
7024 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
7025 .map(Resolved::project)
7026 .filter(|project| project_matches(project, query))
7027 .collect();
7028 Ok(offset_page(
7029 projects,
7030 numeric_cursor(page.cursor.as_ref())?,
7031 page.limit.min(MAX_PAGE_SIZE) as usize,
7032 ))
7033 }
7034 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
7035 Ok(self
7036 .item_by_id(id)
7037 .await?
7038 .filter(|item| item.kind == BoardKind::Document)
7039 .map(|item| item.document()))
7040 }
7041 async fn query_documents(
7042 &self,
7043 query: &DocumentQuery,
7044 page: &PageRequest,
7045 ) -> Result<Page<Document>, SourceError> {
7046 validate_page(page)?;
7047 // Narrowed to one project, this is the same sub-issue read a task list scoped to
7048 // that project makes — a document filed under a project is a sub-issue of it too,
7049 // and which of them come back is the kind this caller asked for.
7050 let (held, membership) = match &query.project {
7051 ProjectFilter::Is(project) => (
7052 self.project_children(project).await?,
7053 // Answered by where these items came from; see `task_matches`.
7054 &ProjectFilter::Any,
7055 ),
7056 ProjectFilter::Any | ProjectFilter::Orphans => {
7057 (self.board().await?.items, &query.project)
7058 }
7059 };
7060 // Filtered before paged, exactly as a task read is: a page of a filtered result is
7061 // a page of the survivors, never the survivors of a page.
7062 let documents = held
7063 .iter()
7064 .filter(|item| item.kind == BoardKind::Document)
7065 .map(Resolved::document)
7066 .filter(|document| document_matches(document, query, membership))
7067 .collect();
7068 Ok(offset_page(
7069 documents,
7070 numeric_cursor(page.cursor.as_ref())?,
7071 page.limit.min(MAX_PAGE_SIZE) as usize,
7072 ))
7073 }
7074 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
7075 validate_page(page)?;
7076 let offset = numeric_cursor(page.cursor.as_ref())?;
7077 let mut labels = self
7078 .board()
7079 .await?
7080 .items
7081 .into_iter()
7082 .flat_map(|item| item.labels)
7083 .fold(Vec::new(), |mut all, label| {
7084 if !all.iter().any(|x: &Label| x.id == label.id) {
7085 all.push(label);
7086 }
7087 all
7088 });
7089 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
7090 Ok(offset_page(
7091 labels,
7092 offset,
7093 page.limit.min(MAX_PAGE_SIZE) as usize,
7094 ))
7095 }
7096 async fn task_dependencies(
7097 &self,
7098 id: &NativeId,
7099 direction: Direction,
7100 page: &PageRequest,
7101 ) -> Result<Page<DependencyEdge>, SourceError> {
7102 self.dependencies(id, ItemKind::Task, direction, page).await
7103 }
7104 async fn project_dependencies(
7105 &self,
7106 id: &NativeId,
7107 direction: Direction,
7108 page: &PageRequest,
7109 ) -> Result<Page<DependencyEdge>, SourceError> {
7110 self.dependencies(id, ItemKind::Project, direction, page)
7111 .await
7112 }
7113
7114 fn writes(&self) -> WriteSupport {
7115 WriteSupport::Supported
7116 }
7117
7118 /// Create or update one task.
7119 ///
7120 /// Its `delivers` and `delivered_by` are checked before anything is read or written —
7121 /// neither may name the task itself or name one task twice — and land in the body's
7122 /// metadata slot under their reserved keys, in place of any caller metadata of those
7123 /// names.
7124 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
7125 let near = write.target.as_ref().unwrap_or(&write.item.id);
7126 for (key, entries) in [
7127 (TaskRef::DELIVERS_KEY, &write.item.delivers),
7128 (TaskRef::DELIVERED_BY_KEY, &write.item.delivered_by),
7129 ] {
7130 TaskRef::listed(key, near, Some(&self.name), entries.clone())
7131 .map_err(|message| SourceError::Refused { message })?;
7132 }
7133 if self.priorities.is_none() && write.item.priority != Priority::None {
7134 return Err(self.holds_no_priority());
7135 }
7136 self.write_item(
7137 &Incoming {
7138 written: Written::Work(ItemKind::Task, &write.item.status),
7139 title: &write.item.title,
7140 content: write.item.content.as_deref(),
7141 labels: &write.item.labels,
7142 metadata: &write.item.metadata,
7143 repositories: &write.item.repositories,
7144 parent: write.item.project.as_ref(),
7145 delivers: &write.item.delivers,
7146 delivered_by: &write.item.delivered_by,
7147 priority: self.priorities.as_ref().map(|_| write.item.priority),
7148 },
7149 write.target.as_ref(),
7150 &write.depends_on,
7151 )
7152 .await
7153 }
7154
7155 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
7156 self.write_item(
7157 &Incoming {
7158 written: Written::Work(ItemKind::Project, &write.item.status),
7159 title: &write.item.title,
7160 content: write.item.content.as_deref(),
7161 labels: &write.item.labels,
7162 metadata: &write.item.metadata,
7163 repositories: &write.item.repositories,
7164 parent: None,
7165 delivers: &[],
7166 delivered_by: &[],
7167 priority: None,
7168 },
7169 write.target.as_ref(),
7170 &write.depends_on,
7171 )
7172 .await
7173 }
7174
7175 /// Create or update one document, which is one issue titled the way this board spells
7176 /// a document.
7177 ///
7178 /// Everything else is exactly a task write: caller metadata goes to the same canonical
7179 /// JSON slot at the end of the body and comes back with its JSON types intact, a key
7180 /// or a field this board cannot carry is refused by name rather than dropped, a target
7181 /// naming an issue this board does not hold is refused rather than created, and an
7182 /// issue this call created is taken back when the rest of the write fails.
7183 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
7184 // A document takes part in no dependency graph, so there is no far end to write
7185 // natively and none to record: a caller naming one is told so rather than having it
7186 // stored under the reserved key, where a later read would report an edge the
7187 // contract says cannot exist.
7188 if !write.depends_on.is_empty() {
7189 return Err(SourceError::Refused {
7190 message: format!(
7191 "this write names {} dependencies for a document, and a document takes \
7192 part in no dependency graph; next: put the dependency on the task or \
7193 project the document is about",
7194 write.depends_on.len()
7195 ),
7196 });
7197 }
7198 self.write_item(
7199 &Incoming {
7200 written: Written::Document,
7201 title: &write.item.title,
7202 content: write.item.content.as_deref(),
7203 labels: &write.item.labels,
7204 metadata: &write.item.metadata,
7205 repositories: &write.item.repositories,
7206 parent: write.item.project.as_ref(),
7207 delivers: &[],
7208 delivered_by: &[],
7209 priority: None,
7210 },
7211 write.target.as_ref(),
7212 &[],
7213 )
7214 .await
7215 }
7216
7217 /// Set one task's status alone.
7218 ///
7219 /// An open target reopens a closed issue with an `updateIssue` carrying only its
7220 /// `stateInput`, then selects the board option with `updateProjectV2ItemFieldValue`; a
7221 /// terminal target selects its mapped option, then closes with its fixed reason. No
7222 /// request carries a title, a body or a label. The status
7223 /// answered is what [`StatusMapping::status`] reads off the state just written, which is
7224 /// what a re-read reports.
7225 async fn set_task_status(
7226 &self,
7227 id: &NativeId,
7228 category: StatusCategory,
7229 ) -> Result<Option<Status>, SourceError> {
7230 self.set_status(id, category).await
7231 }
7232
7233 /// Set one task's priority alone: one `updateProjectV2ItemFieldValue` selecting the
7234 /// mapped option of the board's `Priority` field, or one `clearProjectV2ItemFieldValue`
7235 /// for `none`. Refused by an instance with no `priority_mapping`.
7236 async fn set_task_priority(
7237 &self,
7238 id: &NativeId,
7239 priority: Priority,
7240 ) -> Result<Option<Priority>, SourceError> {
7241 self.set_priority(id, priority).await
7242 }
7243
7244 /// Replace one task's content with a single body update that keeps the metadata slot
7245 /// byte for byte.
7246 async fn set_task_content(
7247 &self,
7248 id: &NativeId,
7249 content: &str,
7250 ) -> Result<Option<()>, SourceError> {
7251 self.replace_content(id, content).await
7252 }
7253
7254 /// Replace one task issue's content and its provenance slot entry with a single body
7255 /// update. The answers are not kept: see `replace_rendering`.
7256 async fn set_task_rendering(
7257 &self,
7258 id: &NativeId,
7259 content: &str,
7260 provenance: &Value,
7261 _answers: &BTreeMap<String, Value>,
7262 ) -> Result<Option<()>, SourceError> {
7263 self.replace_rendering(id, BoardKind::Work(ItemKind::Task), content, provenance)
7264 .await
7265 }
7266
7267 /// Replace one design-document issue's content and its provenance slot entry, on exactly
7268 /// the terms of [`set_task_rendering`](TaskSource::set_task_rendering).
7269 async fn set_document_rendering(
7270 &self,
7271 id: &NativeId,
7272 content: &str,
7273 provenance: &Value,
7274 _answers: &BTreeMap<String, Value>,
7275 ) -> Result<Option<()>, SourceError> {
7276 self.replace_rendering(id, BoardKind::Document, content, provenance)
7277 .await
7278 }
7279
7280 /// Apply a targeted update with one read of the item and a write only for what differs:
7281 /// at most one `updateIssue` for title, body and state, one field write each for `Status`
7282 /// and `Priority`, and the `blockedBy` difference. See `targeted_update`.
7283 async fn update_task(
7284 &self,
7285 id: &NativeId,
7286 update: &TaskUpdate,
7287 ) -> Result<Option<TaskUpdateOutcome>, SourceError> {
7288 self.targeted_update(id, update).await
7289 }
7290
7291 /// Replace one task's `delivered_by` with a single body update that changes the
7292 /// metadata slot and nothing outside it.
7293 async fn set_delivered_by(
7294 &self,
7295 id: &NativeId,
7296 delivered_by: &[TaskRef],
7297 ) -> Result<Option<()>, SourceError> {
7298 self.replace_delivered_by(id, delivered_by).await
7299 }
7300
7301 /// Set one key of one task issue's metadata with a single body update that changes the
7302 /// metadata slot and nothing outside it — no title, label, state or board field request —
7303 /// and sends nothing when the task already holds that value under the key.
7304 async fn set_task_metadata(
7305 &self,
7306 id: &NativeId,
7307 key: &MetadataKey,
7308 value: &Value,
7309 ) -> Result<Option<Task>, SourceError> {
7310 Ok(self
7311 .set_slot_key(id, BoardKind::Work(ItemKind::Task), key, value)
7312 .await?
7313 .map(|item| item.task())
7314 .transpose()?)
7315 }
7316
7317 /// Set one key of one project issue's metadata, on exactly the terms of
7318 /// [`set_task_metadata`](TaskSource::set_task_metadata).
7319 async fn set_project_metadata(
7320 &self,
7321 id: &NativeId,
7322 key: &MetadataKey,
7323 value: &Value,
7324 ) -> Result<Option<Project>, SourceError> {
7325 Ok(self
7326 .set_slot_key(id, BoardKind::Work(ItemKind::Project), key, value)
7327 .await?
7328 .map(|item| item.project()))
7329 }
7330
7331 /// Set one key of one design-document issue's metadata, on exactly the terms of
7332 /// [`set_task_metadata`](TaskSource::set_task_metadata).
7333 async fn set_document_metadata(
7334 &self,
7335 id: &NativeId,
7336 key: &MetadataKey,
7337 value: &Value,
7338 ) -> Result<Option<Document>, SourceError> {
7339 Ok(self
7340 .set_slot_key(id, BoardKind::Document, key, value)
7341 .await?
7342 .map(|item| item.document()))
7343 }
7344
7345 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
7346 self.delete_item(id).await
7347 }
7348
7349 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
7350 self.delete_item(id).await
7351 }
7352
7353 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
7354 self.delete_item(id).await
7355 }
7356
7357 /// One page of the task issue's own comments, walked by GitHub's own cursor.
7358 ///
7359 /// Nothing here filters, so nothing has to be read ahead of the page: the caller's limit is
7360 /// the page GitHub is asked for and GitHub's `endCursor` is the cursor handed back.
7361 async fn task_comments(
7362 &self,
7363 task: &NativeId,
7364 page: &PageRequest,
7365 ) -> Result<Option<Page<Comment>>, SourceError> {
7366 validate_page(page)?;
7367 let Some(issue) = self.commented_issue(task).await? else {
7368 return Ok(None);
7369 };
7370 let after = page.cursor.as_ref().map(|cursor| cursor.0.as_str());
7371 let data = self
7372 .graphql(
7373 graphql::ISSUE_COMMENTS,
7374 json!({"id":issue.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":after}),
7375 )
7376 .await?;
7377 // The issue was there a moment ago; one removed since is no longer a task here.
7378 let Some(node) = data.get("node").filter(|value| !value.is_null()) else {
7379 return Ok(None);
7380 };
7381 let connection = node
7382 .get("comments")
7383 .filter(|value| !value.is_null())
7384 .ok_or_else(|| SourceError::Malformed {
7385 message: format!(
7386 "GitHub issue {} answered with no comments connection",
7387 issue.0
7388 ),
7389 })?;
7390 let items = optional_nodes(Some(connection), "issue comments")?
7391 .into_iter()
7392 .flatten()
7393 .map(comment_from)
7394 .collect::<Result<Vec<_>, _>>()?;
7395 let next = next_cursor(connection)?;
7396 if let Some(next) = &next {
7397 validate_cursor_progress(after, &next.0)?;
7398 }
7399 Ok(Some(Page { items, next }))
7400 }
7401
7402 /// Add one comment to the task's issue, as the account the token belongs to.
7403 ///
7404 /// The author is refused before anything is sent — not even the task is read — because
7405 /// no answer GitHub could give would make posting under another name than the one asked
7406 /// for the right outcome.
7407 async fn add_comment(
7408 &self,
7409 task: &NativeId,
7410 comment: &NewComment,
7411 ) -> Result<Option<Comment>, SourceError> {
7412 if let Some(author) = &comment.author {
7413 return Err(SourceError::Refused {
7414 message: format!(
7415 "source {} cannot post a comment as {author:?}: GitHub records the account \
7416 the token signs in as the author of every comment; next: leave --author \
7417 out, and the comment is posted as that account",
7418 self.name
7419 ),
7420 });
7421 }
7422 let Some(issue) = self.commented_issue(task).await? else {
7423 return Ok(None);
7424 };
7425 let data = self
7426 .graphql(
7427 graphql::ADD_COMMENT,
7428 json!({"input":{"subjectId":issue.0,"body":comment.body.as_str()}}),
7429 )
7430 .await?;
7431 let subject = data
7432 .pointer("/addComment/subject")
7433 .filter(|value| !value.is_null())
7434 .ok_or_else(|| SourceError::Malformed {
7435 message: "GitHub comment addition returned no subject".into(),
7436 })?;
7437 if required_str(subject, "id")? != issue.0 {
7438 return Err(SourceError::Malformed {
7439 message: "GitHub comment addition answered about another issue".into(),
7440 });
7441 }
7442 let added = data
7443 .pointer("/addComment/commentEdge/node")
7444 .filter(|value| !value.is_null())
7445 .ok_or_else(|| SourceError::Malformed {
7446 message: "GitHub comment addition returned no comment".into(),
7447 })?;
7448 comment_from(added).map(Some)
7449 }
7450
7451 async fn edit_comment(
7452 &self,
7453 task: &NativeId,
7454 comment: &NativeId,
7455 body: &CommentBody,
7456 ) -> Result<Option<Comment>, SourceError> {
7457 let Some(issue) = self.commented_issue(task).await? else {
7458 return Ok(None);
7459 };
7460 if !self.comment_is_on(&issue, comment).await? {
7461 return Ok(None);
7462 }
7463 let data = self
7464 .graphql(
7465 graphql::UPDATE_COMMENT,
7466 json!({"input":{"id":comment.0,"body":body.as_str()}}),
7467 )
7468 .await?;
7469 let edited = data
7470 .pointer("/updateIssueComment/issueComment")
7471 .filter(|value| !value.is_null())
7472 .ok_or_else(|| SourceError::Malformed {
7473 message: "GitHub comment update returned no comment".into(),
7474 })?;
7475 let edited = comment_from(edited)?;
7476 if edited.id != *comment {
7477 return Err(SourceError::Malformed {
7478 message: "GitHub comment update returned the wrong comment".into(),
7479 });
7480 }
7481 Ok(Some(edited))
7482 }
7483
7484 async fn delete_comment(
7485 &self,
7486 task: &NativeId,
7487 comment: &NativeId,
7488 ) -> Result<Option<NativeId>, SourceError> {
7489 let Some(issue) = self.commented_issue(task).await? else {
7490 return Ok(None);
7491 };
7492 if !self.comment_is_on(&issue, comment).await? {
7493 return Ok(None);
7494 }
7495 let data = self
7496 .graphql(graphql::DELETE_COMMENT, json!({"input":{"id":comment.0}}))
7497 .await?;
7498 // The payload says nothing about the comment it removed, so what is checked is that
7499 // GitHub answered the mutation at all rather than leaving it unanswered.
7500 data.get("deleteIssueComment")
7501 .filter(|value| !value.is_null())
7502 .ok_or_else(|| SourceError::Malformed {
7503 message: "GitHub comment deletion returned no payload".into(),
7504 })?;
7505 Ok(Some(comment.clone()))
7506 }
7507
7508 /// Every request this source has recorded, and what each of GitHub's two budgets was
7509 /// attributed — read off the same accounting the session report is rendered from, so
7510 /// the two cannot count one request two ways.
7511 async fn metering(&self) -> Result<Option<Metering>, SourceError> {
7512 Ok(Some(self.ledger.snapshot().metering()))
7513 }
7514}
7515
7516/// One issue comment as the contract carries it.
7517///
7518/// `author` is absent both when GitHub answers `null` for an account that no longer exists
7519/// and when it answers an actor with no login, because either way the source did not say who
7520/// wrote it — which is what an absent author means, rather than an author called nothing.
7521fn comment_from(value: &Value) -> Result<Comment, SourceError> {
7522 Ok(Comment {
7523 id: NativeId(required_str(value, "id")?.to_owned()),
7524 author: optional_str(value.get("author").unwrap_or(&Value::Null), "login")?
7525 .map(str::to_owned),
7526 created_at: optional_time(value, "createdAt")?,
7527 updated_at: optional_time(value, "updatedAt")?,
7528 body: required_str(value, "body")?.to_owned(),
7529 url: optional_str(value, "url")?.map(str::to_owned),
7530 })
7531}
7532
7533/// Where the recorded tail of a dependency walk resumes; see
7534/// [`GitHubProjectsSource::recorded_edges`].
7535const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
7536
7537/// The board text field this source keeps a copy's origin in.
7538///
7539/// Named after the key it holds, and held to that name by the guard below rather than by
7540/// a reader noticing.
7541const ORIGIN_FIELD: &str = "onetaskgraph.origin";
7542
7543/// The metadata key that field holds.
7544///
7545/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
7546/// constructs or interprets the qualified id it carries. This source names it only to
7547/// route it — a short, typed value belongs in a typed field rather than in the body slot
7548/// a caller's own prose shares.
7549///
7550/// Restated rather than imported, because no plugin crate may depend on the engine. What
7551/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
7552/// target in `check`: it reads the engine's own literal and fails naming the file and the
7553/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
7554/// that creates a second item every run instead of finding the one it wrote — and that is
7555/// too late to learn it.
7556const ORIGIN_KEY: &str = "onetaskgraph.origin";
7557
7558/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
7559///
7560/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
7561/// is derived from the far end, never written down on the near item — so only a forward
7562/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
7563/// it did not come from, and it is told so rather than answered with an empty page that
7564/// reads as a walk which ended.
7565fn recorded_offset(
7566 cursor: Option<&str>,
7567 direction: Direction,
7568) -> Result<Option<usize>, SourceError> {
7569 cursor
7570 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
7571 .map(|offset| {
7572 if direction != Direction::DependsOn {
7573 return Err(SourceError::Config {
7574 message: format!(
7575 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
7576 reverse dependency read never issues; resume it in the direction \
7577 that reported it"
7578 ),
7579 });
7580 }
7581 offset.parse().map_err(|_| SourceError::Config {
7582 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
7583 })
7584 })
7585 .transpose()
7586}
7587
7588fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
7589 let mut page = offset_page(edges, offset, limit.max(1));
7590 page.next = page
7591 .next
7592 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
7593 page
7594}
7595
7596/// The kind of one issue reached through a dependency connection.
7597///
7598/// The same questions the board scan asks, over the fields the dependency document
7599/// selects, and in the same order: the design prefix first, then a sub-issue is a task,
7600/// then anything with sub-issues or the marker is a project.
7601///
7602/// # Errors
7603///
7604/// A far end this board holds as a document is refused rather than reported. The two
7605/// answers that are not refusals would both be wrong: reporting it as a task names an id
7606/// no task read of this source can find, and reporting it as a project names one no
7607/// project read can. There is no third value to return — `ItemKind` has no document
7608/// variant, because nothing may point at a document — so the relationship itself is what
7609/// the person is told about.
7610fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
7611 let id = required_str(value, "id")?;
7612 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
7613 return Err(SourceError::Refused {
7614 message: format!(
7615 "GitHub issue {id} is a document of this board — its title begins \
7616 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
7617 on by one; next: remove that issue's blocking relationship on this board"
7618 ),
7619 });
7620 }
7621 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
7622 if parent.is_some() {
7623 return Ok(ItemKind::Task);
7624 }
7625 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
7626 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
7627 message: format!("GitHub issue {id}: {message}"),
7628 })?;
7629 let sub_issues = sub_issue_total(value)?;
7630 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
7631 ItemKind::Project
7632 } else {
7633 ItemKind::Task
7634 })
7635}
7636
7637/// The `IssueStateUpdateInput` one status target asks for.
7638///
7639/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
7640/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
7641/// a currently-closed issue: without that the item would read back `Unknown` and a copy
7642/// would report a change forever. A document has no status at all, and asks for neither.
7643fn state_input(target: Option<&StatusTarget>) -> Value {
7644 match target {
7645 Some(StatusTarget::Terminal(_, reason)) => {
7646 json!({"value":"CLOSED","stateReason":reason.reason()})
7647 }
7648 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
7649 // A document has no status, so a write of one says nothing about the issue's open
7650 // or closed state rather than forcing it open: `stateInput` is what carries that
7651 // instruction, and an explicit null asks for no change to it.
7652 None => Value::Null,
7653 }
7654}
7655
7656/// The metadata one write stores in the item's body slot.
7657///
7658/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
7659/// rather than carried: the kind marker so an empty project stays readable, the
7660/// repository list only when it is not exactly the issue's own repository, and the far
7661/// ends no relationship here can name.
7662fn slot_metadata(
7663 incoming: &Incoming<'_>,
7664 own_repository: Option<&Repository>,
7665 fallback: &[DependencyEdge],
7666) -> BTreeMap<String, Value> {
7667 let mut metadata = incoming.metadata.clone();
7668 metadata.remove(ORIGIN_KEY);
7669 match incoming.written.kind() {
7670 BoardKind::Work(kind) => metadata.insert(
7671 ItemKind::METADATA_KEY.to_owned(),
7672 Value::String(kind.marker().to_owned()),
7673 ),
7674 // A document is told by its title, so it carries no kind marker: that key names
7675 // what a dependency endpoint points at, and nothing may point at a document.
7676 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
7677 };
7678 let derivable = own_repository
7679 .map(|own| incoming.repositories == [own.clone()])
7680 .unwrap_or(incoming.repositories.is_empty());
7681 if derivable {
7682 metadata.remove(Repository::METADATA_KEY);
7683 } else {
7684 metadata.insert(
7685 Repository::METADATA_KEY.to_owned(),
7686 Value::Array(
7687 incoming
7688 .repositories
7689 .iter()
7690 .map(|repository| Value::String(repository.as_str().to_owned()))
7691 .collect(),
7692 ),
7693 );
7694 }
7695 // The typed lists are what land, whatever the caller's own metadata held under their
7696 // keys: a key of either name travelling beside the field would otherwise be a second
7697 // answer to the same question, and the field is the one the contract names.
7698 for (key, entries) in [
7699 (TaskRef::DELIVERS_KEY, incoming.delivers),
7700 (TaskRef::DELIVERED_BY_KEY, incoming.delivered_by),
7701 ] {
7702 set_task_list(&mut metadata, key, entries);
7703 }
7704 record_edges(&mut metadata, fallback);
7705 metadata
7706}
7707
7708/// Hold the far ends no relationship here can name under [`DependencyEdge::RECORDED_KEY`] in
7709/// one slot's metadata, or no such key when there are none.
7710fn record_edges(metadata: &mut BTreeMap<String, Value>, fallback: &[DependencyEdge]) {
7711 if fallback.is_empty() {
7712 metadata.remove(DependencyEdge::RECORDED_KEY);
7713 } else {
7714 metadata.insert(
7715 DependencyEdge::RECORDED_KEY.to_owned(),
7716 Value::Array(
7717 fallback
7718 .iter()
7719 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
7720 .collect(),
7721 ),
7722 );
7723 }
7724}
7725
7726/// Every label one item carries, from its content's own connection and nowhere else.
7727///
7728/// There is no second place to read one from: no document this source sends selects the
7729/// board's built-in `Labels` field, because GitHub derives it from the content and a draft
7730/// cannot carry one at all. The module documentation records the three schema facts that
7731/// settle it.
7732fn labels(content: &Value) -> Result<Vec<Label>, SourceError> {
7733 optional_nodes(content.get("labels"), "content labels")?
7734 .into_iter()
7735 .flatten()
7736 .map(|v| {
7737 Ok(Label {
7738 id: NativeId(required_str(v, "id")?.to_owned()),
7739 name: required_str(v, "name")?.to_owned(),
7740 color: optional_str(v, "color")?.map(str::to_owned),
7741 })
7742 })
7743 .collect()
7744}
7745
7746/// The definition of each board field one item's values are values of, in the shape a read
7747/// of the board's own `fields` gives one.
7748///
7749/// A value names its field through a fragment on that field's own type, so the type is
7750/// known from which kind of value it is: a single-select value's field is a
7751/// `ProjectV2SingleSelectField`, options and all, and a text value's is a `ProjectV2Field`.
7752/// A value whose field carried no id, or an empty one, says nothing usable and is left out.
7753fn field_definitions(field_values: &[Value]) -> Vec<Value> {
7754 field_values
7755 .iter()
7756 .filter_map(|value| {
7757 let field = value.get("field")?.as_object()?;
7758 field.get("id")?.as_str().filter(|id| !id.is_empty())?;
7759 let typename = if value.get("text").is_some() {
7760 "ProjectV2Field"
7761 } else if value.get("name").is_some() {
7762 "ProjectV2SingleSelectField"
7763 } else {
7764 return None;
7765 };
7766 let mut defined = field.clone();
7767 defined.insert("__typename".to_owned(), json!(typename));
7768 Some(Value::Object(defined))
7769 })
7770 .collect()
7771}
7772
7773fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
7774 let Some(node) = field_values
7775 .iter()
7776 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
7777 else {
7778 return Ok(None);
7779 };
7780 Ok(optional_str(node, "text")?.map(str::to_owned))
7781}
7782
7783fn valid_github_owner(owner: &str) -> bool {
7784 !owner.is_empty()
7785 && owner.len() <= 39
7786 && !owner.starts_with('-')
7787 && !owner.ends_with('-')
7788 && !owner.contains("--")
7789 && owner
7790 .bytes()
7791 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
7792}
7793
7794/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
7795/// neither of the two names a path segment already means.
7796fn valid_github_repository_name(name: &str) -> bool {
7797 !name.is_empty()
7798 && name.len() <= 100
7799 && name != "."
7800 && name != ".."
7801 && name
7802 .bytes()
7803 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
7804}
7805
7806fn valid_environment_name(name: &str) -> bool {
7807 let mut bytes = name.bytes();
7808 bytes
7809 .next()
7810 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
7811 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
7812}
7813
7814/// How many sub-issues one issue has.
7815///
7816/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
7817/// absent or non-integer one is a response this source cannot read — and reading it as
7818/// zero would classify a project as a task, which is exactly the mistake the marker
7819/// exists to keep from happening quietly.
7820fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
7821 let summary = issue
7822 .get("subIssuesSummary")
7823 .ok_or_else(|| SourceError::Malformed {
7824 message: "GitHub issue is missing subIssuesSummary".into(),
7825 })?;
7826 summary
7827 .get("total")
7828 .and_then(Value::as_u64)
7829 .ok_or_else(|| SourceError::Malformed {
7830 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
7831 })
7832}
7833
7834/// One issue's own `number`.
7835///
7836/// An issue always has one: GitHub declares `Issue.number` as `Int!` and every selection of
7837/// an issue in this module asks for it. So a read of one that comes back without it, or
7838/// with something that is not an unsigned integer, is a response this source cannot read —
7839/// absence here is **not** "this issue has no number". A draft is the content that has
7840/// none, and a draft never reaches this: the caller decides on `__typename` first, the way
7841/// it does for `subIssuesSummary`, which `DraftIssue` equally declares nothing for.
7842fn issue_number(issue: &Value) -> Result<u64, SourceError> {
7843 issue
7844 .get("number")
7845 .and_then(Value::as_u64)
7846 .ok_or_else(|| SourceError::Malformed {
7847 message: "GitHub issue number is missing or is not an unsigned integer".into(),
7848 })
7849}
7850
7851/// The `number` a creating mutation answered with, and `None` when it answered without one;
7852/// why a missing one is tolerated is at the call in `create_and_file_issue`.
7853fn created_issue_number(created: &Value) -> Result<Option<u64>, SourceError> {
7854 match created.get("number") {
7855 None | Some(Value::Null) => Ok(None),
7856 Some(value) => value
7857 .as_u64()
7858 .map(Some)
7859 .ok_or_else(|| SourceError::Malformed {
7860 message: "GitHub created issue number is not an unsigned integer".into(),
7861 }),
7862 }
7863}
7864
7865fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
7866 value
7867 .get(field)
7868 .and_then(Value::as_str)
7869 .ok_or_else(|| SourceError::Malformed {
7870 message: format!("GitHub response is missing string field {field}"),
7871 })
7872}
7873
7874fn required_nonblank_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
7875 let found = required_str(value, field)?;
7876 if found.trim().is_empty() {
7877 return Err(SourceError::Malformed {
7878 message: format!("GitHub response has blank string field {field}"),
7879 });
7880 }
7881 Ok(found)
7882}
7883
7884/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
7885/// needs one — Linear spells them too, in its own description field.
7886///
7887/// Restated rather than shared, because a plugin crate depends on the contract crate and
7888/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
7889/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
7890/// source round-trips its own writes perfectly well under its own spelling.
7891const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
7892const METADATA_CLOSE: &str = "\n-->";
7893
7894/// What the composer puts between a non-empty visible body and the slot, and the one thing
7895/// the parser takes off the visible body when it takes the slot off — exactly once, so every
7896/// other trailing byte of the body comes back as it was written.
7897// 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.
7898const METADATA_SEPARATOR: &str = "\n\n";
7899
7900/// The visible body and the metadata slot at the end of it.
7901///
7902/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
7903/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
7904/// own content and is left alone. The visible body is everything before the slot less the
7905/// one [`METADATA_SEPARATOR`] the composer put there, byte for byte.
7906fn metadata_body(
7907 body: Option<String>,
7908) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
7909 let Some(body) = body else {
7910 return Ok((None, BTreeMap::new()));
7911 };
7912 let Some(slot) = slot_span(&body)? else {
7913 return Ok((Some(body), BTreeMap::new()));
7914 };
7915 let metadata =
7916 serde_json::from_str(&body[slot.encoded_start..slot.encoded_end]).map_err(|error| {
7917 SourceError::Malformed {
7918 message: format!(
7919 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
7920 ),
7921 }
7922 })?;
7923 let before = &body[..slot.start];
7924 let visible = before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before);
7925 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
7926}
7927
7928/// Where the metadata slot sits in one body, as byte offsets into it.
7929struct SlotSpan {
7930 /// Where [`METADATA_OPEN`] begins.
7931 start: usize,
7932 /// Where the encoded JSON begins, just past [`METADATA_OPEN`].
7933 encoded_start: usize,
7934 /// Where the encoded JSON ends, at the start of [`METADATA_CLOSE`].
7935 encoded_end: usize,
7936 /// Just past [`METADATA_CLOSE`].
7937 end: usize,
7938}
7939
7940/// The slot at the very end of `body`, or `None` when it has none.
7941///
7942/// The one reading of *where the slot is*, shared by [`metadata_body`], which reads it, and
7943/// [`with_slot`], which rewrites it — so the two cannot disagree about which comment is the
7944/// slot.
7945fn slot_span(body: &str) -> Result<Option<SlotSpan>, SourceError> {
7946 let Some(start) = body.rfind(METADATA_OPEN) else {
7947 return Ok(None);
7948 };
7949 let encoded_start = start + METADATA_OPEN.len();
7950 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
7951 return Err(SourceError::Malformed {
7952 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
7953 });
7954 };
7955 let encoded_end = encoded_start + relative_end;
7956 let end = encoded_end + METADATA_CLOSE.len();
7957 if !body[end..].trim().is_empty() {
7958 return Ok(None);
7959 }
7960 Ok(Some(SlotSpan {
7961 start,
7962 encoded_start,
7963 encoded_end,
7964 end,
7965 }))
7966}
7967
7968/// `body` with its metadata slot holding exactly `metadata`, and every byte outside the
7969/// slot as it was.
7970///
7971/// A slot that is there has its JSON replaced in place; one that becomes empty is removed
7972/// together with the one [`METADATA_SEPARATOR`] separating it from the prose before it. A
7973/// body with no slot gains one the way [`compose_body`] writes it — after that separator,
7974/// or alone in an empty body — and a body with no slot that is given no metadata is
7975/// returned as it is.
7976fn with_slot(body: &str, metadata: &BTreeMap<String, Value>) -> Result<String, SourceError> {
7977 let encoded = if metadata.is_empty() {
7978 None
7979 } else {
7980 Some(
7981 serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
7982 message: error.to_string(),
7983 })?,
7984 )
7985 };
7986 Ok(match (slot_span(body)?, encoded) {
7987 (Some(slot), Some(encoded)) => format!(
7988 "{}{encoded}{}",
7989 &body[..slot.encoded_start],
7990 &body[slot.encoded_end..]
7991 ),
7992 (Some(slot), None) => {
7993 let before = &body[..slot.start];
7994 format!(
7995 "{}{}",
7996 before.strip_suffix(METADATA_SEPARATOR).unwrap_or(before),
7997 &body[slot.end..]
7998 )
7999 }
8000 (None, None) => body.to_owned(),
8001 (None, Some(encoded)) if body.is_empty() => {
8002 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8003 }
8004 (None, Some(encoded)) => {
8005 format!("{body}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8006 }
8007 })
8008}
8009
8010/// `body` with everything before its metadata slot replaced by `content`, and the slot
8011/// itself kept byte for byte.
8012///
8013/// The inverse of how [`metadata_body`] splits a body: the slot, when there is one, follows
8014/// `content` after the one [`METADATA_SEPARATOR`] the composer puts there — or alone, when
8015/// `content` is empty — so a read of the result reports `content` as the visible body and
8016/// the slot's metadata exactly as it was.
8017fn with_content(body: &str, content: &str) -> Result<String, SourceError> {
8018 let Some(slot) = slot_span(body)? else {
8019 return Ok(content.to_owned());
8020 };
8021 let kept = &body[slot.start..];
8022 Ok(if content.is_empty() {
8023 kept.to_owned()
8024 } else {
8025 format!("{content}{METADATA_SEPARATOR}{kept}")
8026 })
8027}
8028
8029/// Hold `entries` under `key` in one slot's metadata, or no such key when there are none.
8030fn set_task_list(metadata: &mut BTreeMap<String, Value>, key: &str, entries: &[TaskRef]) {
8031 if entries.is_empty() {
8032 metadata.remove(key);
8033 } else {
8034 metadata.insert(
8035 key.to_owned(),
8036 Value::Array(
8037 entries
8038 .iter()
8039 .map(|entry| Value::String(entry.as_str().to_owned()))
8040 .collect(),
8041 ),
8042 );
8043 }
8044}
8045
8046fn compose_body(
8047 content: Option<&str>,
8048 metadata: &BTreeMap<String, Value>,
8049) -> Result<Option<String>, SourceError> {
8050 let visible = content.unwrap_or_default();
8051 if metadata.is_empty() {
8052 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
8053 }
8054 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
8055 message: error.to_string(),
8056 })?;
8057 Ok(Some(if visible.is_empty() {
8058 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8059 } else {
8060 format!("{visible}{METADATA_SEPARATOR}{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
8061 }))
8062}
8063
8064fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
8065 value
8066 .get(field)
8067 .and_then(Value::as_bool)
8068 .ok_or_else(|| SourceError::Malformed {
8069 message: format!("GitHub response is missing boolean field {field}"),
8070 })
8071}
8072fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
8073 match value.get(field) {
8074 None | Some(Value::Null) => Ok(None),
8075 Some(value) => value
8076 .as_str()
8077 .map(Some)
8078 .ok_or_else(|| SourceError::Malformed {
8079 message: format!("GitHub response field {field} is not a string or null"),
8080 }),
8081 }
8082}
8083fn optional_nodes<'a>(
8084 connection: Option<&'a Value>,
8085 name: &str,
8086) -> Result<Option<&'a Vec<Value>>, SourceError> {
8087 match connection {
8088 None | Some(Value::Null) => Ok(None),
8089 Some(value) => value
8090 .get("nodes")
8091 .and_then(Value::as_array)
8092 .map(Some)
8093 .ok_or_else(|| SourceError::Malformed {
8094 message: format!("GitHub {name}.nodes is not an array"),
8095 }),
8096 }
8097}
8098fn complete_connection(connection: &Value, name: &str, size: u32) -> Result<(), SourceError> {
8099 let page_info = connection
8100 .get("pageInfo")
8101 .ok_or_else(|| SourceError::Malformed {
8102 message: format!("GitHub {name} has no pageInfo"),
8103 })?;
8104 if required_bool(page_info, "hasNextPage")? {
8105 return Err(SourceError::Malformed {
8106 message: format!(
8107 "GitHub {name} exceeds the supported nested connection size of {size}"
8108 ),
8109 });
8110 }
8111 Ok(())
8112}
8113fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
8114 optional_str(value, field)?
8115 .map(|timestamp| {
8116 timestamp.parse().map_err(|error| SourceError::Malformed {
8117 message: format!("GitHub response field {field} is not a timestamp: {error}"),
8118 })
8119 })
8120 .transpose()
8121}
8122fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
8123 if page.limit == 0 {
8124 Err(SourceError::Config {
8125 message: "page limit must be at least 1".into(),
8126 })
8127 } else {
8128 Ok(())
8129 }
8130}
8131fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
8132 let page = connection
8133 .get("pageInfo")
8134 .filter(|value| value.is_object())
8135 .ok_or_else(|| SourceError::Malformed {
8136 message: "GitHub connection is missing pageInfo".into(),
8137 })?;
8138 if required_bool(page, "hasNextPage")? {
8139 let cursor = required_str(page, "endCursor")?;
8140 validate_cursor_progress(None, cursor)?;
8141 Ok(Some(Cursor(cursor.into())))
8142 } else {
8143 Ok(None)
8144 }
8145}
8146fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
8147 if next.is_empty() || previous == Some(next) {
8148 Err(SourceError::Malformed {
8149 message: "GitHub pagination cursor is empty or did not advance".into(),
8150 })
8151 } else {
8152 Ok(())
8153 }
8154}
8155fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
8156 cursor.map_or(Ok(0), |c| {
8157 c.0.parse().map_err(|_| SourceError::Config {
8158 message: "page cursor is invalid".into(),
8159 })
8160 })
8161}
8162fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
8163 if offset > items.len() {
8164 return Page::last(vec![]);
8165 }
8166 let tail = items.split_off(offset);
8167 let mut selected = tail;
8168 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
8169 selected.truncate(limit);
8170 Page {
8171 items: selected,
8172 next,
8173 }
8174}