refactor(ci): review ai-toolkit's own PRs with @uniswap/review-cli #1993
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Claude Code Review | |
| # Automated PR code reviews for this repository, powered by | |
| # @uniswap/review-cli from Uniswap/internal-tools. | |
| # | |
| # This repo also PUBLISHES a reusable review workflow | |
| # (`_claude-code-review.yml`) that other Uniswap repos consume. That | |
| # workflow is a separate product and is unaffected by this file — this | |
| # file is only how ai-toolkit reviews its own PRs. See | |
| # .github/workflows/CLAUDE.md for the distinction. | |
| # | |
| # WHAT REVIEW-CLI PROVIDES | |
| # - Formal GitHub reviews (APPROVE / REQUEST_CHANGES / COMMENT) | |
| # - Inline review threads, one per finding, deduplicated by | |
| # file:line:category fingerprint | |
| # - Auto-resolution of threads whose finding is fixed, and a hard rule | |
| # that a thread with a human reply is never auto-resolved | |
| # - Idempotency across force-pushes via a three-level change check | |
| # (tree SHA -> patch ID -> hunk digest), so a pure rebase costs nothing | |
| # - A sticky summary comment carrying review history between runs | |
| # - Parallel specialist reviewers chosen per-PR by a triage agent | |
| # | |
| # TRIGGERS | |
| # - Automatic: PR opened, synchronize (push), reopened, ready_for_review | |
| # - Comment: any PR comment or inline review comment containing | |
| # "@request-claude-review" | |
| # - Manual: workflow_dispatch with a PR number | |
| # | |
| # NOTE: re-requesting a review from github-actions[bot] in the GitHub UI | |
| # does NOT work — GitHub fires no event when a review is re-requested | |
| # from a bot account. Use the comment trigger or manual dispatch. | |
| # | |
| # CONFIGURATION | |
| # - Policy, model, budgets, and reviewer staffing: .claude/review.yml | |
| # - Repo-specific reviewers: .claude/agents/*-reviewer.md | |
| # - CLI version: vars.REVIEW_CLI_VERSION (falls back to the pin below) | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - ready_for_review | |
| issue_comment: | |
| types: [created] | |
| pull_request_review_comment: | |
| types: [created] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'Pull request number to review' | |
| required: true | |
| type: string | |
| force_review: | |
| description: "Re-review even if the code hasn't changed (skips rebase detection)" | |
| required: false | |
| type: boolean | |
| default: true | |
| concurrency: | |
| # A re-push SHOULD supersede an in-flight review — there is no value in | |
| # reviewing superseded code — so `pull_request` events cancel. | |
| # | |
| # Comment events that do NOT carry the trigger phrase get a per-run | |
| # unique group suffix instead. Without it, any unrelated PR comment | |
| # (Socket Security alerts, dependency notices, general discussion) | |
| # creates a sibling run that grabs the `claude-review-<N>` group, | |
| # cancels the in-flight review, and then immediately skips itself at | |
| # the job `if:`. Concurrency is evaluated at run-creation time, BEFORE | |
| # job-level `if:`, so the skip does not save us. | |
| group: >- | |
| claude-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }}${{ | |
| (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') | |
| && !contains(github.event.comment.body, '@request-claude-review') | |
| && format('-skip-{0}', github.run_id) | |
| || '' | |
| }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| # Deny by default. Every job below declares exactly the scopes it needs, | |
| # so the empty workflow-level block means a newly added job starts with no | |
| # token permissions until its author grants them deliberately. | |
| permissions: {} | |
| jobs: | |
| # Classify automated PRs. review-cli's own skip policy covers branch | |
| # prefixes and authors (see .claude/review.yml), but it has no notion of | |
| # PR *titles* — and this repo's release and sync automation is | |
| # identified by title (`chore(release):`, `chore(sync):`). This job | |
| # keeps that classification, reusing the same composite action that | |
| # ci-pr-checks.yml and ci-check-pr-title.yml use. | |
| # | |
| # Only meaningful for `pull_request`: comment and manual triggers are | |
| # explicit human requests and deliberately bypass automation filtering. | |
| check-automated: | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # sparse-checkout the local composite action | |
| outputs: | |
| is_automated: ${{ steps.check.outputs.is_automated }} | |
| category: ${{ steps.check.outputs.category }} | |
| skip_reason: ${{ steps.check.outputs.skip_reason }} | |
| steps: | |
| - uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 | |
| - name: Checkout repository (for composite action) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| sparse-checkout: .github/actions | |
| sparse-checkout-cone-mode: false | |
| - name: Check for automated PR | |
| id: check | |
| uses: ./.github/actions/check-automated-pr | |
| with: | |
| branch_name: ${{ github.head_ref }} | |
| pr_title: ${{ github.event.pull_request.title }} | |
| # Cheap gate: no PR checkout, no LLM call. `review-cli triage` reads | |
| # GITHUB_EVENT_NAME + GITHUB_EVENT_PATH itself and writes a structured | |
| # decision to GITHUB_OUTPUT, so no PR-author-controlled field is ever | |
| # interpolated into a shell. It applies the draft skip, the author | |
| # association threshold, the bot self-mention guard, the trigger-phrase | |
| # parse, and workflow_dispatch pr_number extraction. | |
| triage: | |
| name: Triage | |
| needs: [check-automated] | |
| # Coarse event gate: a cheap early exit so unrelated comments and | |
| # untrusted commenters never pay for runner startup. `review-cli | |
| # triage` below is the authoritative policy check (it enforces the | |
| # same author-association threshold itself); this only avoids booting | |
| # a runner for events that obviously don't apply. | |
| # | |
| # `always()` is required because check-automated is skipped entirely | |
| # for comment and manual triggers; without it, a skipped dependency | |
| # would skip this job too. | |
| # | |
| # Dependency PRs (category `deps`) are deliberately reviewed rather | |
| # than skipped: auto-merge-dependabot below gates on the review | |
| # result, so skipping them would leave security bumps unmerged. | |
| # | |
| # The draft policy lives here rather than in .claude/review.yml | |
| # (`skip.drafts: false`) because review-cli's draft skip is a single | |
| # boolean and cannot express the claude[bot] carve-out: the | |
| # autonomous-task workflow opens DRAFT PRs and those must still be | |
| # reviewed on open, not only once a human marks them ready. | |
| if: | | |
| always() && ( | |
| ( | |
| github.event_name == 'pull_request' && | |
| needs.check-automated.result == 'success' && | |
| ( | |
| needs.check-automated.outputs.is_automated != 'true' || | |
| needs.check-automated.outputs.category == 'deps' | |
| ) && | |
| ( | |
| github.event.pull_request.draft == false || | |
| github.event.action == 'ready_for_review' || | |
| github.event.pull_request.user.login == 'claude[bot]' | |
| ) | |
| ) || | |
| ( | |
| github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request != null && | |
| contains(github.event.comment.body, '@request-claude-review') && | |
| github.event.comment.user.type != 'Bot' && | |
| contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) | |
| ) || | |
| ( | |
| github.event_name == 'pull_request_review_comment' && | |
| contains(github.event.comment.body, '@request-claude-review') && | |
| github.event.comment.user.type != 'Bot' && | |
| contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) | |
| ) || | |
| github.event_name == 'workflow_dispatch' | |
| ) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # checkout the local composite action | |
| packages: read # pull @uniswap/review-cli from GitHub Packages | |
| issues: write # 👀 reaction on issue_comment | |
| pull-requests: write # 👀 reaction on pull_request_review_comment | |
| outputs: | |
| run: ${{ steps.gate.outputs.run }} | |
| pr_number: ${{ steps.gate.outputs.pr_number }} | |
| comment_id: ${{ steps.gate.outputs.comment_id }} | |
| trigger_source: ${{ steps.gate.outputs.trigger_source }} | |
| note: ${{ steps.gate.outputs.note }} | |
| commenter: ${{ steps.gate.outputs.commenter }} | |
| thread_anchor: ${{ steps.gate.outputs.thread_anchor }} | |
| is_fork: ${{ steps.fork_check.outputs.is_fork }} | |
| # Set only when a running-state reply was posted in this job. The | |
| # review job PATCHes this reply with the terminal verdict. Empty for | |
| # push-driven runs, which have no comment to reply to. | |
| reply_id: ${{ steps.post_reply.outputs.id }} | |
| steps: | |
| - uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 | |
| # Sparse checkout of just the composite action and `.claude/`. The | |
| # gate needs BOTH: the action to install the CLI, and | |
| # `.claude/review.yml` for the skip policy. | |
| # | |
| # Do NOT drop `.claude` and add `--skip-config` to the gate below. | |
| # `--skip-config` passes NO policy at all, which is not the same as | |
| # "the CLI's defaults": `skip.drafts` falls back to `true` | |
| # (skipping the claude[bot] draft PRs this repo must review) while | |
| # branch and author skips are simply not applied. Reading the real | |
| # config is what makes the policy in .claude/review.yml effective. | |
| # | |
| # Deliberately NOT pinned to the default branch, unlike the review | |
| # job's tooling checkout. With no `ref:` this resolves to GITHUB_SHA | |
| # (the merge commit on `pull_request`, the default branch on | |
| # issue_comment / workflow_dispatch), so a PR can influence the skip | |
| # policy the gate reads. That is proportionate here: this job holds | |
| # no CLAUDE_CODE_OAUTH_TOKEN and runs no agent, so the worst case is | |
| # a PR changing its own review eligibility, not code execution with a | |
| # credential. The fork guard below deliberately does not rely on | |
| # config — it resolves the head repo through the API instead. | |
| - name: Checkout repository (config + composite action) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| sparse-checkout: | | |
| .github/actions | |
| .claude | |
| sparse-checkout-cone-mode: false | |
| - name: Install review-cli | |
| id: install-review-cli | |
| uses: ./.github/actions/install_review_cli | |
| with: | |
| # Pinned, never `@latest`, so an upstream release cannot change | |
| # review behavior mid-PR. Override without a commit by setting | |
| # the REVIEW_CLI_VERSION repo variable (Settings → Secrets and | |
| # variables → Actions → Variables). 1.10.x is required for the | |
| # `model.synthesis` key in .claude/review.yml. | |
| version: ${{ vars.REVIEW_CLI_VERSION || '1.10.1' }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # `loadConfig` treats a missing or invalid .claude/review.yml as | |
| # "use defaults" and says nothing. Those defaults skip dependabot, | |
| # every *[bot] author, and drafts — so a botched sparse checkout | |
| # would silently stop reviewing dependency PRs (breaking | |
| # auto-merge) with a green run and no signal. Fail loudly instead. | |
| - name: Verify review config is present | |
| run: | | |
| set -euo pipefail | |
| if [ ! -s .claude/review.yml ]; then | |
| echo "::error::.claude/review.yml missing from the sparse checkout. The triage gate would silently fall back to defaults that skip dependency PRs and drafts." | |
| exit 1 | |
| fi | |
| # `-s` catches missing and empty but NOT malformed, and `loadConfig` | |
| # treats invalid YAML identically to a missing file: defaults, no | |
| # log line. Assert the parse and the value auto-merge depends on. | |
| # Fail-closed: absent python3/PyYAML fails the step rather than | |
| # waving the config through. | |
| python3 - <<'PY' | |
| import sys, yaml | |
| with open('.claude/review.yml') as fh: | |
| cfg = yaml.safe_load(fh) | |
| if not isinstance(cfg, dict): | |
| sys.exit('review.yml did not parse to a mapping') | |
| if cfg.get('skip', {}).get('authors') != []: | |
| sys.exit('skip.authors is not [] — dependency PRs would be skipped at the gate, breaking auto-merge-dependabot') | |
| PY | |
| - name: Decide whether to run | |
| id: gate | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: '"$REVIEW_CLI_BIN/review-cli" triage --from-github-actions' | |
| # A declining gate is otherwise a fully green, fully PR-invisible | |
| # no-review: `review` is skipped and nothing annotates why. That is | |
| # exactly the path a mis-scoped skip prefix or a defaulted config takes, | |
| # so it is the one that must not look like a silent no-op. Mirrors what | |
| # `review-skipped` does for the check-automated path. | |
| - name: Note that the gate declined | |
| if: steps.gate.outputs.run != 'true' | |
| run: echo "::notice::AI review skipped by review-cli triage. See the \"Decide whether to run\" step log for the policy that matched, and .claude/review.yml for the skip block." | |
| # Fork guard. The review job checks out PR head code and runs an | |
| # agent with Bash tool access, so a fork PR must never reach it. | |
| # review-cli's triage has no fork concept, and the issue_comment / | |
| # workflow_dispatch payloads carry no head-repo field, so this is | |
| # resolved via the API rather than from the event. | |
| - name: Verify PR head is not a fork | |
| id: fork_check | |
| if: steps.gate.outputs.run == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ steps.gate.outputs.pr_number }} | |
| run: | | |
| set -euo pipefail | |
| HEAD_REPO=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER" --jq '.head.repo.full_name') | |
| if [ "$HEAD_REPO" != "$GH_REPO" ]; then | |
| echo "::notice::Skipping AI review for fork PR ($HEAD_REPO -> $GH_REPO)" | |
| echo "is_fork=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "is_fork=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Acknowledge a comment trigger with 👀. Only fires when the gate | |
| # accepted the comment — bot self-mentions, untrusted commenters, | |
| # and comments without the trigger phrase leave comment_id empty. | |
| - name: Acknowledge comment trigger (👀) | |
| if: | | |
| steps.gate.outputs.run == 'true' && | |
| steps.fork_check.outputs.is_fork != 'true' && | |
| steps.gate.outputs.comment_id != '' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| COMMENT_ID: ${{ steps.gate.outputs.comment_id }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: | | |
| "$REVIEW_CLI_BIN/review-cli" react \ | |
| --repo "$GH_REPO" \ | |
| --comment-id "$COMMENT_ID" \ | |
| --event "$EVENT_NAME" \ | |
| --reaction ack | |
| # A reply under the trigger comment, carrying the run URL. The 👀 | |
| # reaction is glanceable; this is the responsive signal, and it is | |
| # edited in place with the terminal verdict when the review lands. | |
| # Best-effort: a failed post costs only silence until the ✅/❌. | |
| - name: Post running-state reply | |
| id: post_reply | |
| if: | | |
| steps.gate.outputs.run == 'true' && | |
| steps.fork_check.outputs.is_fork != 'true' && | |
| steps.gate.outputs.comment_id != '' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ steps.gate.outputs.pr_number }} | |
| COMMENT_ID: ${{ steps.gate.outputs.comment_id }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REPLY_BODY: | | |
| ↻ **Reviewing now** · [view run ↗](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) | |
| This comment will update when the review completes. Findings will appear in the sticky summary above. | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| # `set -uo pipefail` without `-e` on purpose: continue-on-error should | |
| # surface a failure as a warning, and an explicit non-zero exit is what | |
| # produces the annotation. Without any of this a failed `reply` fell | |
| # through to the trailing `if`, which returns 0 when the file is | |
| # absent, so the step reported SUCCESS and the failure was invisible. | |
| run: | | |
| set -uo pipefail | |
| OUT="$RUNNER_TEMP/reply-id.txt" | |
| if ! "$REVIEW_CLI_BIN/review-cli" reply \ | |
| --repo "$GH_REPO" \ | |
| --pr "$PR_NUMBER" \ | |
| --event "$EVENT_NAME" \ | |
| --in-reply-to "$COMMENT_ID" \ | |
| --body "$REPLY_BODY" \ | |
| --out-id "$OUT"; then | |
| echo "::warning::could not post the running-state reply; the 👀 reaction is the only acknowledgement this run will give" | |
| exit 1 | |
| fi | |
| if [ -s "$OUT" ]; then | |
| echo "id=$(cat "$OUT")" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "::warning::posted a running-state reply but could not capture its id; it will never be updated with the terminal verdict" | |
| fi | |
| review: | |
| name: AI review | |
| needs: triage | |
| if: needs.triage.outputs.run == 'true' && needs.triage.outputs.is_fork != 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| # `contents: write` (not read) is required for the GraphQL | |
| # resolveReviewThread mutation; `pull-requests: write` alone returns | |
| # "Resource not accessible by integration". | |
| # https://github.com/orgs/community/discussions/44650 | |
| # review-cli never pushes to git (persist-credentials: false below). | |
| contents: write # required by the resolveReviewThread mutation, per above | |
| packages: read # pull @uniswap/review-cli | |
| pull-requests: write # post review comments + resolve threads | |
| issues: write # comment reactions + sticky comment | |
| # Hard ceiling. agent_budget_usd in .claude/review.yml is the real cost | |
| # control; this is only the safety net if the CLI hangs. Set to 25 rather | |
| # than 20 so the step-level ceiling on Analyze (17) always fires FIRST: | |
| # a step timeout is measured from step start and the job timeout from job | |
| # start, so the tail budget is `25 - setup - 17`, and setup is not bounded | |
| # anywhere (Bullfrog, a fetch-depth-0 checkout, a GitHub Packages install, | |
| # and a curl|bash). Measured setup on a real run was 35s, but a slow | |
| # Packages fetch must not be able to invert the two ceilings — see the | |
| # Analyze step for why a job-level timeout firing first strands the sticky. | |
| timeout-minutes: 25 | |
| env: | |
| # `secrets.*` is not a valid context in a step-level `if:`, and | |
| # step-level `env:` is not applied before `if:` is evaluated — but | |
| # job-level env is, so this exposes "is Datadog configured?" to the | |
| # optional telemetry step below. | |
| HAS_DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY != '' }} | |
| steps: | |
| - uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 | |
| # Pin the checkout to the PR head. issue_comment and | |
| # workflow_dispatch events default GITHUB_REF to the default | |
| # branch, so an unpinned checkout would review `next` instead of the | |
| # PR and then skip on "all hunks identical". fetch-depth: 0 so the | |
| # CLI can diff against the merge base. | |
| # | |
| # This is the CONTENT UNDER ANALYSIS and nothing more. Every piece of | |
| # tooling that acts on it comes from the trusted checkout below. | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| ref: refs/pull/${{ needs.triage.outputs.pr_number }}/head | |
| # Reviewer tooling from the DEFAULT ref, never the PR head. Two | |
| # separate reasons, both load-bearing: | |
| # | |
| # 1. Availability. `.github/actions/install_review_cli` does not | |
| # exist on branches cut before it landed, so resolving it out of | |
| # the PR head fails with "Can't find 'action.yml'" on every open | |
| # PR until that PR rebases. | |
| # 2. Trust. This job holds CLAUDE_CODE_OAUTH_TOKEN and a | |
| # `contents: write` token, and runs an agent with Bash access | |
| # whose prompts ARE `.claude/agents/*.md`. Sourcing the installer | |
| # shell or the agent set from the head branch would let a PR | |
| # author rewrite both. Note that `.github/actions/**` is NOT | |
| # covered by the `workflow` token scope that guards | |
| # `.github/workflows/**`, so that gate does not help here. | |
| # | |
| # MUST come after the PR-head checkout: `actions/checkout` runs | |
| # `git clean -ffdx` on its target, so checking out the workspace root | |
| # second would delete this directory. | |
| # `ref:` is REQUIRED and must be explicit. Omitting it does not mean | |
| # "the default branch" — checkout falls back to GITHUB_SHA, which on | |
| # a `pull_request` event is the merge commit (base + head), so the | |
| # head branch would still influence the contents and this checkout | |
| # would not be trusted at all. | |
| - name: Checkout review tooling (trusted ref) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| persist-credentials: false | |
| path: .review-tooling | |
| sparse-checkout: | | |
| .github/actions | |
| .claude | |
| sparse-checkout-cone-mode: false | |
| # Swap the head's `.claude` for the trusted copy. review-cli's | |
| # `loadConfig(repoRoot)` takes only a repo root — there is no flag to | |
| # read config from somewhere else — so the config and agent files | |
| # have to be in place on disk before Analyze runs. | |
| # | |
| # Consequence, and it is intended: edits to review.yml or the agent | |
| # files take effect only once merged, so a PR cannot review itself | |
| # with a reviewer set it wrote. Iterate locally with `review-cli dev` | |
| # instead of pushing a commit per change. | |
| # | |
| # Guarded because `loadConfig` treats a missing file as "use | |
| # defaults" and says nothing. Those defaults skip dependency PRs and | |
| # drafts and staff neither repo-specific reviewer, all on a green | |
| # run — the same silent-degradation trap the triage gate guards. | |
| - name: Use trusted review config | |
| run: | | |
| set -euo pipefail | |
| # `.review-tooling/` lives inside GITHUB_WORKSPACE and is untracked, | |
| # and review-cli gates its full-checkout fast path on | |
| # `git status --porcelain` being empty. Left visible to git, the tree | |
| # reads dirty on EVERY run and the CLI silently falls back to | |
| # extracting only the files the diff touched, with no .git — which | |
| # strips the agents' whole-repo Read/Grep/Glob and history access. | |
| # Both reviewers this repo adds depend on out-of-diff reads | |
| # (plugin-conventions globs skills/ dirs, workflow-security greps | |
| # sibling workflows), so the degradation is invisible and total. | |
| echo '/.review-tooling/' >> .git/info/exclude | |
| # `/.claude/` too, for the third way this step dirties the tree. | |
| # The trusted copy lands files that may not be TRACKED at the PR | |
| # head — every `.claude/**` file added after that branch was cut, | |
| # which on this migration's merge day means review.yml and both | |
| # agents for every already-open PR. Those arrive as `??` entries, | |
| # and `git diff --name-only` never lists untracked paths, so the | |
| # assume-unchanged pass below structurally cannot reach them. | |
| # Excluding the directory hides only untracked files; tracked | |
| # modifications still show, so that pass is still needed. | |
| echo '/.claude/' >> .git/info/exclude | |
| for f in review.yml \ | |
| agents/workflow-security-reviewer.md \ | |
| agents/plugin-conventions-reviewer.md; do | |
| if [ ! -s ".review-tooling/.claude/$f" ]; then | |
| echo "::error::.claude/$f missing from the trusted checkout. Analyze would run without it: wrong model, no investigation gate, or a reviewer named in triage.guidance that does not exist on disk." | |
| exit 1 | |
| fi | |
| done | |
| # `-s` catches missing and empty but NOT malformed. `loadConfig` | |
| # treats invalid YAML exactly like a missing file: returns defaults, | |
| # logs nothing. Those defaults skip dependency PRs, every *[bot] | |
| # author, and drafts, which breaks auto-merge-dependabot on a green | |
| # run. Assert the parse AND the one value that gate depends on. | |
| # Fail-closed by design: if python3/PyYAML is absent the step fails | |
| # loudly rather than waving the config through. | |
| python3 - <<'PY' | |
| import sys, yaml | |
| with open('.review-tooling/.claude/review.yml') as fh: | |
| cfg = yaml.safe_load(fh) | |
| if not isinstance(cfg, dict): | |
| sys.exit('review.yml did not parse to a mapping') | |
| if cfg.get('skip', {}).get('authors') != []: | |
| sys.exit('skip.authors is not [] — dependency PRs would be skipped, breaking auto-merge-dependabot') | |
| if not cfg.get('model', {}).get('default'): | |
| sys.exit('model.default missing — every reviewer would fall back to the CLI default model') | |
| PY | |
| # Known consequence of the swap, accepted: restoring the clean tree | |
| # also re-enables review-cli's post-synthesis `verifyFindings` pass, | |
| # which is gated on workspaceShape == 'working-tree' and was | |
| # therefore dead in CI while the tree was always dirty. It resolves | |
| # cited files from this workspace, where `.claude` is now the | |
| # pre-PR copy — so on a PR that ADDS a `.claude/**` file, a finding | |
| # against it is dropped as "file not readable at HEAD", and on one | |
| # that lengthens a file, a finding past the trusted copy's EOF is | |
| # dropped as "beyond file end". Both drops are logged, not silent, | |
| # and only reviewer-tuning PRs can hit them. A carve-out needs an | |
| # upstream change; do not "fix" it by skipping the swap, which | |
| # would hand config and agent prompts back to the PR author. | |
| rm -rf .claude | |
| cp -R .review-tooling/.claude .claude | |
| # The trusted copy reverts any `.claude/**` the PR edited, leaving | |
| # those TRACKED files dirty — which .git/info/exclude cannot cover | |
| # and which would re-trigger the extraction fallback above. Hide | |
| # exactly those paths from `git status` so PRs touching .claude/** | |
| # still get whole-repo reads. | |
| git diff --name-only -- .claude | xargs -r git update-index --assume-unchanged | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "::warning::working tree is not clean; review-cli will extract diff-only files and agents will lose whole-repo reads" | |
| git status --porcelain | |
| fi | |
| - name: Install review-cli | |
| id: install-review-cli | |
| uses: ./.review-tooling/.github/actions/install_review_cli | |
| with: | |
| version: ${{ vars.REVIEW_CLI_VERSION || '1.10.1' }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # Fail loudly rather than silently degrading: without a token the | |
| # agent-query factory falls back to a mock and "reviews" nothing. | |
| # Only CLAUDE_CODE_OAUTH_TOKEN is forwarded (subscription auth) — | |
| # ANTHROPIC_API_KEY is deliberately never passed to this job. | |
| - name: Verify Claude OAuth token present | |
| env: | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| run: | | |
| if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then | |
| echo "::error::Set CLAUDE_CODE_OAUTH_TOKEN as a repo secret to enable AI review." | |
| exit 1 | |
| fi | |
| # Install the Claude Code native binary. | |
| # | |
| # As of @anthropic-ai/claude-agent-sdk@0.2.113 the SDK spawns a | |
| # per-platform native binary shipped via optional deps. Bun on Linux | |
| # installs metadata for every variant but extracts only the host | |
| # one, while the SDK's resolver probes `linux-<arch>-musl` first — so | |
| # require.resolve returns a path that isn't on disk and the SDK fails | |
| # with "Claude Code native binary not found". Installing via the | |
| # official script and pointing the SDK at it with | |
| # CLAUDE_CODE_EXECUTABLE_PATH sidesteps the optional-dep dance. | |
| # | |
| # SECURITY: this is the one un-pinned external fetch here. Hashing | |
| # install.sh would not help — it is a per-host bootstrapper that | |
| # selects a binary for the runner's libc/arch at install time, so a | |
| # fixed hash on the script gives no integrity guarantee for the | |
| # binary that lands on disk, and Anthropic rotates the bootstrapper | |
| # independently of binary versions. Compensating controls: Bullfrog | |
| # egress monitoring is active on this job, the forwarded credential | |
| # is a subscription token scoped to Claude Code rather than metered | |
| # API spend, and ANTHROPIC_API_KEY is never present. The asymmetry | |
| # with the rest of this SHA-pinned workflow is deliberate. | |
| - name: Install Claude Code binary | |
| run: | | |
| set -euo pipefail | |
| curl -fsSL https://claude.ai/install.sh | bash | |
| for candidate in "$HOME/.local/bin/claude" "$HOME/.claude/bin/claude" "$HOME/.npm-global/bin/claude"; do | |
| if [ -x "$candidate" ]; then | |
| CLAUDE_BIN="$candidate"; break | |
| fi | |
| done | |
| if [ -z "${CLAUDE_BIN:-}" ]; then | |
| CLAUDE_BIN="$(command -v claude || true)" | |
| fi | |
| if [ -z "$CLAUDE_BIN" ] || [ ! -x "$CLAUDE_BIN" ]; then | |
| echo "::error::claude binary not found after install"; exit 1 | |
| fi | |
| echo "CLAUDE_CODE_EXECUTABLE_PATH=$CLAUDE_BIN" >> "$GITHUB_ENV" | |
| dirname "$CLAUDE_BIN" >> "$GITHUB_PATH" | |
| # Upsert a "review running" sticky so the PR shows in-progress state | |
| # from t=0. Best-effort: a GitHub API blip here must not skip | |
| # Analyze. Without continue-on-error a non-zero exit would | |
| # short-circuit every later step (they default to `if: success()`) | |
| # and Post would have no last-run.json to recover from. | |
| - name: Pre — running sticky placeholder | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ needs.triage.outputs.pr_number }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: '"$REVIEW_CLI_BIN/review-cli" post "$PR_NUMBER" --repo "$GH_REPO" --pre' | |
| # Analyze. Reads the diff, runs the agents, persists last-run.json. | |
| # | |
| # review-cli's own pipeline does not write to GitHub here: the analyze | |
| # path is never handed a GitHub writer, which is why `post` below is the | |
| # only verb that writes review STATE — findings, thread resolutions, the | |
| # sticky. (The react/reply steps in this workflow also write to the PR, | |
| # but only reactions and the trigger reply.) That is a property of the | |
| # CLI, NOT of this step's environment — the job declares | |
| # `contents: write`, so GITHUB_TOKEN here IS write-capable and the agent | |
| # has Bash access. Do not cite this comment to justify relaxing the fork | |
| # guard. | |
| # | |
| # It does READ the diff via `gh`, which needs GITHUB_TOKEN; without it | |
| # the diff comes back empty and the run short-circuits on "PR has no | |
| # changes". | |
| # | |
| # Trigger context arrives from the triage job's outputs through | |
| # `env:` and is appended as argv entries, never interpolated into the | |
| # script, so a malicious comment body cannot break out into the shell. | |
| # | |
| # `--force` is passed for explicit human requests (comment trigger, or | |
| # manual dispatch with force_review). It does more than skip rebase | |
| # detection: it also bypasses both diff size guards, re-staffs reviewers | |
| # from scratch rather than reusing the prior agentSet, and — because no | |
| # --budget/--max-turns is passed — substitutes the CLI's force defaults | |
| # ($20 per agent, 200 turns) for `agent_budget_usd`/`agent_max_turns` in | |
| # .claude/review.yml, which therefore bind on push-driven runs only. | |
| # | |
| # `--fresh` is deliberately NOT used: it would also discard prior | |
| # findings and thread decisions, losing the iterative review context | |
| # that makes re-reviews coherent. | |
| - name: Analyze | |
| # Step ceiling, deliberately BELOW the job's 25-minute | |
| # `timeout-minutes`. A job-level timeout CANCELS the job, and | |
| # `if: success() || failure()` on Post does not match a cancelled | |
| # run — so an overrun here would skip Post entirely, leaving the | |
| # `--pre` sticky stuck on "⏳ Review running" while the `always()` | |
| # reaction step flips to ❌. Timing out at the step level makes the | |
| # overrun a failure, which Post's gate does match. Do NOT "fix" | |
| # that gate with `always()` instead: a concurrency-cancelled run | |
| # would then overwrite the sticky its successor is mid-way through | |
| # writing. | |
| # | |
| # This must fire before the job's 25-minute ceiling. Tail budget is | |
| # `25 - setup - 17`, NOT a flat 3 minutes: a step timeout runs from | |
| # step start, the job timeout from job start. Measured setup was 35s, | |
| # leaving ~7 minutes for Post and the uploads (which took 13s). | |
| timeout-minutes: 17 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| REVIEW_NOTE: ${{ needs.triage.outputs.note }} | |
| REVIEW_TRIGGER_SOURCE: ${{ needs.triage.outputs.trigger_source }} | |
| REVIEW_COMMENTER: ${{ needs.triage.outputs.commenter }} | |
| REVIEW_THREAD_ANCHOR: ${{ needs.triage.outputs.thread_anchor }} | |
| # Pin where the CLI writes the run artifact so the Upload and | |
| # Datadog steps can read it back. The CLI default is `./`, which | |
| # would silently drop both consumers. | |
| REVIEW_CLI_ARTIFACT_PATH: ${{ runner.temp }}/review-cli-output.json | |
| # Per-agent cost records, consumed by the agent-scorecard | |
| # aggregator. | |
| REVIEW_CLI_TOKENS_ARTIFACT_PATH: ${{ runner.temp }}/agent-tokens.jsonl | |
| # Datadog telemetry emitted by the CLI during the run. Required | |
| # for npm-installed consumers: only the standalone binary from | |
| # GitHub Releases has credentials baked at build time, so | |
| # without this passthrough the client is a silent no-op. | |
| DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} | |
| DD_SITE: ${{ vars.DATADOG_SITE || 'datadoghq.com' }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ needs.triage.outputs.pr_number }} | |
| FORCE_REVIEW: ${{ (github.event_name == 'workflow_dispatch' && inputs.force_review) || needs.triage.outputs.trigger_source == 'comment' || needs.triage.outputs.trigger_source == 'review_comment' }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: | | |
| set -uo pipefail | |
| args=() | |
| [ -n "$REVIEW_NOTE" ] && args+=(--note "$REVIEW_NOTE") | |
| [ -n "$REVIEW_COMMENTER" ] && args+=(--commenter "$REVIEW_COMMENTER") | |
| [ -n "$REVIEW_THREAD_ANCHOR" ] && args+=(--thread-anchor "$REVIEW_THREAD_ANCHOR") | |
| [ "$FORCE_REVIEW" = "true" ] && args+=(--force) | |
| "$REVIEW_CLI_BIN/review-cli" review "$PR_NUMBER" \ | |
| --repo "$GH_REPO" \ | |
| --trigger-source "$REVIEW_TRIGGER_SOURCE" \ | |
| "${args[@]}" | |
| # Post — the only verb that writes. Reads last-run.json, posts new | |
| # findings, resolves fixed threads, upserts the sticky. Runs even | |
| # when Analyze failed so it can publish the most recent saved state. | |
| - name: Post | |
| # `bin-path` is empty whenever the install step never ran — which | |
| # happens when an earlier step (the trusted-config guard) failed. | |
| # Without this check, Post runs anyway and dies on | |
| # `/review-cli: No such file or directory` (exit 127), replacing the | |
| # real error with a meaningless one. Skip instead, so the failure | |
| # the developer sees is the one that actually happened. | |
| if: | | |
| (success() || failure()) && | |
| steps.install-review-cli.outputs.bin-path != '' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ needs.triage.outputs.pr_number }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: '"$REVIEW_CLI_BIN/review-cli" post "$PR_NUMBER" --repo "$GH_REPO"' | |
| - name: Upload run artifact | |
| # Upload even on failure: the artifact holds whatever events were | |
| # captured up to the failure, which is the most useful post-hoc | |
| # debugging surface. | |
| if: always() | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: review-cli-run-${{ needs.triage.outputs.pr_number }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/review-cli-output.json | |
| if-no-files-found: warn | |
| retention-days: 30 | |
| # Name MUST be exactly `agent-tokens` — the agent-scorecard | |
| # aggregator finds it across consumer repos with one | |
| # `gh run download` call. | |
| - name: Upload agent-tokens artifact | |
| if: always() | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: agent-tokens | |
| path: ${{ runner.temp }}/agent-tokens.jsonl | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| overwrite: true | |
| # Optional: stamp the CI Visibility pipeline span with the review | |
| # outcome so the GitHub Actions run is filterable by verdict in | |
| # Datadog. Additive — the CLI already ships metrics over HTTPS. | |
| # Skipped entirely when DATADOG_API_KEY is unset. | |
| - name: Stamp DD CI Visibility with review outcome | |
| if: always() && env.HAS_DATADOG_API_KEY == 'true' | |
| env: | |
| DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }} | |
| DATADOG_SITE: ${{ vars.DATADOG_SITE || 'datadoghq.com' }} | |
| REVIEW_TEAM: ${{ vars.REVIEW_DD_TEAM || '' }} | |
| ARTIFACT: ${{ runner.temp }}/review-cli-output.json | |
| run: | | |
| set -uo pipefail | |
| if [ ! -s "$ARTIFACT" ]; then | |
| echo "no review-cli artifact at $ARTIFACT — skipping DD CI tagging" | |
| exit 0 | |
| fi | |
| VERDICT=$(jq -r '.review.verdict // "unknown"' "$ARTIFACT") | |
| DEPTH=$(jq -r '.review.depth // "unknown"' "$ARTIFACT") | |
| FINDINGS=$(jq -r '(.review.findings // []) | length' "$ARTIFACT") | |
| # `review.clean`, not `review.skipped`: a skipped run writes no | |
| # artifact at all, so reaching this step means a real review | |
| # completed. APPROVE with zero findings is clean, not skipped — | |
| # and trivial_threshold: 0 in .claude/review.yml disables the | |
| # size-skip path outright. | |
| CLEAN=$([ "$FINDINGS" = 0 ] && [ "$VERDICT" = "APPROVE" ] && echo true || echo false) | |
| TAGS=( | |
| --tags "service:review-cli" | |
| --tags "review.verdict:$VERDICT" | |
| --tags "review.depth:$DEPTH" | |
| --tags "review.clean:$CLEAN" | |
| ) | |
| if [ -n "$REVIEW_TEAM" ]; then | |
| TAGS+=(--tags "team:$REVIEW_TEAM") | |
| fi | |
| bunx @datadog/datadog-ci@5.15.0 tag --level pipeline "${TAGS[@]}" \ | |
| || echo "datadog-ci tag failed (non-fatal)" | |
| bunx @datadog/datadog-ci@5.15.0 measure --level pipeline \ | |
| --measures "review.findings:$FINDINGS" \ | |
| || echo "datadog-ci measure failed (non-fatal)" | |
| # Swap the 👀 ack for ✅/❌. --clear-prior-ack removes the eyes | |
| # first so the comment carries a single terminal signal. | |
| # | |
| # `!cancelled()` is load-bearing. `always()` is the one gate that runs on | |
| # cancellation, and `job.status` is `cancelled` there — which the | |
| # success/failure branch below would render as ❌. Concurrency cancels | |
| # this run whenever a push supersedes a comment-triggered review, so | |
| # without this the requester gets a ❌ on a review that a successor is | |
| # about to complete. | |
| # | |
| # Note what skipping does NOT do: no successor replaces the 👀. Only a | |
| # `pull_request` run can cancel this one (`cancel-in-progress` is scoped | |
| # to that event), and such a run has an empty `comment_id`, so it never | |
| # reaches this step. The ack stays until someone clears it. That is the | |
| # deliberate trade — a stale 👀 is better than a false ❌, and the reply | |
| # step below carries the real "Superseded" signal. | |
| - name: Update reaction (✅ / ❌) on comment trigger | |
| if: | | |
| always() && !cancelled() && | |
| needs.triage.outputs.comment_id != '' && | |
| steps.install-review-cli.outputs.bin-path != '' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| OUTCOME: ${{ job.status }} | |
| GH_REPO: ${{ github.repository }} | |
| COMMENT_ID: ${{ needs.triage.outputs.comment_id }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: | | |
| REACTION=$([ "$OUTCOME" = "success" ] && echo "success" || echo "failure") | |
| "$REVIEW_CLI_BIN/review-cli" react \ | |
| --repo "$GH_REPO" \ | |
| --comment-id "$COMMENT_ID" \ | |
| --event "$EVENT_NAME" \ | |
| --reaction "$REACTION" \ | |
| --clear-prior-ack | |
| # Edit the running-state reply with the terminal verdict so the | |
| # requester gets feedback without scrolling back to the sticky. | |
| - name: Update threaded reply with terminal state | |
| if: | | |
| always() && | |
| needs.triage.outputs.reply_id != '' && | |
| steps.install-review-cli.outputs.bin-path != '' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # `job.status` is one of success / failure / cancelled. The third | |
| # value is why this is not a two-way branch: the step runs under | |
| # `always()`, which fires on cancellation too, and concurrency | |
| # cancels a comment-triggered run whenever a push supersedes it. | |
| # Reporting that as "Review failed" would tell the requester to | |
| # retry a review their successor is about to finish. | |
| # | |
| # `cancelled()` is deliberately NOT used here — it is only valid in | |
| # a job or step `if:`, not in `env:` (actionlint catches this). | |
| OUTCOME: ${{ job.status }} | |
| GH_REPO: ${{ github.repository }} | |
| REPLY_ID: ${{ needs.triage.outputs.reply_id }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| REVIEW_CLI_BIN: ${{ steps.install-review-cli.outputs.bin-path }} | |
| run: | | |
| if [ "$OUTCOME" = "cancelled" ]; then | |
| BODY="↻ **Superseded** · a newer push took over this review · [view run ↗](${RUN_URL})" | |
| elif [ "$OUTCOME" = "success" ]; then | |
| BODY="✅ **Reviewed** · [view run ↗](${RUN_URL}) · scroll up for the full summary." | |
| else | |
| BODY="⚠ **Review failed** · [view run ↗](${RUN_URL}) · scroll up for the partial state, or comment \`@request-claude-review\` to retry." | |
| fi | |
| "$REVIEW_CLI_BIN/review-cli" reply \ | |
| --repo "$GH_REPO" \ | |
| --event "$EVENT_NAME" \ | |
| --edit-reply "$REPLY_ID" \ | |
| --body "$BODY" | |
| # Report automated PRs that were filtered out, so the run explains | |
| # itself rather than looking like a silent no-op. | |
| review-skipped: | |
| needs: check-automated | |
| if: | | |
| github.event_name == 'pull_request' && | |
| needs.check-automated.outputs.is_automated == 'true' && | |
| needs.check-automated.outputs.category != 'deps' | |
| runs-on: ubuntu-latest | |
| # No checkout, no API calls — this job runs Bullfrog and two echoes, and | |
| # fetching a SHA-pinned external action needs no token scope. `{}` is the | |
| # real floor, matching the workflow-level default above. | |
| permissions: {} | |
| steps: | |
| - uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 | |
| - name: Report skipped review | |
| env: | |
| SKIP_REASON: ${{ needs.check-automated.outputs.skip_reason }} | |
| run: | | |
| echo "::notice::Code review skipped for automated PR: $SKIP_REASON" | |
| echo "✅ Automated PR detected - code review is not required for this PR type." | |
| # Auto-merge Dependabot security updates once the review succeeds. | |
| # Depends on `review` rather than the retired reusable-workflow job. | |
| auto-merge-dependabot: | |
| needs: review | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.pull_request.user.login == 'dependabot[bot]' && | |
| (contains(github.event.pull_request.title, 'security') || contains(github.event.pull_request.title, 'Bump')) && | |
| needs.review.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # enable auto-merge on the PR | |
| pull-requests: write # `gh pr merge --auto` updates the PR's merge state | |
| steps: | |
| - uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3 # v0.8.4 | |
| - name: Enable auto-merge for Dependabot updates | |
| run: gh pr merge --auto --squash "$PR_NUMBER" | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} |