|
| 1 | +# Review Multiple Pull Requests in ABCA |
| 2 | + |
| 3 | +## Persona |
| 4 | + |
| 5 | +Hold the same bar as [`review_pr`](./review_pr.md): a **Principal AWS Solutions Architect** who |
| 6 | +is direct, specific, justifies every concern with a concrete risk and a `file:line`, distinguishes |
| 7 | +blocking issues from nits, and never rubber-stamps. This command orchestrates that single-PR |
| 8 | +review across a *set* of PRs — it does **not** restate the review process. Each PR is reviewed by |
| 9 | +the full `review_pr` workflow, so the review bar stays single-sourced. |
| 10 | + |
| 11 | +This is **ABCA (Autonomous Background Coding Agents on AWS)** — a self-hosted platform for |
| 12 | +background coding agents. Bounded blast radius applies to the *reviewer* too: this command can |
| 13 | +fan out many concurrent sub-reviews, so it is explicit about scope, cost, and what it skips. |
| 14 | + |
| 15 | +**How the fan-out runs — and why it keeps the caller's context fresh.** Stage 1 (scouting the |
| 16 | +work-list) runs inline in the main context; it is small and cheap. Stage 2 (the per-PR reviews) |
| 17 | +runs as a **`Workflow`** — a deterministic multi-agent orchestration — *not* as prose-directed |
| 18 | +subagent dispatch. Each PR's heavy work (a large diff, the three nested `review_pr` review agents, |
| 19 | +the full review body) is confined to a Workflow-spawned subagent's context; only a **small |
| 20 | +structured result per PR** (number, verdict, one-line rationale, review URL) returns to the caller. |
| 21 | +Making the fan-out a `Workflow` means that isolation is a property of the control flow, not |
| 22 | +something an executor must remember to do — so a large queue can't blow the main window on diff |
| 23 | +bodies and agent transcripts. (Caveat: per-PR *summaries* still accrete linearly in the caller, so |
| 24 | +"fresh" ≠ "constant" — a 200-PR run still returns 200 rows.) |
| 25 | + |
| 26 | +## Execution context — interactive operator only |
| 27 | + |
| 28 | +**This is an interactive Claude Code / Cursor command, run by a human operator. It is NOT — and |
| 29 | +cannot be — executed by ABCA's headless agent runtime.** The distinction matters because Stage 2's |
| 30 | +core tool, `Workflow`, is unavailable to headless tasks by construction: |
| 31 | + |
| 32 | +- The runtime resolves its SDK tool surface from an **allowlist**, not the on-disk config: |
| 33 | + `agent/src/runner.py` `_resolve_allowed_tools()` returns the workflow's `agent_config.allowed_tools` |
| 34 | + or falls back to `_FULL_TOOL_SURFACE = ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebFetch"]` |
| 35 | + (`read_only` lanes drop `Write`/`Edit` on top). `Workflow`, `Task`, and `Agent` are **not in that |
| 36 | + allowlist**, so a headless task physically cannot spawn a `Workflow` — the incident-driven |
| 37 | + hardening against a repo-less task launching a detached background `Workflow` and finalizing on a |
| 38 | + placeholder. Interactive Claude Code / Cursor sessions are not tool-gated this way, so `Workflow` |
| 39 | + is available there — which is where this command runs. |
| 40 | +- The runtime does load project `.claude/` config (`ClaudeAgentOptions(setting_sources=["project"])` |
| 41 | + in `runner.py`), so this command's stub **is** discoverable to a task that clones this repo. That |
| 42 | + is precisely why the allowlist — not mere obscurity — is the guarantee: even though the file is on |
| 43 | + disk, the tool it calls is off-surface. |
| 44 | +- The read-only review workflow (`coding/pr-review-v1`) cannot be steered into `/review_prs`: it is |
| 45 | + bound to the `pr_review` system prompt (`agent/src/workflow/loader.py`) and drives the agent |
| 46 | + through that fixed prompt over the allowlisted tool surface — there is no "invoke a slash command" |
| 47 | + path, and `Workflow` is off-surface regardless. |
| 48 | + |
| 49 | +In short: operators run `/review_prs`; the ABCA runtime never does, and structurally cannot. |
| 50 | + |
| 51 | +## Arguments |
| 52 | + |
| 53 | +Optional. |
| 54 | + |
| 55 | +- **No arguments** — review every PR that currently requests your review (the default query in |
| 56 | + Stage 1). |
| 57 | +- **Explicit list** — e.g. `#616 #612 590` — review exactly those PRs, skipping Stage-1 derivation. |
| 58 | +- **Natural-language filter** — e.g. "all open non-fork PRs with green CI", "everything P1 and |
| 59 | + above requesting my review" — translate to the appropriate `gh` search and gates in Stage 1. |
| 60 | + |
| 61 | +## Stage 1: Build the work-list |
| 62 | + |
| 63 | +Derive the candidate PRs. The default set is *ready-to-review, not mine, not yet approved by me, |
| 64 | +requesting my review, excluding forks*: |
| 65 | + |
| 66 | +```sh |
| 67 | +gh pr list --repo <owner>/<repo> \ |
| 68 | + --search "is:open is:pr draft:false -author:@me -review:approved review-requested:@me" \ |
| 69 | + --json number,isCrossRepository,headRepositoryOwner \ |
| 70 | + --jq '.[] | select(.isCrossRepository == false) | .number' |
| 71 | +``` |
| 72 | + |
| 73 | +- **Fork exclusion is client-side.** GitHub's issue/PR search has **no fork qualifier** — `is:fork` |
| 74 | + is a *repository*-search qualifier and is silently ignored in a PR search (it returns a superset, |
| 75 | + not an error). Filter on `isCrossRepository == false` (a PR whose head branch lives in a fork is |
| 76 | + cross-repository); `headRepositoryOwner.login` (fetched above) tells you *which* fork — surface it |
| 77 | + when announcing a dropped cross-repo PR (e.g. `#42 — dropped: fork PR from acme-labs`). |
| 78 | +- **Optional CI-green gate (only when the user asks for "green" / "passing" / "CI clean").** Keep a |
| 79 | + PR iff its checks are all terminal **and** all passed — i.e. `gh pr checks <n>` exits `0`. Do |
| 80 | + **not** pass `--json` to `gh pr checks` in the gate: with `--json` it always exits `0` and the |
| 81 | + exit code stops meaning anything (use `--json state,bucket` only for *reporting* the state). The |
| 82 | + gate is opt-in, not the default: a red or still-running PR is often exactly the one that needs a |
| 83 | + "request changes", so gating it out by default would hide the work. |
| 84 | +- **Announce what you drop.** For every PR removed by the CI gate (or any other filter), say so and |
| 85 | + why. Silent truncation reads as "reviewed everything" when it wasn't. |
| 86 | +- **Empty list** — if nothing matches, say so and stop. Never invent PR numbers. |
| 87 | +- **Transient by design** — submitting a review clears `review-requested:@me`, so re-running the |
| 88 | + default query later returns a *different* set. That is correct for a worklist; do not treat it as |
| 89 | + a bug or try to re-review already-reviewed PRs. |
| 90 | + |
| 91 | +Present the resolved list (with the reason any candidate was dropped) before fanning out. |
| 92 | + |
| 93 | +## Stage 2: Fan out via `Workflow` — one full `review_pr` per PR |
| 94 | + |
| 95 | +Run the per-PR reviews as a **`Workflow`**, not as inline subagent dispatch. The `Workflow` tool |
| 96 | +requires explicit opt-in — reaching for it here is warranted because the user invoked a batch |
| 97 | +review, and it is what keeps the diffs and nested-agent transcripts out of the caller's context |
| 98 | +(see the Persona note). For a **single** PR, skip the Workflow and just run `review_pr` directly — |
| 99 | +orchestration overhead isn't worth it for `N == 1`. |
| 100 | + |
| 101 | +**Pre-stage shared git state ONCE, inline, before invoking the Workflow** — worktree/branch |
| 102 | +creation mutates shared `.git` state and must not race across concurrent agents: |
| 103 | + |
| 104 | +1. `git fetch origin main` (single fetch; reason about base freshness against the current tip). |
| 105 | +2. Per PR: fetch its head with a **forced** refspec — `git fetch origin +pull/<n>/head:pr<n>head` |
| 106 | + (the leading `+` overwrites an existing `pr<n>head`, so a re-run after an interrupted or |
| 107 | + uncleaned run doesn't fail on the stale local branch). Add a worktree on that ref, dump |
| 108 | + `gh pr diff <n>` to a scratch file, and capture the head SHA (`gh pr view <n> --json |
| 109 | + headRefOid`) and base branch (`--json baseRefName`). Use a run-scoped scratch dir (e.g. a |
| 110 | + per-invocation temp path) so concurrent or repeated runs don't collide on worktree paths. |
| 111 | + |
| 112 | +Assemble one descriptor per PR — `{ number, worktreePath, diffPath, headSha, baseRef }` — and pass |
| 113 | +the **array** as the Workflow's `args`. Then invoke `Workflow` with a self-contained script that: |
| 114 | + |
| 115 | +- reads the descriptors from `args` and fans out **one agent per PR concurrently** (`parallel()` |
| 116 | + over the descriptors, or `pipeline()` if you add a downstream stage); the tool caps real |
| 117 | + concurrency, so pass all PRs — they queue and drain automatically. |
| 118 | +- has each agent execute the full [`review_pr`](./review_pr.md) workflow for its PR and **submit** |
| 119 | + the review (inline suggestions + an Approve / Comment / Request-changes decision) via the Stage-3 |
| 120 | + mechanism. The agent prompt must include that PR's `worktreePath`, `diffPath`, `headSha` (the |
| 121 | + review `commit_id`), and `baseRef` — and **flag stacked PRs** whose `baseRef != main` (the diff |
| 122 | + is against the base branch, not `main`, and merge order matters). |
| 123 | +- carries the standing `review_pr` governance rule into each agent prompt: the gate is an |
| 124 | + **approved backing issue** (see |
| 125 | + [ADR-003](../../docs/decisions/ADR-003-contribution-governance.md)); a `pr/*` branch name is a |
| 126 | + de-facto-waived nit, **not** a blocker. Each agent must **read existing review threads first** |
| 127 | + and independently verify any prior blocking claim against current code before finalizing. |
| 128 | +- returns a **compact `schema`-validated result per PR** — `{ number, verdict, rationale, |
| 129 | + reviewUrl }` — and nothing larger. The `schema` is what guarantees only the summary crosses back |
| 130 | + to the caller; without it an agent's free-text return could drag a full review body into the main |
| 131 | + context, defeating the freshness goal. The Workflow's return value is the array of these results. |
| 132 | + |
| 133 | +Sketch (illustrative — adapt names/paths): |
| 134 | + |
| 135 | +```js |
| 136 | +export const meta = { |
| 137 | + name: 'review-prs', |
| 138 | + description: 'Review a set of PRs, one review_pr per PR, return compact verdicts', |
| 139 | + phases: [{ title: 'Review' }], |
| 140 | +} |
| 141 | +const RESULT = { type: 'object', required: ['number', 'verdict', 'reviewUrl'], properties: { |
| 142 | + number: { type: 'number' }, |
| 143 | + verdict: { enum: ['APPROVE', 'COMMENT', 'REQUEST_CHANGES'] }, |
| 144 | + rationale: { type: 'string' }, |
| 145 | + reviewUrl: { type: 'string' }, |
| 146 | +} } |
| 147 | +const results = await parallel(args.map((pr) => () => |
| 148 | + agent( |
| 149 | + `Run the /review_pr workflow for PR #${pr.number} and SUBMIT the review with inline ` + |
| 150 | + `suggestions. Worktree: ${pr.worktreePath}. Diff: ${pr.diffPath}. commit_id: ${pr.headSha}. ` + |
| 151 | + `Base: ${pr.baseRef}${pr.baseRef !== 'main' ? ' (STACKED — diff is vs the base branch)' : ''}. ` + |
| 152 | + `Governance: gate is an approved backing issue; a pr/* branch is a waived nit. Read existing ` + |
| 153 | + `review threads first and verify prior blocking claims. Submit via gh api (see review_pr).`, |
| 154 | + { label: `review:#${pr.number}`, phase: 'Review', schema: RESULT }, |
| 155 | + ))) |
| 156 | +return results.filter(Boolean) |
| 157 | +``` |
| 158 | + |
| 159 | +Scale the depth to the request inside each agent: a routine queue is one agent per PR running |
| 160 | +`review_pr` once; "audit these thoroughly" lets each agent invoke the deeper Stage-3 agent fan-out |
| 161 | +that `review_pr` itself prescribes. |
| 162 | + |
| 163 | +## Stage 3: Submit & report |
| 164 | + |
| 165 | +- **Submission mechanism** — the GitHub MCP `pull_request_review_write` tool may lack the required |
| 166 | + token scope (`Resource not accessible by personal access token`). When it does, submit via the |
| 167 | + `gh` CLI instead: |
| 168 | + |
| 169 | + ```sh |
| 170 | + gh api --method POST repos/<owner>/<repo>/pulls/<n>/reviews --input <review>.json |
| 171 | + ``` |
| 172 | + |
| 173 | + where `<review>.json` has `commit_id`, `event` (`APPROVE` | `COMMENT` | `REQUEST_CHANGES`), |
| 174 | + `body`, and `comments[]`. Inline comments must anchor to a line present on the **RIGHT** side of |
| 175 | + the diff, or the call returns `422` — move any such finding into the review body instead. |
| 176 | + Each Workflow agent submits its own review this way from inside its context — the review bodies |
| 177 | + never return to the caller (only the compact `schema` result does). |
| 178 | +- **Clean up — unconditionally.** Remove every per-PR worktree (`git worktree remove --force`) and |
| 179 | + `pr<n>head` branch created in Stage 2, **whether the Workflow succeeded, threw, or the run was |
| 180 | + interrupted** — not only on the happy path. A partial or failed run still leaves worktrees and |
| 181 | + branches in the shared `.git`, so treat teardown as a `finally`, not a trailing step: bounded |
| 182 | + blast radius applies to the reviewer too. (The forced refspec in Stage 2 also lets a later run |
| 183 | + self-heal if a crash skipped teardown entirely.) |
| 184 | +- **Report** — assemble a summary table from the Workflow's returned results: |
| 185 | + **PR · verdict · one-line rationale · review URL**. Include the count reviewed and the count |
| 186 | + (and reasons) dropped in Stage 1. |
| 187 | + |
| 188 | +## Notes |
| 189 | + |
| 190 | +- This command is a thin orchestrator over `review_pr`. It intentionally does not duplicate the |
| 191 | + review stages, agent-invocation requirements, or output structure — refinements to the review bar |
| 192 | + land in [`review_pr`](./review_pr.md) and are inherited here automatically. |
| 193 | +- The Stage-2 fan-out uses the **`Workflow`** tool so context-freshness is enforced by the control |
| 194 | + flow rather than left to an executor following prose. This command *uses* `Workflow`; it does not |
| 195 | + modify orchestration infrastructure. Git pre-staging (worktrees/branches) stays inline before the |
| 196 | + Workflow because it mutates shared `.git` state and must not race across concurrent agents. |
0 commit comments