diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml new file mode 100644 index 0000000..9d5d6d4 --- /dev/null +++ b/.github/workflows/claude_review.yml @@ -0,0 +1,111 @@ +name: Claude review +# Runs a Claude code review on a pull request, when someone comments "@claude review" on it. +# Consumers: uses: Duatic/ci-workflows/.github/workflows/claude_review.yml@v1 +# +# Owns everything that shouldn't be re-decided per repo: +# - the trigger phrase and the guard that keeps ordinary PR chatter from starting a runner +# - the model and review depth (opus / medium effort) - deliberately not configurable +# - review-only enforcement: the job token cannot push and Claude gets no write tools +# - auto-cancelling a superseded review on the same PR +# +# Consumers only choose their runner, and supply an ANTHROPIC_API_KEY secret. + +on: + workflow_call: + inputs: + runner: + description: 'Runner label(s) to run the job on, e.g. "ubuntu-latest" or "self-hosted".' + default: 'self-hosted' + required: false + type: string + secrets: + # Supply exactly one of these as a repo secret and pass via `secrets: inherit`. + CLAUDE_CODE_OAUTH_TOKEN: + description: 'Claude subscription token from `claude setup-token` (valid 1 year). Usage draws on that account''s plan allowance rather than being billed per token.' + required: false + ANTHROPIC_API_KEY: + description: 'Claude API key. Alternative to CLAUDE_CODE_OAUTH_TOKEN; billed per token to the Console organization.' + required: false + +concurrency: + # A second request on the same PR supersedes an in-flight review of it. + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + name: Review + # Only fire for a PR comment that starts with the trigger phrase. + if: >- + github.event.issue.pull_request && + startsWith(github.event.comment.body, '@claude review') + runs-on: ${{ inputs.runner }} + permissions: + contents: read # review-only: the job token cannot push + pull-requests: write # ...but it can comment + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - id: claude + uses: anthropics/claude-code-action@v1 + with: + # Whichever of the two the consumer repo has set; the other is empty and ignored. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + use_sticky_comment: true + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.issue.number }}' + # `medium` effort reports only the findings the reviewer is confident in, which + # keeps false positives low. --allowedTools deliberately omits Edit/Write/Bash: + # combined with `contents: read` above, there is no path to changing the code. + claude_args: | + --model opus + --effort medium + --max-turns 20 + --allowedTools "Read,Grep,Glob,mcp__github_inline_comment__create_inline_comment,mcp__github_comment__*" + + - name: Report run details + if: always() + uses: actions/github-script@v9 + env: + EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} + with: + script: | + const fs = require('fs'); + const file = process.env.EXECUTION_FILE; + if (!file || !fs.existsSync(file)) { + core.info('No execution file produced - nothing to report.'); + return; + } + let stats; + try { + const log = JSON.parse(fs.readFileSync(file, 'utf8')); + const last = Array.isArray(log) ? log[log.length - 1] : log; + // Report whatever Claude Code recorded for the run (cost, tokens, duration, + // turns, ...), minus `result` - that's the review prose, already posted above. + const { result, ...rest } = last ?? {}; + stats = rest; + } catch (error) { + core.info(`Could not parse execution file: ${error.message}`); + return; + } + const body = [ + '
Claude review run details', + '', + '```json', + JSON.stringify(stats, null, 2), + '```', + '', + '
', + ].join('\n'); + await core.summary.addRaw(body).write(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body, + }); diff --git a/README.md b/README.md index fb97c49..eeb8213 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,14 @@ GitHub Release with auto-generated notes. ## What consumers call -Repos only ever call **`ci_orchestrator.yml`** - it owns the distro matrix, the `ROS_REPO` channel -per distro, gating, concurrency, and the draft-PR policy, and internally drives the two leaf +For CI, repos only ever call **`ci_orchestrator.yml`** - it owns the distro matrix, the `ROS_REPO` +channel per distro, gating, concurrency, and the draft-PR policy, and internally drives the two leaf workflows (`reusable_ici.yml`, `pre-commit.yml`) that most repos never reference directly. +**`claude_review.yml`** is the other consumer-facing workflow, and is unrelated to CI: it runs a +Claude code review on a PR when someone comments `@claude review` on it. It's opt-in per repo and +never runs on its own. See [Claude code review](#claude-code-review-claude_reviewyml). + ### `ci_orchestrator.yml` - consumer-facing entry point | Input | Required | Default | Description | @@ -121,6 +125,42 @@ cron, concurrent writers hitting the *same* gist trip GitHub's secondary (abuse) after coalescing each repo down to one write. The abuse limit is sensitive to concurrent writes against one resource, not just total call volume. +## Claude code review + +Comment this on any open pull request: + +``` +@claude review +``` + +A Claude code review runs against that PR and posts its findings as inline comments on the +lines it has something to say about, plus a summary comment. It's the same reviewer as the +`/code-review` command in the Claude Code CLI, so findings are calibrated the same way as +what you see locally. + +Nothing about the request is configurable. The comment takes no arguments, and the workflow +fixes the model to `opus` and the review depth to `medium` effort. `medium` reports only the +findings the reviewer is confident in, which keeps false positives low. + +**Reviews are never automatic.** They only run when someone asks, so no PR costs anything +unless a developer wants a review on it. Each run posts a collapsed *Claude review run +details* comment with what Claude Code recorded for it. Note that the +dollar figure is computed locally from token counts at list rates: on subscription auth it's what the review *would* have cost on the API, not a charge. + +**The review can only read.** The job's token gets `contents: read`, and Claude is given no +`Edit`, `Write`, or `Bash` tools, so a review cannot modify code, commit, or open a PR. + +| Input | Required | Default | Description | +|---|---|---|---| +| `runner` | no | `self-hosted` | Runner label(s) to run the review on, e.g. `ubuntu-latest`. | + +| Secret | Required | Description | +|---|---|---| +| `CLAUDE_CODE_OAUTH_TOKEN` | one of | Claude subscription token from `claude setup-token`. Usage draws on that account's plan allowance. | +| `ANTHROPIC_API_KEY` | one of | Claude API key. Billed per token to the Console organization instead. | + +Set whichever one you want as a repo secret. If both are set, the API key wins. + ## Leaf workflows (internal, not called directly by product repos) - **`reusable_ici.yml`** - the upstream `ros-industrial` industrial_ci template, builds one distro/channel combination. Auto-detects `repos.list`, `Aptfile`, and `requirements.txt`. @@ -129,3 +169,4 @@ one resource, not just total call volume. ## Requirements on consumer repos - Repos using a private-dependency PAT must have a secret available (repo or org level) and pass `secrets: inherit`. - Repos opting into gist-backed badges must have a `GIST_TOKEN` secret available (repo or org level) and pass `secrets: inherit`. +- Repos opting into `claude_review.yml` must have their own `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) repo secret and pass `secrets: inherit`.