This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 164c2e0 feat(pr-management-triage): fetch all PRs upfront, classify
in batch (#346)
164c2e0 is described below
commit 164c2e0eecade1bb57b919ccdafecafd528a365a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed May 27 23:37:02 2026 +0200
feat(pr-management-triage): fetch all PRs upfront, classify in batch (#346)
Restructure the triage flow from the per-page interactive loop
to fetch-all → classify-once → present-groups.
== Why ==
The per-page model forced the maintainer to context-switch
between action classes (mark-ready on page 1, draft on page 2,
back to mark-ready on page 3) and required attention throughout
the long fetch phase — the loop paused for input after every
page. Running the skill against the Apache Airflow queue
(200+ open PRs) showed maintainer attention was the bottleneck,
not GraphQL budget.
== Flow changes ==
Step 1 walks every page serially until `hasNextPage=false`,
accumulating PRs into a single in-memory list. The fetch phase
is uninterrupted; the maintainer can step away. One progress
line per page lands so the loop is visibly advancing.
Step 2 classifies the full set in one pass. Pre-filters,
decision table, and Real-CI guard all run once.
Step 3 groups by `(classification, action)` across the entire
queue. A `mark-ready` group can carry 30+ PRs across what was
previously six pages — one screen, one decision.
Step 5 becomes "stale sweeps only" — pagination is finished by
the time Step 5 runs. Each stale sweep that needs a different
candidate set uses the same full-pagination pattern as Step 1.
Golden rule 4 rewritten from "prefetch and pre-classify while
the maintainer is reading" to "fetch all pages up front, then
classify once, then present." The prefetch + pre-classification
machinery in `fetch-and-batch.md` and `interaction-loop.md` is
removed. Lazy per-PR drill-in fetches remain (only fired when
the maintainer pulls a PR out of a group).
Session cache schema: `prefetched_pages.<n>` → `fetched_prs`
(selector, fetched_at, pages_fetched, total_prs, all_prs[],
classified[]).
== Settings ==
Add a project-level `permissions.allow` rule for
`Bash(gh api graphql *)` so the read-only fetch loop bypasses
the `gh api * -F *` / `-f *` ask rules. The pattern is more
specific than the wildcard ask, so it short-circuits. Mutations
via REST or `gh api -X POST` still hit ask. The same allow
rule lands in the isolation-setup template at
`docs/setup/secure-agent-setup.md` and the sandbox-lint
baseline at `tools/sandbox-lint/expected.json` so new adopters
get it out of the box and the baseline stays in lockstep.
== Verification ==
`skill-and-tool-validate` exits 0; pre-existing soft warnings
in unrelated skills, zero hard violations on the touched files.
Generated-by: Claude Code (Opus 4.7)
---
.claude/settings.json | 3 +
.claude/skills/pr-management-triage/SKILL.md | 206 +++++++++++++--------
.../skills/pr-management-triage/fetch-and-batch.md | 202 ++++++++++++--------
.../pr-management-triage/interaction-loop.md | 128 +++++--------
docs/setup/secure-agent-setup.md | 5 +-
tools/sandbox-lint/expected.json | 3 +
6 files changed, 301 insertions(+), 246 deletions(-)
diff --git a/.claude/settings.json b/.claude/settings.json
index bf81703..592b7b9 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -44,6 +44,9 @@
}
},
"permissions": {
+ "allow": [
+ "Bash(gh api graphql *)"
+ ],
"deny": [
"Read(~/.aws/**)",
"Read(~/.ssh/**)",
diff --git a/.claude/skills/pr-management-triage/SKILL.md
b/.claude/skills/pr-management-triage/SKILL.md
index de8a464..ca5dcc4 100644
--- a/.claude/skills/pr-management-triage/SKILL.md
+++ b/.claude/skills/pr-management-triage/SKILL.md
@@ -48,11 +48,22 @@ of scope here.
This skill is the successor to the triage mode of
`breeze pr auto-triage`. It drops the full-screen TUI in favour
-of a CLI conversation: PRs are presented to the maintainer one
-*group* at a time (grouped by suggested action), and the
-maintainer either bulk-confirms the group, pulls individual PRs
-out for case-by-case handling, or skips. Detail files in this
-directory break the logic out topic-by-topic:
+of a CLI conversation. The flow is:
+
+1. **Fetch the entire candidate set up front** by paginating
+ through GitHub until `has_next_page=false`. The fetch is a
+ no-attention phase — the maintainer can step away while the
+ skill walks the pages.
+2. **Classify every fetched PR in one pass**, building groups
+ that span the whole queue (a `mark-ready` group may carry
+ 30 PRs across what was previously six pages).
+3. **Present groups to the maintainer one at a time**, in the
+ fixed risk-ordered sequence. The maintainer bulk-confirms a
+ group, pulls individual PRs out for case-by-case handling,
+ or skips. Within a single group the maintainer never
+ context-switches to a different action class.
+
+Detail files in this directory break the logic out topic-by-topic:
| File | Purpose |
|---|---|
@@ -191,23 +202,23 @@ PR will quickly blow the maintainer's 5000-point/h GraphQL
budget. See [`fetch-and-batch.md`](fetch-and-batch.md) for the
canonical query templates.
-**Golden rule 4 — prefetch *and pre-classify* while the
-maintainer is reading.** The next page of PRs, and the
-deeper-data calls (failed-job log snippets, diff previews for
-workflow-approval PRs) are issued in parallel with the
-maintainer's current decision, not serialised behind it.
-Concretely: when you present group N to the maintainer, the
-same tool-call turn also fires off the GraphQL enrichment for
-group N+1 and the diff fetch for any workflow-approval PRs the
-maintainer is likely to see next. **Pre-classification rides
-along for free** — the moment the next-page payload arrives,
-run pre-filters + decision table on it (a pure function over
-the fetched data — zero further GraphQL) and pre-render the
-first group's screen. Stash the bundle under
-`prefetched_pages.<page_num>` in the session cache so Step 5's
-page-turn collapses to a cache read with no classification
-latency. See
-[`interaction-loop.md#prefetch-plan`](interaction-loop.md).
+**Golden rule 4 — fetch all pages up front, then classify
+once, then present.** Pagination happens entirely in Step 1
+before any group is shown to the maintainer. The fetch loop
+runs until `has_next_page=false`, accumulating every PR record
+into a single in-memory set. Classification runs once over the
+full set (a pure function over the fetched data — zero further
+GraphQL). Groups are then formed across the whole queue, not
+per page. The maintainer sees one screen per `(classification,
+action)` group regardless of how many GitHub pages it spans —
+the `mark-ready` group is presented once with every passing
+PR, not chunk-by-chunk. This eliminates the per-page
+context switch and lets the maintainer step away during the
+fetch phase. The cost is one upfront wait; the saving is no
+intra-session context-switching between action classes. See
+[`fetch-and-batch.md#full-pagination-loop`](fetch-and-batch.md#full-pagination-loop)
+and
+[`interaction-loop.md#group-ordering`](interaction-loop.md#group-ordering).
**Golden rule 5 — scope is triage, not review.** The skill
decides *whether to engage* with a PR and lands a small set of
@@ -393,12 +404,13 @@ via a merge or a misconfigured bot account.
---
-## Step 1 — Resolve the selector and fetch page 1
+## Step 1 — Resolve the selector and fetch every page
Translate the selector into the GraphQL PR-list query from
-[`fetch-and-batch.md`](fetch-and-batch.md). Fetch
-the first page (default 50 PRs) and enrich it in a *single*
-aliased batch call that returns, for every PR on the page:
+[`fetch-and-batch.md`](fetch-and-batch.md). **Walk every page**
+of the result set in a loop until `pageInfo.hasNextPage` is
+false, each iteration issuing one aliased batch call that
+returns, for every PR on the page:
- head SHA, base ref, draft flag, mergeable state,
- check-rollup state + list of failing check names,
@@ -408,15 +420,34 @@ aliased batch call that returns, for every PR on the page:
triaged" detection),
- `authorAssociation` and labels.
+Accumulate every PR into a single in-memory list keyed by
+number. Do not classify, do not present, do not prompt the
+maintainer between pages — the fetch loop is uninterrupted,
+runs to completion, and emits one progress line per page so
+the maintainer can step away during the wait. See
+[`fetch-and-batch.md#full-pagination-loop`](fetch-and-batch.md#full-pagination-loop)
+for the canonical loop pattern and rate-limit accounting.
+
+Also fetch, once per session before the page loop:
+
+- the `action_required` workflow-run index, per
+
[`fetch-and-batch.md#mandatory-action_required-run-index-per-page`](fetch-and-batch.md#mandatory-action_required-run-index-per-page)
+- the recent main-branch failures set, per
+
[`fetch-and-batch.md#recent-main-branch-failures`](fetch-and-batch.md#recent-main-branch-failures-for-is-this-failure-systemic)
+
+Both are repo-scoped (not page-scoped) and only need fetching
+once. Stash them on the session for Step 2.
+
Do not read PR bodies, diffs, or failed-job logs in this step —
those are deferred to the per-PR drill-in when the maintainer
pulls a PR out of a group.
---
-## Step 2 — Filter, classify, and pick action
+## Step 2 — Classify the entire fetched set
-Run the page through [`classify-and-act.md`](classify-and-act.md):
+Run **every PR fetched in Step 1** through
+[`classify-and-act.md`](classify-and-act.md), once:
1. Apply the [pre-filters](classify-and-act.md#pre-filters) (F1–F5b)
to drop collaborator PRs, bot accounts, fresh drafts,
@@ -433,9 +464,12 @@ Run the page through
[`classify-and-act.md`](classify-and-act.md):
Classification + action selection is a pure function of the data
already fetched in Step 1. No extra network calls. No prompts.
+The full-set classification runs in a single pass over the
+in-memory list assembled in Step 1 — no pagination, no chunking.
-The output is a list of `(pr, classification, action, reason)`
-tuples that the interaction loop then groups in Step 3. See
+The output is a single list of `(pr, classification, action,
+reason)` tuples covering the entire queue, which the
+interaction loop then groups in Step 3. See
[`rationale.md`](rationale.md) only when a decision needs prose
context — borderline PR, contested rule, or when editing the
table itself.
@@ -445,8 +479,14 @@ table itself.
## Step 3 — Group and present
Using [`interaction-loop.md`](interaction-loop.md), group the
-tuples by `action` and present each group to the maintainer in
-the order:
+tuples produced in Step 2 by `(classification, action)`. Groups
+**span the entire queue**: every passing PR across every page
+goes into a single `mark-ready` group, every CI-failed PR
+across every page goes into a single `draft` group, and so on.
+The maintainer sees one screen per `(classification, action)`
+class regardless of how many GitHub pages it spans.
+
+Present each group to the maintainer in the order:
1. `pending_workflow_approval` — safety-relevant, goes first
2. `deterministic_flag` with action `close` — destructive,
@@ -484,9 +524,11 @@ offer:
without an extra per-PR confirm — those are destructive enough
that batching must still route through a per-PR review.
-While the group is on-screen, prefetch the next group's deeper
-data (failed-job log snippets for the next `draft` group, diff
-previews for the next `approve-workflow` group) in parallel.
+When a PR is pulled out of a group via `[P]NN` or `[E]`, fetch
+the per-PR drill-in data (failed-job log snippets, full diff
+for `[W]`) lazily at that moment. Step 1's full-set fetch
+intentionally omits this deep data — the per-PR cost is paid
+only when the maintainer actually drills in.
---
@@ -509,30 +551,12 @@ window skips the PRs we just handled.
---
-## Step 5 — Paginate and sweep
-
-If the page had `has_next_page=true` and the maintainer hasn't
-quit, advance to the next page. Two cases:
-
-- **Prefetched** (the common case — see
- [Golden rule 4](#golden-rules) and
-
[`interaction-loop.md#pre-classification-and-pre-rendering-of-the-next-page`](interaction-loop.md#pre-classification-and-pre-rendering-of-the-next-page)):
- the next page's PR-list + rollup payload, the
- `(classification, action, reason)` tuples, and the first
- group's pre-rendered screen are already in the session cache
- under `prefetched_pages.<page_num>`. Steps 1 and 2 collapse
- to a cache read; present the first group immediately and
- re-enter Step 3.
-- **Not prefetched** (last page, prefetch skipped per the
- budget rule, or cache miss after invalidation): fall back to
- re-running Steps 1–4 synchronously.
-
-In either branch, before presenting page N+1's first group,
-fire the prefetch for page N+2 in parallel — Golden rule 4
-applies to every page boundary, not just the first.
-
-When the maintainer has worked through every interactive group
-(or supplied `triage stale`), run the stale sweeps from
+## Step 5 — Stale sweeps
+
+Pagination is finished — Step 1 already walked every page of
+the main candidate set. After the maintainer has worked
+through every interactive group from Step 3 (or supplied
+`triage stale`), run the stale sweeps from
[`stale-sweeps.md`](stale-sweeps.md):
- close stale drafts older than 7 days with no author reply
@@ -550,11 +574,20 @@ When the maintainer has worked through every interactive
group
days, propose plain `ping` to escalate. See
[`stale-sweeps.md#sweep-5--stale-author-confirm-request`](stale-sweeps.md#sweep-5--stale-author-confirm-request).
-Each sweep emits its own group in the interaction loop (Step 3),
-so the maintainer still confirms before any PR is touched.
-Sweep 4 issues its own paged search (the default search
-excludes labeled PRs) — see
-[`fetch-and-batch.md#search-query-construction`](fetch-and-batch.md#search-query-construction).
+Each sweep that needs a different candidate set than the main
+fetch (e.g. Sweep 4, which queries `label:"ready for
+maintainer review"` instead of excluding it) runs its own
+full-pagination loop using the same pattern as Step 1 — walk
+every page until `hasNextPage=false`, accumulate into a single
+list, classify in one pass, then emit a single group via the
+interaction loop. The maintainer confirms the group before any
+PR is touched. Per-sweep candidate sets are typically small
+(stale candidates concentrate around the back of the queue),
+so the additional fetch loops cost little.
+
+See
+[`fetch-and-batch.md#search-query-construction`](fetch-and-batch.md#search-query-construction)
+for how each sweep's selector translates into a search query.
---
@@ -568,8 +601,8 @@ On exit, print a one-screen summary:
suspicious flags)
- counts of PRs skipped and per-reason breakdown (already
triaged, inside grace window, bot, collaborator)
-- counts of PRs left pending (reached quit, didn't finish the
- page)
+- counts of PRs left pending (classified in Step 2 but the
+ group containing them wasn't decided before quit)
- total wall-clock time and PRs-per-minute velocity
The on-screen summary is for the maintainer's quick read at
@@ -659,19 +692,32 @@ When in doubt about the selector, ask the maintainer
## Budget discipline
This skill's practical GraphQL budget per full-sweep session
-(one page of 20 PRs, everything acted on) is:
-
-- 1 query for PR list + rollup enrichment
-- 1 query for "already triaged" classification
-- 0–5 queries for stale-sweep subclassification
+(every page of the candidate set fetched, everything acted on)
+is:
+
+- 1 PR-list + rollup query per page in Step 1 (default
+ `$batchSize=20`, so a 200-PR queue is ~10 page queries)
+- 1 REST call for the `action_required` workflow-run index
+ (paginated, typically ≤3 pages)
+- 1 query for the recent main-branch failures set
+ (cached for 4h)
+- 0–5 additional fetch loops for stale-sweep candidate sets
+ (each loop is itself paginated)
- 1 mutation per action taken (draft / close / comment / label /
rerun / workflow-approve)
-- 1 query for next-page prefetch (runs in parallel)
-
-That comes to roughly 3–5 queries + N mutations per page of 20
-PRs. A normal morning sweep (1–3 pages, 20-ish actions) stays
-well under 100 GraphQL points — a tiny fraction of the 5000/h
-budget. If a run starts approaching the limit, the skill is
-mis-batching (most likely: an individual `gh pr view` per PR
-instead of an aliased batch query) — stop and fix the call
-pattern, do not work around it with rate-limit sleeps.
+
+Per page the cost is `cost=3` against the rate-limit budget
+(see [`fetch-and-batch.md#batch-size`](fetch-and-batch.md#batch-size)),
+so a 200-PR full-sweep is ~30 points of fetch + N mutations —
+well under the 5000/h budget. If a run starts approaching the
+limit, the skill is mis-batching (most likely: an individual
+`gh pr view` per PR instead of an aliased batch query) — stop
+and fix the call pattern, do not work around it with
+rate-limit sleeps.
+
+The fetch loop in Step 1 runs serially page-by-page. Do not
+fire pages in parallel hoping to win wall-clock time — GitHub
+rate-limits per-account and parallel page fetches just push
+you to the throttling boundary faster. The maintainer can
+step away during the fetch; serial pagination uses the budget
+predictably.
diff --git a/.claude/skills/pr-management-triage/fetch-and-batch.md
b/.claude/skills/pr-management-triage/fetch-and-batch.md
index bfacba8..03a0627 100644
--- a/.claude/skills/pr-management-triage/fetch-and-batch.md
+++ b/.claude/skills/pr-management-triage/fetch-and-batch.md
@@ -6,8 +6,9 @@
Every rate-limit problem this skill is going to have, if it has
one, will come from making too many small queries. This file
documents the single batched GraphQL shape the skill uses for
-every page of PRs, the prefetch plan for the *next* page, and the
-session-scoped cache that prevents re-fetching across groups.
+every page of PRs, the **full-pagination loop** that walks the
+entire candidate set before classification runs, and the
+session-scoped cache that holds the fetched set.
---
@@ -184,6 +185,81 @@ expand them. Capture the JSON output and parse it with
`jq` or
---
+## Full-pagination loop
+
+Step 1 of [`SKILL.md`](SKILL.md) walks **every page** of the
+candidate set before classification or presentation. Run the
+loop serially until `pageInfo.hasNextPage` is false, then hand
+the accumulated list to Step 2.
+
+```text
+all_prs = []
+cursor = null
+page = 1
+loop:
+ result = graphql_call(searchQuery, batchSize=20, cursor=cursor)
+ all_prs.extend(result.search.nodes)
+ print(f"page {page}: fetched {len(result.search.nodes)} PRs (total:
{len(all_prs)})")
+ if not result.search.pageInfo.hasNextPage:
+ break
+ cursor = result.search.pageInfo.endCursor
+ page += 1
+return all_prs
+```
+
+Key invariants:
+
+- **Serial, not parallel.** Pages are issued one after the
+ other. Parallel page fetches do not reliably win wall-clock
+ time (GitHub serialises against the per-account rate-limit
+ budget anyway) and they make rate-limit failures harder to
+ reason about. The maintainer steps away during the fetch;
+ predictability beats sub-second optimisation.
+- **One progress line per page.** Emit a `page N: fetched M
+ PRs (total: K)` line per iteration so the maintainer can see
+ the loop is advancing if they glance over.
+- **No classification, no prompts, no presentation inside the
+ loop.** Classification runs once in Step 2 over the entire
+ `all_prs` set. Presentation runs once in Step 3 over the
+ classified set. The fetch loop is uninterrupted.
+- **Skip prefetch heuristics.** With pages fetched serially up
+ front, there is no per-page maintainer wait to overlap with
+ a next-page prefetch — the old prefetch-during-interaction
+ pattern was an optimisation for the per-page model that no
+ longer applies.
+- **Hard cap.** If a run somehow goes past 50 pages, stop and
+ surface to the maintainer. The default selector is
+ `is:pr is:open` against `<upstream>`; 50 × 20 = 1000 PRs
+ comfortably covers any sane queue, and exceeding that signals
+ either a mis-targeted selector or an unhealthy backlog
+ worth flagging.
+
+Stash the accumulated `all_prs` in the session cache as
+`fetched_prs` (see [`#session-cache`](#session-cache)) before
+returning to Step 2 — a re-invocation within the same session
+window can reuse the set if the maintainer wants to re-run with
+a different action override.
+
+### Per-page accounting
+
+GitHub returns `rateLimit` data alongside each search result.
+Log it on each page so a rate-limit failure surfaces its
+proximate cause:
+
+```graphql
+rateLimit {
+ cost
+ remaining
+ resetAt
+}
+```
+
+A typical page costs `cost=3`. If `remaining` drops below 100
+mid-loop, surface a warning and ask the maintainer whether to
+continue or pause; do not silently sleep and retry.
+
+---
+
## Search-query construction
Translate the selector into the GitHub search query:
@@ -323,61 +399,21 @@ session cache.
---
-## Prefetch plan
+## Lazy per-PR fetches (drill-in only)
-The interaction loop (see [`interaction-loop.md`](interaction-loop.md))
-presents one group of PRs at a time. *While* a group is on
-screen — i.e. inside the same tool-call turn as the
-presentation — fire the next enrichment call in parallel so the
-next group is already warm by the time the maintainer decides.
+The full-pagination loop in [`#full-pagination-loop`](#full-pagination-loop)
+deliberately omits per-PR deep data — failed-job log
+snippets, full diffs, author profile rollups — because the
+typical pass through the interaction loop never asks for any
+of it. Defer those fetches to the moment the maintainer pulls
+a PR out of a group via `[P]NN`, `[E]`, or `[W]` on the
+per-PR drill-in screen.
-Concretely, two parallel GraphQL calls per interaction turn:
-
-| Call A (current turn's result display) | Call B (prefetched) |
-|---|---|
-| PR-list + rollup query for **current** page | PR-list + rollup query for
**next** page (using `endCursor` from page 1) |
-| *or* log-snippet fetch for the current `draft` group | *or* diff preview
fetch for the next `approve-workflow` group |
-
-Parallelism is a must, not an option — serialising the prefetch
-behind the maintainer's decision doubles end-to-end latency for
-every group. Use the tool harness's parallel tool call feature
-(issue two Bash tool calls in the same response).
-
-If the maintainer is likely to quit (this is the last group),
-skip the prefetch — it's wasted budget. Heuristic: if
-`has_next_page` is false and there's no larger pending work,
-don't prefetch.
-
-### Pre-classify on arrival
-
-Once Call B's result lands (the next-page PR-list payload), do
-**not** wait for the maintainer to finish the current page
-before running classification on it. Classification is a pure
-function over the fetched data (no further network — see
-[`classify-and-act.md`](classify-and-act.md)), so the moment
-the prefetched data arrives:
-
-1. Apply pre-filters F1–F5b to drop collaborator PRs, bot
- accounts, fresh drafts, already-marked-ready PRs without
- regression, and PRs with an active maintainer conversation.
-2. Evaluate the decision table top-to-bottom for each
- surviving PR.
-3. Apply the Real-CI guard for `passing` rows.
-4. Group the resulting `(pr, classification, action, reason)`
- tuples by `(classification, action)` in the order from
- [`interaction-loop.md#group-ordering`](interaction-loop.md#group-ordering).
-5. Pre-render the first group's presentation screen from the
- [group-presentation template](interaction-loop.md#group-presentation).
-6. Stash the bundle under `prefetched_pages.<page_num>` in the
- session cache (schema below).
-
-The cost is zero GraphQL points; the saving is the entire
-classification "think time" between page N's last group and
-page N+1's first group. Step 5 of [`SKILL.md`](SKILL.md) reads
-the prefetched bundle and presents page N+1's first group
-immediately, with no fresh fetch or re-classification. See
-[`interaction-loop.md#pre-classification-and-pre-rendering-of-the-next-page`](interaction-loop.md#pre-classification-and-pre-rendering-of-the-next-page)
-for the full sequence diagram and invalidation rules.
+The per-PR drill-in is the only place this skill makes a
+per-PR call. See
[`#optional-failed-job-log-snippets-deferred`](#optional-failed-job-log-snippets-deferred)
+for the failed-job log recipe and
+[`interaction-loop.md#individual-drill-in-presentation`](interaction-loop.md#individual-drill-in-presentation)
+for the drill-in shape.
---
@@ -408,22 +444,17 @@ anything that isn't needed. Schema:
"fetched_at": "2026-04-22T08:00:00Z",
"failing_check_names": ["Helm tests (1.29)", "..."]
},
- "prefetched_pages": {
- "2": {
- "end_cursor": "Y3Vyc29yOnYyOpHOA...",
- "fetched_at": "2026-04-22T09:18:14Z",
- "has_next_page": true,
- "groups": [
- {
- "classification": "deterministic_flag",
- "action": "draft",
- "prs": [
- {"number": 65401, "head_sha": "abc123...", "reason": "CI failed +
2 unresolved threads past grace window"}
- ],
- "rendered_screen": "─────────────...\nGroup 1 of 5 —
deterministic_flag → draft — 3 PRs\n..."
- }
- ]
- }
+ "fetched_prs": {
+ "selector": "is:pr is:open repo:<upstream> sort:updated-asc",
+ "fetched_at": "2026-04-22T09:18:14Z",
+ "pages_fetched": 11,
+ "total_prs": 217,
+ "all_prs": [
+ {"number": 65401, "head_sha": "abc123...", "raw": "<full PR record from
Step 1>"}
+ ],
+ "classified": [
+ {"number": 65401, "classification": "deterministic_flag", "action":
"draft", "reason": "CI failed + 2 unresolved threads past grace window"}
+ ]
}
}
```
@@ -436,16 +467,16 @@ anything that isn't needed. Schema:
- The `recent_main_failures` block is valid for 4 hours; after
that, re-fetch via the canary/main-branch failure query
(see below).
-- A `prefetched_pages.<n>` bundle's PR tuples are validated
- against live head SHAs by the optimistic-lock re-check at
- execute time (see
+- The `fetched_prs` bundle is the output of one full Step 1
+ pass; its `classified` list is validated against live head
+ SHAs by the optimistic-lock re-check at execute time (see
[`interaction-loop.md#optimistic-lock-re-check-before-mutate`](interaction-loop.md#optimistic-lock-re-check-before-mutate)).
- A per-PR mismatch drops that PR's tuple from the bundle
- and triggers an inline re-classification; the rest of the
- bundle survives. Discard the entire bundle on session exit —
- do not persist across sessions.
+ A per-PR head-SHA mismatch triggers an inline re-fetch +
+ re-classify of just that PR; the rest of the bundle
+ survives. Discard the entire bundle on session exit — do not
+ persist across sessions.
- The whole cache is discardable — losing it only costs one
- extra enrichment round.
+ extra full-pagination round.
### Writing discipline
@@ -510,9 +541,16 @@ resulting set in the session cache as
`recent_main_failures`.
other lazy structure that might hide the call count. All PR
fetches happen in a small set of named calls — count them and
keep the count down.
-- **Do not prefetch the *next-next* page** just because you can.
- One page ahead is the right depth; two is wasted budget for
- a session the maintainer will usually end within 1–3 pages.
+- **Do not parallelise the page loop.** Pages are issued
+ serially in [`#full-pagination-loop`](#full-pagination-loop).
+ Parallel page fetches do not win wall-clock time against
+ GitHub's per-account rate-limit and they make failures
+ harder to reason about.
+- **Do not interleave fetching with classification or
+ presentation.** The full-pagination loop runs to completion
+ first; classification runs once over the whole set;
+ presentation comes last. Mixing the phases recreates the
+ per-page context-switching this design was built to remove.
- **Do not sleep after rate-limit errors.** If a query 403s on
`X-RateLimit-Remaining: 0`, stop immediately, surface the
budget info to the maintainer, and let them decide whether to
diff --git a/.claude/skills/pr-management-triage/interaction-loop.md
b/.claude/skills/pr-management-triage/interaction-loop.md
index 1fd8946..79b633b 100644
--- a/.claude/skills/pr-management-triage/interaction-loop.md
+++ b/.claude/skills/pr-management-triage/interaction-loop.md
@@ -13,13 +13,20 @@ into maintainer velocity.
The core idea:
> Present **groups of PRs with the same suggested action**
-> together. The maintainer bulk-confirms the group, pulls
-> individual PRs out for closer inspection, or skips the group.
+> together, where each group **spans the entire queue** (every
+> page already fetched in Step 1). The maintainer bulk-confirms
+> the group, pulls individual PRs out for closer inspection, or
+> skips the group.
The underlying `breeze pr auto-triage` tool presented PRs one-
at-a-time (sequential mode) or as a TUI list with per-PR keys.
This skill lands between those: sequential per-group, with a
-drill-in for the PRs the maintainer wants to eyeball.
+drill-in for the PRs the maintainer wants to eyeball — except
+that each group is **the entire set** of PRs with a given
+`(classification, action)` across the queue, not a per-page
+slice. A 200-PR full-sweep with 30 passing PRs presents the
+maintainer with one `mark-ready` group of 30, not six pages of
+five.
---
@@ -90,26 +97,36 @@ For each group, present one screen of information. The goal
is
a decision in under 15 seconds when the suggestion looks right,
or a natural path to per-PR inspection when it doesn't.
+Groups can be **large** — a full-queue `mark-ready` group on
+an active project might carry 20–40 PRs. Render every PR in
+the group (one line per PR) so the maintainer can scan for
+outliers; do not silently truncate. If the group exceeds a
+terminal-friendly threshold (say, 60 PRs), insert a short
+"… N more PRs in this group, showing first 60 …" line at the
+bottom of the visible block and require `[E]` to walk past it
+(no surprise hidden state behind `[A]ll`).
+
```text
─────────────────────────────────────────────────────
-Group 3 of 8 — deterministic_flag → draft — 5 PRs
+Group 3 of 8 — deterministic_flag → draft — 27 PRs
-Common reason: all have failing CI + unresolved review threads
-past the grace window.
+Common reason: failing CI + unresolved review threads past
+the grace window. Spans the entire fetched queue.
#65401 Add new provider foo @alice CI✗ thrd:2 +3/-1 1d
#65417 Fix parsing of baz @bob CI✗ thrd:1 +12/-4 3h
#65422 Change caching behavior @carol CI✗ thrd:3 +8/-2 2d
#65460 Typo fix in helm chart @dave CI✗ thrd:1 +1/-1 6h
#65471 Add support for new db dialect @eve CI✗ thrd:4 +230/-60 4d
+ … (22 more — full list visible by scrolling up; nothing hidden behind [A])
Suggested action: convert all to draft with violations comment.
- [A]ll — apply to all 5
+ [A]ll — apply to all 27
[E]ach — walk one-by-one
[P]NN — pull NN out for inspection (e.g. P65471)
- [O]verride — use a different action for all 5 (comment / close / skip)
- [S]kip — leave all 5 alone this sweep
+ [O]verride — use a different action for all 27 (comment / close / skip)
+ [S]kip — leave all 27 alone this sweep
[Q]uit — exit session
```
@@ -284,83 +301,28 @@ Batch the re-check queries for `[A]` actions — one aliased
---
-## Prefetch plan
+## Lazy drill-in fetches
-Whenever a group is presented to the maintainer (an
-information-only turn), fire **in the same turn** any follow-up
-fetches the next decision will need. Parallel tool calls make
-this free — the network round-trip overlaps with the
-maintainer's reading time.
+The full-set fetch in
+[`SKILL.md#step-1--resolve-the-selector-and-fetch-every-page`](SKILL.md#step-1--resolve-the-selector-and-fetch-every-page)
+deliberately omits per-PR deep data (failed-job log snippets,
+full diffs, author profile rollups). Defer those to the moment
+the maintainer pulls a PR out of a group via `[P]NN`, `[E]`, or
+`[W]` on the drill-in screen.
-Concrete prefetches:
+When a per-PR drill-in fires, fetch in the same tool-call turn:
-| Currently showing | Prefetch |
+| Drill-in context | Fetch |
|---|---|
-| Any group | Next page's PR-list + rollup query (if `has_next_page` and
`page_num < max_num / 50`), then **pre-classify and pre-render the first
group** of that page — see
[`#pre-classification-and-pre-rendering-of-the-next-page`](#pre-classification-and-pre-rendering-of-the-next-page)
below |
-| `pending_workflow_approval` group | `gh pr diff <N>` for the first 2 PRs in
the group |
-| `deterministic_flag → draft/comment` group, one PR at a time | Failed-job
log snippets for the current PR and the next PR in the queue |
-| `close` group (per-PR) | Author's full open-PR list (for the "you have N
flagged PRs" line in the body) |
-| Any per-PR drill-in | Author profile (account age, repo merge rate) if not
already cached |
-
-Do **not** prefetch:
-
-- Data for groups the maintainer may not reach this session
- (page 3 when they're on page 1).
-- Full diffs for non-workflow-approval PRs unless the
- maintainer actually presses `[W]`.
-- Author profiles for PRs in stale-sweep groups — they're being
- closed or drafted with minimal per-PR custom data, so the
- profile costs more than it saves.
-
-When a prefetched result lands before the maintainer acts, store
-it in the session cache; when the maintainer eventually triggers
-the drill-in, it's instant.
-
-### Pre-classification and pre-rendering of the next page
-
-The next-page prefetch is most valuable when it carries the page
-all the way through to a presentable form, not just to raw
-GraphQL nodes. Classification is a pure function over the
-fetched data (no further GraphQL, no prompts — see
-[`classify-and-act.md`](classify-and-act.md)), and so is the
-group-screen template ([`#group-presentation`](#group-presentation));
-both can run eagerly the moment the prefetch resolves. Pipeline:
-
-1. **Turn N** (presenting page N's current group): fire the
- page-(N+1) GraphQL call in parallel with the group screen,
- as the table above documents.
-2. **Turn N+1** (or whenever the prefetch resolves, before the
- maintainer's decision lands): apply pre-filters F1–F5b, walk
- the decision table top-to-bottom, run the Real-CI guard, and
- group the resulting `(pr, classification, action, reason)`
- tuples — exactly as Steps 2 and 3 of [`SKILL.md`](SKILL.md)
- would have done synchronously at page-turn time. Build the
- first group's screen text from the
- [group-presentation template](#group-presentation). Stash
- the bundle under `prefetched_pages.<page_num>` in the
- session cache — see
- [`fetch-and-batch.md#session-cache`](fetch-and-batch.md#session-cache)
- for the schema.
-3. **Page-turn moment** (current page exhausted): instead of
- re-fetching and re-classifying, read the prefetched bundle
- and present the first group immediately. The maintainer
- sees zero classification latency at the page boundary. See
-
[`SKILL.md#step-5--paginate-and-sweep`](SKILL.md#step-5--paginate-and-sweep).
-
-Invalidation: if the optimistic-lock re-check at execute time
-(see
[`#optimistic-lock-re-check-before-mutate`](#optimistic-lock-re-check-before-mutate))
-finds a head-SHA mismatch for a PR in the prefetched bundle,
-drop that PR's tuple and re-classify it inline. The bundle as
-a whole survives — a single stale PR does not poison the page.
-
-If the maintainer quits (`[Q]`) on the current page, the
-prefetched bundle is discarded on session exit. The work was
-wasted, but the GraphQL cost was the same one query that would
-have happened at the page-turn anyway — the downside is
-small. Skip the pre-classification (not just the prefetch) only
-when the prefetch itself was skipped per the "last page or no
-larger pending work" heuristic in
-[`fetch-and-batch.md#prefetch-plan`](fetch-and-batch.md#prefetch-plan).
+| `pending_workflow_approval` group, any PR | `gh pr diff <N>` for the
workflow-approval safety review |
+| `deterministic_flag → draft/comment` PR | Failed-job log snippets per
[`fetch-and-batch.md#optional-failed-job-log-snippets-deferred`](fetch-and-batch.md#optional-failed-job-log-snippets-deferred)
|
+| `close` group (per-PR confirm) | Author's full open-PR list (for the "you
have N flagged PRs" line in the body) |
+| Any per-PR drill-in pressing `[W]` | Full diff via `gh pr diff <N>`, cached
in the session by head SHA |
+
+Cache each lazy fetch in the session cache keyed by `(pr_number,
+head_sha)` — re-entering the same drill-in within a session
+is a cache hit. There is no prefetching across groups; the
+full-set fetch in Step 1 has already paid the upfront cost.
---
@@ -406,7 +368,7 @@ PRs acted on: 22
- author-confirm requests: 1
- pings posted: 2
PRs skipped: 15 (12 already triaged / inside grace, 2 bot, 1
collaborator)
-PRs left pending: 10 (reached [Q] before classifying)
+PRs left pending: 10 (classified but [Q] hit before the group was decided)
Throughput: 22 actions / 25m = 53 PRs/h
```
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index 9e89dd4..0502402 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -380,6 +380,9 @@ below, annotated.
}
},
"permissions": {
+ "allow": [
+ "Bash(gh api graphql *)" // read-only GraphQL fetches
(PR-triage paginated fetch loop, similar bulk reads); MORE SPECIFIC than the
`-F`/`-f` ask rules below, so it short-circuits them. Mutations via `gh api
graphql -F query='mutation {...}'` slip through this rule and are not prompted
— accept this trade-off because the skills in this framework do not route
mutations through graphql (REST + explicit `-X`/`--method` is the mutation
path).
+ ],
"deny": [
"Read(~/.aws/**)", "Read(~/.ssh/**)", "Read(~/.netrc)",
"Read(~/.docker/**)", "Read(~/.kube/**)",
@@ -399,7 +402,7 @@ below, annotated.
"Bash(gh issue close *)", "Bash(gh issue comment *)",
"Bash(gh release create *)",
"Bash(gh api * -X *)", // any non-default-method API
call
- "Bash(gh api * -f *)", "Bash(gh api * -F *)" // any payload-bearing API
call
+ "Bash(gh api * -f *)", "Bash(gh api * -F *)" // any payload-bearing API
call — narrowed by the `gh api graphql *` allow above for the GraphQL read path
]
}
}
diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json
index bf81703..592b7b9 100644
--- a/tools/sandbox-lint/expected.json
+++ b/tools/sandbox-lint/expected.json
@@ -44,6 +44,9 @@
}
},
"permissions": {
+ "allow": [
+ "Bash(gh api graphql *)"
+ ],
"deny": [
"Read(~/.aws/**)",
"Read(~/.ssh/**)",