From a11ca3a0017a2728b6cb0bb08936fe31be1a12c4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 19:23:49 -0400 Subject: [PATCH] ci(auto-approve): post approval reviews on routine-authored PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code triage routine opens PRs as @bokelley (the only GitHub identity available in the Anthropic routine console), which means the project owner cannot approve their own PRs and branch protection forces admin-merge for everything the routine produces. Add a separate auto-approver workflow that uses the AAO Triage Bot GitHub App to post approving reviews on PRs that are: - Routine-authored (head branch starts with `claude/` or `auto/`) - Not draft - All CI checks green (success / neutral / skipped only) - No `do-not-auto-approve` label Triggers on pull_request_target (every PR event), check_suite.completed (when CI lands), and workflow_dispatch (manual fire). Idempotent — skips if the bot already approved the current head_sha. Required repo secrets: - TRIAGE_BOT_APP_ID - TRIAGE_BOT_APP_PRIVATE_KEY The App needs `pull_requests:write` + `contents:read` and must be installed on this repo. If the secrets are missing, the workflow fails fast at the token-generation step (no silent skip). Opt-out: add the `do-not-auto-approve` label to any PR that needs human eyes (e.g., feature work the routine couldn't fully verify). Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/auto-approve-routine-prs.md | 4 + .../workflows/auto-approve-routine-prs.yml | 207 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 .changeset/auto-approve-routine-prs.md create mode 100644 .github/workflows/auto-approve-routine-prs.yml diff --git a/.changeset/auto-approve-routine-prs.md b/.changeset/auto-approve-routine-prs.md new file mode 100644 index 0000000000..ab52aeb219 --- /dev/null +++ b/.changeset/auto-approve-routine-prs.md @@ -0,0 +1,4 @@ +--- +--- + +Add `auto-approve-routine-prs.yml` workflow that posts an approving review on routine-authored PRs (branches `claude/*` or `auto/*`) once all CI checks are green. Uses the AAO Triage Bot GitHub App as a separate approver identity so the routine's PRs (opened under the project owner's PAT) can satisfy the "1 approving review" branch protection rule without admin-merge. Opt-out via `do-not-auto-approve` label. diff --git a/.github/workflows/auto-approve-routine-prs.yml b/.github/workflows/auto-approve-routine-prs.yml new file mode 100644 index 0000000000..1a7d0b2a99 --- /dev/null +++ b/.github/workflows/auto-approve-routine-prs.yml @@ -0,0 +1,207 @@ +name: Auto-approve routine PRs + +# Approves PRs from the Claude Code triage routine using a separate GitHub +# App identity. The routine opens PRs as @bokelley (the token it has access +# to in the Anthropic routine console), so the repo owner can't approve +# their own PRs — branch protection blocks them. This workflow lets the +# triage bot post an approving review, satisfying the "1 review required" +# rule without requiring admin-merge. +# +# Scope: +# - Only PRs whose head branch matches `claude/*` or `auto/*` qualify. +# The routine's PRs use `claude/issue-*` or `claude/` patterns. +# - All required CI checks must be green (we don't gate on a single +# check event — we re-fetch the full check rollup on every fire). +# - Draft PRs are skipped (mark ready first). +# - PRs with the `do-not-auto-approve` label are skipped — explicit +# opt-out for cases where the routine produces something that +# genuinely needs human eyes. +# +# Required repo secrets: +# TRIAGE_BOT_APP_ID — GitHub App ID for the triage bot +# TRIAGE_BOT_APP_PRIVATE_KEY — Private key (PEM) for the App +# +# The App must have: +# - `pull_requests:write` (to post reviews) +# - `contents:read` (to read CI status) +# - Installed on this repository + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + check_suite: + types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to evaluate (manual fire)' + required: true + type: string + +permissions: + contents: read + +jobs: + approve: + name: Approve if CI green + runs-on: ubuntu-latest + timeout-minutes: 5 + # Three trigger paths: + # - pull_request_target → fired on every PR event; filter by branch + draft + # - check_suite.completed → fired when CI finishes; resolves which PR via head_sha + # - workflow_dispatch → manual fire with PR number + if: >- + ( + github.event_name == 'pull_request_target' && + !github.event.pull_request.draft && + (startsWith(github.event.pull_request.head.ref, 'claude/') || + startsWith(github.event.pull_request.head.ref, 'auto/')) + ) || + github.event_name == 'check_suite' || + github.event_name == 'workflow_dispatch' + steps: + - name: Resolve PR number + id: pr + uses: actions/github-script@v8 + with: + script: | + let prNumber; + const event = context.eventName; + + if (event === 'pull_request_target') { + prNumber = context.payload.pull_request.number; + } else if (event === 'workflow_dispatch') { + prNumber = parseInt(context.payload.inputs.pr_number, 10); + } else if (event === 'check_suite') { + // check_suite carries head_sha — find the PR whose head matches. + const headSha = context.payload.check_suite.head_sha; + const prs = context.payload.check_suite.pull_requests || []; + const inRepo = prs.find(p => + p.base.repo && p.base.repo.id === context.payload.repository.id + ); + if (!inRepo) { + core.info(`No PR found for head_sha ${headSha} in this repo`); + return null; + } + prNumber = inRepo.number; + } + + if (!prNumber) { + core.info('No PR resolved from event'); + return null; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + // Branch filter — only routine branches qualify + const branch = pr.head.ref; + const isRoutineBranch = + branch.startsWith('claude/') || branch.startsWith('auto/'); + if (!isRoutineBranch) { + core.info(`Skip — branch ${branch} is not claude/* or auto/*`); + return null; + } + + if (pr.draft) { + core.info('Skip — PR is draft'); + return null; + } + + if (pr.state !== 'open') { + core.info(`Skip — PR is ${pr.state}`); + return null; + } + + // Opt-out label + const labels = (pr.labels || []).map(l => l.name); + if (labels.includes('do-not-auto-approve')) { + core.info('Skip — do-not-auto-approve label present'); + return null; + } + + core.setOutput('number', prNumber); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('author', pr.user.login); + return prNumber; + + - name: Check CI is green + id: ci + if: steps.pr.outputs.number + uses: actions/github-script@v8 + with: + script: | + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + const headSha = '${{ steps.pr.outputs.head_sha }}'; + + // Fetch full check rollup. A PR is "green" when: + // - All check runs on head_sha have conclusion in {success, neutral, skipped} + // - No check run is queued/in_progress + // - At least one check ran (don't approve a PR with zero checks) + const { data: { check_runs: checks } } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: headSha, + per_page: 100, + }); + + if (checks.length === 0) { + core.info('No checks have run yet — wait'); + return false; + } + + const failing = checks.filter(c => + c.conclusion && !['success', 'neutral', 'skipped'].includes(c.conclusion) + ); + const pending = checks.filter(c => c.status !== 'completed'); + + if (failing.length > 0) { + core.info(`Skip — ${failing.length} failing check(s): ${failing.map(c => c.name).join(', ')}`); + return false; + } + + if (pending.length > 0) { + core.info(`Skip — ${pending.length} pending check(s): ${pending.map(c => c.name).join(', ')}`); + return false; + } + + core.info(`✓ All ${checks.length} checks green on ${headSha.substring(0, 8)}`); + return true; + + - name: Generate App token + id: app + if: steps.ci.outputs.result == 'true' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.TRIAGE_BOT_APP_ID }} + private-key: ${{ secrets.TRIAGE_BOT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Post approval review + if: steps.ci.outputs.result == 'true' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Skip if bot has already approved this commit + existing=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \ + --jq "[.[] | select(.commit_id == \"$HEAD_SHA\" and .state == \"APPROVED\")] | length") + if [ "$existing" -gt 0 ]; then + echo "Bot already approved $HEAD_SHA — skipping" + exit 0 + fi + + gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \ + -f event=APPROVE \ + -f commit_id="$HEAD_SHA" \ + -f body="Auto-approved by AAO Triage Bot. CI green; routine-authored branch (\`${{ github.event.pull_request.head.ref || 'unknown' }}\`); no \`do-not-auto-approve\` label." + + echo "✓ Approved PR #$PR_NUMBER"