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