Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 50 additions & 138 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,184 +43,96 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
REVIEWER_USER: ${{ vars.REVIEWER_USER || 'don-petry' }}
# Auth for every gh / script call in this job. The PAT must belong to BOT_USER.
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
# Single bot identity. GH_PAT_WORKFLOWS authenticates as BOT_USER; that
# account owns/has-access-to the personal repos scanned by list-prs.sh,
# signs the cascade's escalation comments, and is filtered out of the
# candidate queue (it can't approve its own PRs). Human reviewers are
# routed via CODEOWNERS at escalation time, not pinned via env.
BOT_USER: ${{ vars.BOT_USER || 'don-petry-bot' }}
TARGET_ORG: ${{ vars.TARGET_ORG || 'petry-projects' }}
BOT_USER: ${{ vars.BOT_USER || 'petry-review-bot' }}
AGENT_REPO: ${{ github.repository }}
# Review engine: "claude" or "copilot". Controls which CLI and models are used.
# gh variable set REVIEW_ENGINE --body copilot --repo petry-projects/.github-private
REVIEW_ENGINE: ${{ vars.REVIEW_ENGINE || 'claude' }}
# Claude engine auth — routes through Max plan subscription.
# Generate with `claude setup-token`, store as repo secret.
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Copilot engine auth — reuses the existing GH_PAT (personal account token with Copilot subscription).
# No new secret needed: gh secret set GH_PAT is already present in this repo.
# Copilot engine reuses GH_PAT (a separate user PAT with Copilot subscription).
COPILOT_GITHUB_TOKEN: ${{ secrets.GH_PAT }}
# Default to dry-run unless the repo variable LIVE_MODE is set to 'true'.
# workflow_dispatch can override via dry_run input.
# repository_dispatch (mention trigger) always runs live — the human explicitly asked.
# To go live: gh variable set LIVE_MODE --body true --repo petry-projects/.github-private
# Default to dry-run. workflow_dispatch can override; repository_dispatch
# (mention) always runs live; vars.LIVE_MODE=true switches scheduled runs live.
DRY_RUN: ${{ github.event_name == 'repository_dispatch' && 'false' || inputs.dry_run || (vars.LIVE_MODE == 'true' && 'false' || 'true') }}
PR_URL_OVERRIDE: ${{ inputs.pr_url || github.event.client_payload.pr_url }}
# Comma-separated list of GitHub orgs where AI delegation is enabled.
# When findings exist, the agent posts fix-request comments for AI to act on.
# gh variable set DELEGATION_ORGS --body "petry-projects,don-petry" --repo petry-projects/.github-private
DELEGATION_ORGS: ${{ vars.DELEGATION_ORGS || vars.CLAUDE_ORGS || '' }}
# Max review cycles before stopping AI delegation and escalating to human.
MAX_REVIEW_CYCLES: ${{ vars.MAX_REVIEW_CYCLES || '3' }}
# When true, bypass idempotency check so an @mention always triggers a fresh review.
# repository_dispatch always forces a review; workflow_dispatch respects the input.
MAX_PRS: ${{ vars.MAX_PRS || '10' }}
CANDIDATE_LIMIT: ${{ vars.CANDIDATE_LIMIT || '100' }}
# Pin via vars.CLAUDE_CODE_VERSION for fully reproducible caching; default
# 'latest' caches an install indefinitely under that key — flush via the
# Actions cache UI or bump the variable to pick up new releases.
CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || 'latest' }}
# Mention-triggered reviews always force; workflow_dispatch respects the input.
FORCE_REVIEW: ${{ github.event_name == 'repository_dispatch' && 'true' || inputs.force_review || 'false' }}

steps:
- name: Checkout agent repo
uses: actions/checkout@v5

- name: Cache claude-code CLI
uses: actions/cache@v4
with:
path: ~/.npm-global
key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }}

- name: Install review engine CLIs
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
run: |
# Primary engine must install successfully; duck engine is best-effort.
set -euo pipefail
gh auth status

# Use a per-user prefix so actions/cache (above) can persist the
# install across runs without needing root. Add the bin dir to PATH
# for both this step and every subsequent step in the job.
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.npm-global/bin:$PATH"

install_claude() {
if command -v claude >/dev/null 2>&1; then
echo "claude-code cache hit: $(claude --version)"
return 0
fi
npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
}

