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
111 changes: 111 additions & 0 deletions .github/workflows/claude_review.yml
Original file line number Diff line number Diff line change
@@ -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 = [
'<details><summary>Claude review run details</summary>',
'',
'```json',
JSON.stringify(stats, null, 2),
'```',
'',
'</details>',
].join('\n');
await core.summary.addRaw(body).write();
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.
Expand All @@ -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`.
Loading