| name | pr-review |
| description | Review open PRs two ways - default is a per-PR deep review with severity-tagged findings, inline comments, and a verdict; --survey runs a risk-tiered triage digest of what's safe to merge first |
| metadata | {"title":"PR Review","category":"basics","var":"","tags":["dev","community"]} |
${var} — Selects the branch and scopes it.
- Default (no
--survey) → per-PR deep review. ${var} empty reviews every repo in memory/watched-repos.md; ${var}=owner/repo scopes to one repo; ${var}=owner/repo#N scopes to that exact PR.
--survey (alias survey) → risk-tiered triage digest (the former pr-merge). In this branch the remaining tokens follow pr-merge's grammar: pass dry-run to skip notify (article + state still write), pass owner/repo to override the target repo, combine with a space (--survey dry-run owner/repo). Empty target = aeonfun/aeon.
Examples: `` (review every watched repo) · owner/repo (review one repo) · owner/repo#42 (review exactly PR 42) · --survey (triage digest of aeonfun/aeon) · --survey dry-run (refresh digest, no notify) · --survey owner/repo (triage a specific repo).
Shared preamble (every run)
Read memory/MEMORY.md for high-level context. Scan the last ~3 days of memory/logs/ for recent activity and to avoid re-reporting the same signal.
Parse ${var} → branch: split ${var} on whitespace.
- If a
--survey or survey token is present → SURVEY branch (jump to "Survey branch"). Remove that token; the remaining tokens are parsed by the survey branch (dry-run, owner/repo override, unknown → BAD_VAR).
- Otherwise → REVIEW branch (default; continue below). The remaining
${var} is an optional owner/repo or owner/repo#N scope (empty = every watched repo).
The two branches never share mutation logic: the REVIEW branch posts PR comments/reviews via gh; the SURVEY branch writes the digest article + state file and (gated) notifies — neither performs an actual gh pr merge. Dispatch to exactly one branch per run.
REVIEW branch (default) — per-PR deep review
Read memory/MEMORY.md and memory/watched-repos.md.
Read the last 2 days of memory/logs/ to pull the headRefOid of any PR reviewed recently — used for dedup.
If ${var} names owner/repo#N, fetch and review only that exact open PR; do not list or comment on any other PR. If ${var} names owner/repo, review that repo's open PRs. Otherwise review every repo listed in memory/watched-repos.md.
If memory/watched-repos.md is empty or missing (and no owner/repo was passed), log PR_REVIEW_NO_REPOS and end.
What this branch optimizes for
Noise is the documented failure mode of automated PR review. Every finding emitted must be severity-tagged, line-specific, and justified with a one-sentence "why it matters". If there is nothing worth saying, say so in one line and move on.
For each repo
gh pr list -R owner/repo --state open --limit 20 \
--json number,title,author,isDraft,labels,headRefOid,updatedAt
Skip rules
Skip a PR if any of the following hold (record the skip reason for the run summary):
For each remaining PR
-
Fetch context:
gh pr view NUMBER -R owner/repo \
--json title,body,headRefOid,baseRefName,files,additions,deletions
If the body contains Fixes #N or Closes #N, fetch the linked issue for context:
gh issue view N -R owner/repo --json title,body,labels
-
Fetch the diff:
gh pr diff NUMBER -R owner/repo
-
Early-exit for trivial PRs: if the diff is docs-only (.md/.rst/docs/**), lockfile-only, or test-only, skip deep review and post the 1-line ack form in step 6.
-
Review with severity tagging. Every finding must carry exactly one tag:
[CRITICAL] — correctness break, security hole, data loss, API break, regression
[ISSUE] — likely bug, missing edge case, wrong behavior under a realistic input
[NIT] — naming, style, minor cleanup (dropped by default)
Rules:
- Cap at 5 findings total per PR. Drop NITs first, then the lowest-impact ISSUEs.
- Drop all NITs unless there are zero CRITICAL/ISSUE findings and a NIT is genuinely useful.
- Every finding must name
path/to/file:LINE and include a one-sentence "why it matters" — the consequence, not just "this is wrong".
- No praise, no diff restating, no "this PR adds X" summaries.
-
Determine a verdict:
approve-ready — no CRITICAL, no ISSUE
blocked: <one-phrase reason> — at least one CRITICAL
discussion-needed — ISSUE findings but no CRITICAL
-
Post the review. Send both a consolidated summary comment and inline line-specific comments — inline for precision, summary for consumers that parse review bodies.
For each line-specific finding:
gh api repos/owner/repo/pulls/NUMBER/comments \
-f body="[SEVERITY] finding text — why it matters" \
-f path="path/to/file" \
-f commit_id="$HEAD_SHA" \
-F line=LINE_NUMBER \
-f side="RIGHT"
Then the consolidated summary as a review — include the verdict and a bulleted recap of every inline finding (severity + file:line + one-sentence rationale), so downstream body-parsers don't miss them:
gh pr review NUMBER -R owner/repo --comment --body "**Verdict**: <verdict>
<one-line rationale if blocked or discussion-needed; omit if approve-ready>
Findings (mirrored as inline comments):
- [CRITICAL] path/to/file:LINE — why it matters
- [ISSUE] path/to/file:LINE — why it matters
"
If there are no CRITICAL/ISSUE findings, skip inline comments and post the
approve-ready line followed by its receipt:
```text
**Verdict**: approve-ready — no blockers.
<!-- aeon-review:{"schema":1,"target":"owner/repo#N","sha":"<full-headRefOid>","verdict":"approve-ready","critical":0,"issues":0} -->
Use exactly one receipt in every completed review, including trivial-PR
early-exits. The target and full headRefOid must match the PR fetched in
step 1, and the counts must equal the [CRITICAL] and [ISSUE] bullets in
the consolidated body. This is routing input for a later chain step. Do not
emit it only when the PR itself was skipped (draft, bot, or duplicate SHA),
and keep the human-readable blocked reason outside the receipt.
For trivial-PR early-exits (step 3), post the category line — Docs-only change — no blockers. / Dependency-bump — no review needed. / Test-only change — no production code touched. — followed by the same approve-ready receipt from above. A trivial PR is reviewed, not skipped; omitting its receipt makes the dev-loop fail closed because it cannot distinguish an approval from an incomplete review.
Fallback: if inline-comment creation fails (missing permissions, commit_id mismatch), consolidate all findings into the review body, preserving the severity tags and file:line refs. Do not silently drop findings.
Notify and log (REVIEW branch)
The captured final output must include the same **Verdict** line and exact
<!-- aeon-review:{...} --> receipt posted to GitHub for every reviewed PR.
Do not replace them with a generic "review posted" summary. The chain consumes
the receipt, and the notification gate uses the verdict line as its signal.
Send one combined message per run via ./notify:
*PR Review — ${today}*
Reviewed N, skipped K (drafts: x, bots: y, dup-SHA: z, bot-reviewed-recently: w).
- owner/repo#123: [verdict] — N critical, M issues
Telegram summary. When the run was scoped to a single repo (${var}=owner/repo), send a Telegram summary of the review with ./notify -f review.md so the operator sees the verdict at a glance. Skip it on an all-repos run.
If every PR was skipped, do not notify — just log.
Log to memory/logs/${today}.md under the shared ### pr-review heading with a mode discriminator:
### pr-review
- **Mode**: review (per-PR deep review)
- owner/repo#123 (SHA abc1234): [verdict] — N critical, M issues
- Skipped: owner/repo#124 (draft), owner/repo#125 (bot-reviewed-recently)
If no open PRs across all repos, log PR_REVIEW_OK and end.
SURVEY branch (--survey) — risk-tiered triage digest
(This is the former pr-merge skill, folded in verbatim. It surveys the queue and buckets by blast radius; it does not merge anything — auto-merge owns the actual-merge action behind its own author-allowlist + size-cap + branch-protection policy. This branch is the decision-support layer that lives before auto-merge, sized for the much larger pool of PRs auto-merge's safety policy intentionally skips.)
Today is ${today}. The open-PR queue on aeonfun/aeon has crossed the threshold where a human reviewer working alone falls behind: yesterday (June 1) eighteen PRs were merged in a single 37-minute Monday catch-up window, but on every prior weekend day they stacked up untouched. As community skill packs become the primary contribution model and external contributors keep landing skill PRs every other day, the queue's steady-state size will keep climbing — skill-scan evaluates one inbound skill PR at a time, but no skill answers the operator's actual morning question: "of the N open PRs right now, which N1 can I merge in one click and which N2 need real review?"
This branch is that answer. It surveys every open PR on a target repo, categorises each by the files it touches, runs scripts/skill-scan.sh against every changed SKILL.md (same scanner skill-scan reuses verbatim), and emits one structured Telegram digest with four risk buckets sorted by PR age. The operator can fire-and-forget the FAST_TRACK bucket, glance at SKILL_PASS, and budget real attention for INFRA_REVIEW + SKILL_WARN_OR_BLOCK + CORE_REVIEW.
Read memory/MEMORY.md for context.
Read the last 8 days of memory/logs/ for prior-run context (skip if dispatched).
Read soul/SOUL.md + soul/STYLE.md if populated to match voice in the notification.
Why a separate branch from pr-triage / skill-scan / auto-merge
| Skill | Scope | Action |
|---|
pr-triage | Per external PR, first-touch | Welcomes + labels + leaves a verdict comment |
skill-scan | One skill-PR (workflow_dispatch var=PR_NUMBER) | Posts a structured per-skill security/secrets/conflict comment |
auto-merge | All bot-authored PRs that pass a strict safety policy | Merges if CLEAN |
pr-review --survey | All open PRs across the watched-repos queue | One operator-facing digest sorted by risk + age — no per-PR comment, no merge action |
The four compose. pr-triage runs once per PR open; skill-scan runs on demand per skill PR; auto-merge runs against the bot subset; this survey branch is the morning brief over everything else — the open backlog the operator still has to think about. Building a fifth verdict layer into any of the existing three would either bloat their per-PR cost or skip the operator-overview question entirely.
Inputs
| Source | Purpose | Auth |
|---|
gh api repos/{repo}/pulls?state=open&per_page=100 --paginate | Open PR list with author, draft state, base ref, age, mergeable state, head SHA, statusCheckRollup summary, labels | GH_TOKEN |
gh api repos/{repo}/pulls/{N}/files?per_page=100 --paginate | Per-PR list of changed file paths + status (added/modified/removed) — the only signal we trust for bucketing | GH_TOKEN |
gh api repos/{repo}/contents/{path}?ref={head_sha} | Each changed SKILL.md body — fed to scan.sh for PASS/WARN/BLOCK verdict | GH_TOKEN |
scripts/skill-scan.sh (local) | Scanner reused verbatim (no fork, no shadow copy) — same source skill-scan reuses | Local script |
memory/watched-repos.md (local) | Read only the ## Trusted Authors section — those authors' PRs surface in a separate TRUSTED_AUTHOR row that bypasses the FAST_TRACK / CORE_REVIEW buckets | Local file |
No new secrets. GitHub access via gh CLI (GH_TOKEN) per CLAUDE.md.
Writes:
output/articles/pr-merge-${today}.md — full digest with one row per open PR, sortable by bucket + age (every non-error run, including QUIET)
memory/topics/pr-merge-state.json — prior-run snapshot (per-PR bucket + first_seen date + last_head_sha, used to suppress re-notification on the same head SHA)
memory/logs/${today}.md — one log block per run
- Notification via
./notify — only when ≥1 new PR appeared in a non-FAST_TRACK bucket since the last run, or a SKILL_BLOCK / CORE_REVIEW PR is present and operator has not been notified about it on this head SHA yet, or it's the first (baseline) run (see step 8)
Steps
0. Bootstrap
mkdir -p memory/topics output/articles
[ -f memory/topics/pr-merge-state.json ] || cat > memory/topics/pr-merge-state.json <<'EOF'
{"last_run":null,"last_status":null,"last_repo":null,"prs":{}}
EOF
If jq empty fails on the state file (corrupt JSON from an aborted write), back it up to .bak, reset to the empty template, and tag the run STATE_CORRUPT. Continue — a fresh state file means re-notifying every currently-open PR as "new" on this one run, which is the safer post-corruption outcome than silently skipping a SKILL_BLOCK PR the operator hadn't seen yet.
prs is a map keyed by <owner>/<repo>#<number>: {bucket, scan_verdict, head_sha, first_seen, last_notified_head_sha, age_days, author, draft}. Cap to 200 most-recent entries (LRU by first_seen) so a long-lived state file can't grow unbounded.