case "$REVIEW_ENGINE" in
claude)
npm install -g @anthropic-ai/claude-code
# gh copilot is now a built-in in modern gh CLI — no extension install needed.
# Verify the built-in is accessible with the user token (GH_PAT).
install_claude
if ! env GH_TOKEN="$COPILOT_GITHUB_TOKEN" gh copilot --version > /dev/null 2>&1; then
echo "::warning::gh copilot built-in unavailable with user token — rate-limit fallback will be unavailable."
fi
;;
copilot)
gh extension install github/gh-copilot
npm install -g @anthropic-ai/claude-code || true
install_claude || true
;;
*)
echo "::error::Unknown REVIEW_ENGINE=$REVIEW_ENGINE"
exit 1
;;
esac

- name: Verify auth
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
run: |
gh auth status

- name: Enumerate candidate PRs
id: list
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
run: |
set -euo pipefail
if [ -n "${PR_URL_OVERRIDE}" ]; then
echo "${PR_URL_OVERRIDE}" > prs.txt
printf '%s\n' "${PR_URL_OVERRIDE}" > prs.txt
else
bash scripts/list-prs.sh > prs.txt
TOTAL=$(wc -l < prs.txt | tr -d ' ')
if [ "$TOTAL" -gt 0 ]; then
echo "::notice::Candidate pool has $TOTAL PRs. Review loop will process until MAX_PRS actual reviews are completed (no-ops are skipped)."
fi
fi
echo "count=$(wc -l < prs.txt | tr -d ' ')" >> "$GITHUB_OUTPUT"
echo "--- candidate PRs (will filter no-ops) ---"
count=$(grep -c . prs.txt || true)
echo "::notice::Candidate pool has $count PRs (no-ops will be skipped, MAX_PRS caps actual reviews)"
echo "--- candidate PRs ---"
cat prs.txt || true

- name: Review each PR (cascade)
if: steps.list.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
MAX_PRS: ${{ vars.MAX_PRS || '10' }}
CANDIDATE_LIMIT: ${{ vars.CANDIDATE_LIMIT || '100' }}
run: |
set -euo pipefail
actual=0
skipped_noops=0
failed=0
engine_fallbacks=0
processed=0
session_aborted=0
abort_pr=""
abort_reason=""
total_candidates=$(wc -l < prs.txt | tr -d ' ')
while IFS= read -r pr_url; do
[ -z "$pr_url" ] && continue
processed=$((processed + 1))

# Hard limit on candidates checked to avoid timeout
if [ "$processed" -gt "$CANDIDATE_LIMIT" ]; then
echo "::notice::Hit CANDIDATE_LIMIT=$CANDIDATE_LIMIT, stopping batch"
break
fi

# Stop after MAX_PRS actual reviews (not counting no-ops)
if [ "$actual" -ge "$MAX_PRS" ]; then
echo "::notice::Reached MAX_PRS=$MAX_PRS actual reviews, stopping batch"
break
fi

echo "::group::Reviewing $pr_url"
rc=0
bash scripts/review-one-pr.sh "$pr_url" || rc=$?

# Exit code 2 = engine rate-limited.
# If primary engine is Claude, switch to Copilot and retry this PR.
# All subsequent PRs in this batch will also use Copilot.
if [ "$rc" -eq 2 ] && [ "${REVIEW_ENGINE:-claude}" = "claude" ]; then
if ! gh extension list 2>/dev/null | grep -q copilot; then
echo "::warning::Copilot fallback engine unavailable (not installed) — skipping $pr_url and continuing batch"
failed=$((failed + 1))
echo "::endgroup::"
continue
fi
echo "::warning::Claude rate limit hit — switching to Copilot engine for remaining PRs"
export REVIEW_ENGINE=copilot
engine_fallbacks=$((engine_fallbacks + 1))
rc=0
bash scripts/review-one-pr.sh "$pr_url" || rc=$?
fi

