Skip to content
Closed
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
154 changes: 154 additions & 0 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
name: PR Review Agent

on:
schedule:
# Every 15 min to ensure at least one run fires per hour
# (GitHub Actions scheduled runs can be delayed/skipped under load)
- cron: "7 * * * *"
- cron: "22 * * * *"
- cron: "37 * * * *"
- cron: "52 * * * *"
workflow_dispatch:
inputs:
pr_url:
description: "Optional: review a single PR URL instead of enumerating"
required: false
type: string
dry_run:
description: "If true, never submit reviews or comments"
required: false
default: "false"
type: string
force_review:
description: "If true, bypass idempotency check and re-review even if already reviewed at head SHA (use for @mention-triggered reviews)"
required: false
default: "false"
type: string
repository_dispatch:
# Triggered by the petry-projects/.github mention-listener workflow.
# Requires only Contents: write on this repo (not Actions: write).
# Payload: { pr_url: "https://github.com/...", force_review: "true" }
types: [pr-review-mention]

permissions:
contents: read
pull-requests: write
checks: read

concurrency:
# Scheduled and manual batch runs share one slot to prevent overlap.
# Mention-triggered runs (repository_dispatch or workflow_dispatch with pr_url)
# get per-PR slots so they don't queue behind hourly batches or each other.
group: ${{ (inputs.pr_url || github.event.client_payload.pr_url) && format('pr-review-mention-{0}', inputs.pr_url || github.event.client_payload.pr_url) || 'pr-review-scheduled' }}
cancel-in-progress: false

jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
# Auth for every gh / script call in this job. The PAT must belong to BOT_USER.
GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }}
# Single bot identity. DON_PETRY_BOT_GH_PAT 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 || 'donpetry-bot' }}
TARGET_ORG: ${{ vars.TARGET_ORG || 'petry-projects' }}
AGENT_REPO: ${{ github.repository }}
# Review engine: "claude" or "copilot". Controls which CLI and models are used.
REVIEW_ENGINE: ${{ vars.REVIEW_ENGINE || 'claude' }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Copilot engine reuses GH_PAT (a separate user PAT with Copilot subscription).
COPILOT_GITHUB_TOKEN: ${{ secrets.GH_PAT }}
Comment on lines +63 to +64
# Gemini engine auth — requires GOOGLE_API_KEY repo secret.
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
# 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 }}
DELEGATION_ORGS: ${{ vars.DELEGATION_ORGS || vars.CLAUDE_ORGS || '' }}
MAX_REVIEW_CYCLES: ${{ vars.MAX_REVIEW_CYCLES || '3' }}
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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 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
run: |
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)
install_claude
# Best-effort install of fallbacks for duck/fallback support
npm install -g @google/gemini-cli || true
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
;;
gemini)
npm install -g @google/gemini-cli
install_claude || true
;;
copilot)
# Best-effort install of copilot if built-in is missing
if ! gh copilot --version > /dev/null 2>&1; then
gh extension install github/gh-copilot || true
fi
install_claude || true
npm install -g @google/gemini-cli || true
;;
*)
echo "::error::Unknown REVIEW_ENGINE=$REVIEW_ENGINE"
exit 1
;;
esac

- name: Enumerate candidate PRs
run: |
set -euo pipefail
if [ -n "${PR_URL_OVERRIDE}" ]; then
printf '%s\n' "${PR_URL_OVERRIDE}" > prs.txt
else
bash scripts/list-prs.sh > prs.txt
fi
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)
run: bash scripts/review-batch.sh