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