if [ "$rc" -eq 100 ]; then
# Exit code 100 = no-op (already reviewed), don't count toward budget
skipped_noops=$((skipped_noops + 1))
echo "::notice::No-op (already reviewed)"
elif [ "$rc" -eq 0 ]; then
# Successful review
actual=$((actual + 1))
echo "::notice::Review posted ($actual/$MAX_PRS)"
elif [ "$rc" -eq 2 ]; then
# Rate-limited on fallback engine too — no further fallback available.
# Session-fatal: subsequent PRs will hit the same wall.
failed=$((failed + 1))
echo "::error::Rate limit hit on $REVIEW_ENGINE engine, no fallback available for $pr_url"
session_aborted=1
abort_pr="$pr_url"
abort_reason="rate-limit on fallback engine"
else
# Other failure — session-fatal. A systemic problem (model degraded,
# prompt regression, malformed verdicts) shouldn't burn the queue.
failed=$((failed + 1))
echo "::error::Review failed for $pr_url (exit code $rc)"
session_aborted=1
abort_pr="$pr_url"
abort_reason="exit code $rc"
fi
echo "::endgroup::"
if [ "$session_aborted" -eq 1 ]; then
break
fi
done < prs.txt
remaining=$((total_candidates - processed))
if [ "$session_aborted" -eq 1 ]; then
echo "::error::Session aborted early after failure on $abort_pr ($abort_reason). Skipped $remaining remaining candidate(s); will retry on next scheduled run."
fi
if [ "$engine_fallbacks" -gt 0 ]; then
summary="Summary: $actual reviews posted, $skipped_noops no-ops skipped, $failed failures, $engine_fallbacks engine fallback(s) to copilot (processed $processed/$total_candidates candidates)"
else
summary="Summary: $actual reviews posted, $skipped_noops no-ops skipped, $failed failures (processed $processed/$total_candidates candidates)"
fi
if [ "$session_aborted" -eq 1 ]; then
echo "$summary [SESSION ABORTED EARLY]"
exit 1
else
echo "$summary"
fi
run: bash scripts/review-batch.sh
9 changes: 6 additions & 3 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ and **Copilot**.
(and on `workflow_dispatch`).
1a. **@mention** — comment `@petry-review-bot` on any PR to trigger an immediate
review, bypassing the hourly schedule. See [Mention-triggered reviews](#mention-triggered-reviews).
2. **Enumerate** — `scripts/list-prs.sh` queries GitHub for open PRs where
`@me` is the author OR a requested reviewer, across every repo the PAT can
see. Output: one URL per line.
2. **Enumerate** — `scripts/list-prs.sh` queries GitHub for open PRs across
every repo the PAT can see (the bot's personal account plus `TARGET_ORG`),
**excluding PRs authored by `BOT_USER`** (the workflow's authenticated
identity, default `don-petry-bot`). GitHub's GraphQL API rejects
self-approval unconditionally, so self-authored PRs are unreviewable and
would otherwise starve the queue. Output: one URL per line.
3. **Per-PR review** — `scripts/review-one-pr.sh` runs a cascading review
where each tier only fires if the previous one flagged concerns:

Expand Down
6 changes: 3 additions & 3 deletions scripts/engine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ run_triage() {
# gh copilot is now a built-in; auth via GH_PAT (user token with Copilot subscription).
# Model selection is not supported by gh copilot suggest — uses Copilot's default.
( export GH_TOKEN="$COPILOT_GITHUB_TOKEN"
timeout "$TRIAGE_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --target shell
timeout "$TRIAGE_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --agent shell
) || rc=$?
;;
esac
Expand Down Expand Up @@ -159,7 +159,7 @@ run_agentic() {
# gh copilot is now a built-in; auth via GH_PAT (user token with Copilot subscription).
# Model selection is not supported by gh copilot suggest — uses Copilot's default.
( export GH_TOKEN="$COPILOT_GITHUB_TOKEN"
timeout "$DEEP_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --target shell
timeout "$DEEP_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --agent shell
)
;;
esac
Expand Down Expand Up @@ -220,7 +220,7 @@ run_duck() {
unset CLAUDE_CODE_OAUTH_TOKEN 2>/dev/null || true
# gh copilot is now a built-in; auth via GH_PAT (user token with Copilot subscription).
( export GH_TOKEN="$COPILOT_GITHUB_TOKEN"
timeout "$DUCK_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --target shell
timeout "$DUCK_TIMEOUT_SEC" gh copilot suggest "$(cat "$prompt_file")" --agent shell
)
;;
*)
Expand Down
38 changes: 27 additions & 11 deletions scripts/list-prs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Enumerate open, non-draft PRs the agent should consider reviewing.
#
# Searches across two namespaces:
# 1. All open PRs in repos owned by $REVIEWER_USER (personal account)
# 1. All open PRs in repos owned by $BOT_USER (the bot's personal account)
# 2. All open PRs in repos owned by $TARGET_ORG (organization)
#
# Filters:
Expand All @@ -12,30 +12,46 @@
# never consume a review slot. review-one-pr.sh also
# enforces this per-PR as a second layer of defence.
#
# Note: Uses repo enumeration instead of @me/@review-requested, which don't work
# with GitHub App tokens (app tokens have no user identity).
# Self-authored PRs (PRs whose author is $BOT_USER) are excluded here, because
# GitHub's GraphQL API rejects self-approval unconditionally — including such a
# PR in the queue previously triggered a fatal session abort that starved every
# subsequent candidate (see issue #96).
#
# Output: one PR URL per line on stdout.

set -euo pipefail

# Configurable via environment / repo variables
REVIEWER_USER="${REVIEWER_USER:-don-petry}"
# Configurable via environment / repo variables. BOT_USER is the GitHub
# identity the workflow PAT authenticates as — both the queue scope (which
# repos to scan) and the self-approval filter use it.
BOT_USER="${BOT_USER:-don-petry-bot}"
TARGET_ORG="${TARGET_ORG:-petry-projects}"

# Reject BOT_USER values that aren't valid GitHub usernames before
# interpolating into a jq program. GitHub usernames are 1–39 chars of
# [A-Za-z0-9-] and may not start or end with a hyphen. Anything else is
# either a misconfiguration or an injection attempt — fail loud rather
# than silently dropping PRs.
if ! [[ "$BOT_USER" =~ ^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$ ]]; then
echo "::error::BOT_USER='$BOT_USER' is not a valid GitHub username" >&2
exit 1
fi

all_prs=""

# Get all repos in personal account and search each
JQ_NOT_SELF=".[] | select(.author.login != \"$BOT_USER\") | .url"

# Get all repos in bot's personal account and search each
while IFS= read -r repo; do
prs=$(gh search prs \
--state open \
--repo "$repo" \
--draft=false \
--limit 100 \
--json url \
--jq '.[].url' 2>/dev/null || true)
--json url,author \
--jq "$JQ_NOT_SELF" 2>/dev/null || true)
all_prs="${all_prs}${prs}"$'\n'
done < <(gh repo list "$REVIEWER_USER" --json nameWithOwner --jq '.[].nameWithOwner' 2>/dev/null || true)
done < <(gh repo list "$BOT_USER" --json nameWithOwner --jq '.[].nameWithOwner' 2>/dev/null || true)

# Get all repos in org and search each (require passing checks)
while IFS= read -r repo; do
Expand All @@ -45,8 +61,8 @@ while IFS= read -r repo; do
--draft=false \
--checks success \
--limit 100 \
--json url \
--jq '.[].url' 2>/dev/null || true)
--json url,author \
--jq "$JQ_NOT_SELF" 2>/dev/null || true)
all_prs="${all_prs}${prs}"$'\n'
done < <(gh repo list "$TARGET_ORG" --json nameWithOwner --jq '.[].nameWithOwner' 2>/dev/null || true)

Expand Down
23 changes: 18 additions & 5 deletions scripts/post-pr-review.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,25 @@ if [ "$DECISION" = "approve" ]; then
echo "$BODY" > "$BODY_FILE"

echo "Posting APPROVED review..."
gh pr review "$PR_URL" --approve --body "$(cat "$BODY_FILE")" || {
REVIEW_ERR_FILE="/tmp/pr-review-err-$$.txt"
gh pr review "$PR_URL" --approve --body "$(cat "$BODY_FILE")" 2>"$REVIEW_ERR_FILE" || {
rc=$?
review_err=$(cat "$REVIEW_ERR_FILE" 2>/dev/null || true)
cat "$REVIEW_ERR_FILE" >&2 2>/dev/null || true
rm -f "$BODY_FILE" "$REVIEW_ERR_FILE"
# Self-approval is a permanent, PR-specific constraint — never the runner's
# fault and never recoverable on retry. Exit 100 (no-op sentinel) so the
# workflow loop skips this PR without aborting the rest of the session.
# See issue #96: a single self-authored PR at the top of the queue
# previously starved every batch.
if echo "$review_err" | grep -qiE 'Can not approve your own pull request'; then
echo "::warning::Cannot self-approve $PR_URL — skipping (exit 100)"
exit 100
fi
echo "ERROR: gh pr review failed with exit code $rc"
rm -f "$BODY_FILE"
exit 1
}
rm -f "$BODY_FILE"
rm -f "$BODY_FILE" "$REVIEW_ERR_FILE"

# Dismiss prior agent reviews / collapse prior agent comments now that the
# newest review has landed. Best-effort: failures here don't break the run.
Expand Down Expand Up @@ -273,10 +285,11 @@ COMMENT_END
# has landed. A new fix-request also invalidates any prior approval.
mark_prior_agent_items_obsolete "$PR_URL"
else
# Escalate to human
# Escalate to human via CODEOWNERS — avoid hard-coding a single reviewer.
echo "Escalating to human review..."
gh pr edit "$PR_URL" --add-label needs-human-review 2>/dev/null || true
gh pr request-review "$PR_URL" --user "${REVIEWER_USER:-don-petry}" 2>/dev/null || true
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
bash "$SCRIPT_DIR/request-codeowners-review.sh" "$PR_URL" || true
fi
fi

Expand Down
Loading
Loading