A GitHub Action that reviews pull requests in Articore repositories with Claude Code.
This repository holds the logic: the action, its helper commands and the local runner. The review rules are in the private redbubble/claude-code-reviews-rules, and so are the architecture decision records for both repositories. The action is built for Articore's repositories, and won't work elsewhere: it needs our GitHub App, secrets and rules.
Add .github/workflows/claude-code-review.yml to the repository:
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
permissions:
contents: read
pull-requests: write
issues: read
jobs:
claude-review:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: redbubble/claude-code-reviews@master
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
app-private-key: ${{ secrets.REVIEW_APP_PRIVATE_KEY }}
atlassian-client-secret: ${{ secrets.ATLASSIAN_CLIENT_SECRET }}Then:
- Optionally, add a
REVIEW.mdat the repository root with rules the baseline doesn't cover (see A repository's own rules). Without one, the baseline applies on its own. - Add a
CODEOWNERSentry coveringREVIEW.mdand.github/workflows/, so changes that weaken the review need an owner's approval. - Open a pull request. The review runs on that PR, so you can check the setup works before merging.
With this workflow, a review runs when a PR is opened, reopened, marked ready for review, or pushed to, and a new push cancels a review still running on the previous one. To change when it runs, see Configuring the workflow. Draft PRs aren't reviewed until they're marked ready.
This relies on the one-time setup in CI Bootstrapping.
Pull requests that only hold an architecture decision record aren't reviewed: they're discussed
on the PR instead. A PR counts as one when its branch name starts with adr- and every file it
changes, including a renamed file's old name, is under docs/adr/. Any other change from an
adr- branch is reviewed as usual. A PR the review skips still passes the claude-review check,
so a required check doesn't block it.
A REVIEW.md at the repository root adds rules for that repository on top of the baseline, and
takes precedence where the two conflict. Keep it short, and only add what the baseline doesn't
cover:
# Review instructions
Rules specific to this repository. They apply on top of the Articore review baseline and take
precedence where the two conflict.
## Context
- What this service does, and what data classification it handles (Level 1-4).
- Internet-facing? Handles payments, auth or customer PII?
## What Important also means here
- e.g. "Any change to `app/payments/` without a request spec is Important."
## Always check
- e.g. "New Sidekiq jobs must be idempotent and set `retry:` explicitly."
## Do not report
- e.g. "`src/gen/` is generated from the OpenAPI spec."Each of these is a change to the workflow in Usage, made through a pull request like any other workflow change.
To review a pull request only when someone asks for it, replace the on: block with a labeled
trigger, and skip every other label:
on:
pull_request:
types: [labeled]
jobs:
claude-review:
if: github.event.label.name == 'claude-review'
# ...the rest of the job as in UsageAdd the claude-review label to a pull request to review it. To review a later push, remove the
label and add it again. The if: matters: without it, adding any other label starts a job that
cancels a review in progress.
@master runs the latest action on every review. To choose when the action changes, use a full
40-character commit SHA from this repository's master instead:
- uses: redbubble/claude-code-reviews@0123456789abcdef0123456789abcdef01234567We keep the action working with every rules version, so most repositories leave this on
master and pin the rules instead.
The rules follow master of redbubble/claude-code-reviews-rules by default, so a rule change
applies everywhere on the next review. To choose when your repository takes new rules, pin a
commit with rules-ref:
with:
# ...the inputs as in Usage
rules-ref: 0123456789abcdef0123456789abcdef01234567 # review baseline v0.11Use the merge commit on master of the baseline version you want: the version is in the title of
RULES.md and in the commit message. Use the full 40-character SHA: a short one won't check out.
Say which version it is in a comment.
To update, change the SHA in a pull request. The review on that pull request uses the new rules,
so you see how they behave on your code before merging. To go back to following every change,
remove rules-ref. A pinned repository gets no rule changes until it updates, fixes included,
and nothing tells it a newer version exists.
The review uses claude-opus-5-5. To use another model, add model with a model ID:
with:
# ...the inputs as in Usage
model: claude-sonnet-5-5The value is pasted into claude_args, which the action splits into separate flags, so the
review fails unless it's a single model ID with no spaces or quotes. Otherwise it could add flags
such as a wider --allowedTools. A model ID that doesn't exist also fails the review rather than
falling back to the default.
A review posts at most 5 new findings, of every severity together. Important findings come
first and are always posted, even past the limit; the rest fill what's left, most impactful
first. The summary says how many were left out. To set your own limit, add max-findings:
with:
# ...the inputs as in Usage
max-findings: 8It must be a number from 1 to 999, and it's a hard limit: Important findings still come first,
but no more than that are posted, even when that leaves some Important ones out. Rules versions
up to v0.14 ignore it and cap Nits at 5 instead. For local reviews, set
CLAUDE_REVIEW_MAX_FINDINGS.
When a later push fixes an issue the review raised, the review closes its thread. How it closes it depends on one permission in the workflow, and each repository chooses:
contents: read (the default in Usage) |
contents: write |
|
|---|---|---|
| A fixed finding's thread | Stays open. Its first comment is hidden as Resolved, so it collapses on the PR. | Is resolved. |
| "Require conversation resolution before merging" | Each fixed thread still needs a person to resolve it before the PR can merge. | Fixed threads no longer block the merge. |
| If a prompt injection leaks the run's token | Whoever has it can comment on, label or edit pull requests in this repository until the job ends. | They can also push branches and commits to this repository until the job ends. |
GitHub won't resolve threads without contents: write. The review can't push with it: none of its
allowed tools write files or push. But it can read its own environment and post what it reads,
so a prompt injection in what it reviews (a PR description, a dependency's release notes, code
from another repository) could leak the token while the job runs.
To switch, change contents: read to contents: write. Keep contents: read if branch
protection doesn't require resolved conversations, or if a leaked token's push access matters
more to you than clicking Resolve on fixed threads.
On every pull request it reviews, the action:
- Creates read-only tokens from the review GitHub App, one for each organisation, and passes
them to Claude as
RB_READ_TOKEN(redbubble) andTP_READ_TOKEN(teepublic). It also exchanges the Atlassian service account's OAuth credential atauth.atlassian.comfor an access token that lasts an hour, passed asATLASSIAN_READ_TOKEN(see Whyatlassian-read). - Checks out the rules from redbubble/claude-code-reviews-rules, at
masteror the pinned commit, into.claude-code-reviews/with the redbubble token. If the checkout fails orRULES.mdis missing, the job fails and there's no review. It puts this action'sbin/on thePATH. - Runs
anthropics/claude-code-action. Claude readsRULES.mdfirst, then the other rule files in the orderRULES.mdgives, and reviews the PR against them plus the repository's ownREVIEW.md. It reads other repositories in either organisation, and public repositories anywhere on GitHub (upstream changelogs and release notes, say), withgh-readwhen the PR refers to them, the Jira issues and Confluence pages it refers to withatlassian-read, and handles its earlier review threads withgh-review-threads. - Posts inline comments and a summary as
github-actions[bot], using the run's ownGITHUB_TOKEN. It hides the earlier summaries and sets the PR'sclaude-review:*labels to match its open findings.
Changes merged to master, here or in the rules repository, reach every repository that follows
master on its next review.
scripts/run-local.sh reviews your branch and uncommitted changes before you open a pull
request. It prints the review and changes nothing.
You need:
-
Docker, running.
-
A clone of this repository, and of redbubble/claude-code-reviews-rules next to it (as
../claude-code-reviews-rules), or setCLAUDE_REVIEW_RULESto where your rules clone is. The review uses the helpers in this clone and the rules in that one, whatever branches they're on, sogit pullboth to get the latest. The script says when the rules clone isn't onmaster, and you can use that to try a rule change on real code before opening its pull request. -
An Anthropic credential. If
ANTHROPIC_API_KEYis set, the script checks it with Anthropic and uses it. Otherwise, the first time you run it, it runsclaude setup-tokenso you can log in with your Claude subscription:- With Claude Code installed on your machine,
setup-tokenopens your browser. Without it,setup-tokenruns in the container, which can't: open the link it prints yourself. - Log in, and paste the code the login page gives you back into
setup-token. setup-tokenprints a token (sk-ant-oat01-…). The script picks it up from the screen and stores it in your macOS Keychain (or~/.config/claude-code-reviews/oauth-tokenelsewhere) for later runs. You don't need to copy it orexportit.
If it can't find the token, it asks you to paste it: the token, not the code. To paste one you already have, run
scripts/run-local.sh --set-token. The token lasts a year; when Anthropic rejects it, the script offers to create a new one. ACLAUDE_CODE_OAUTH_TOKENin your environment is used instead of the stored one. - With Claude Code installed on your machine,
-
Optionally, your own Atlassian API token, so the review can read the Jira issues and Confluence pages the change refers to. Create a scoped token at https://id.atlassian.com/manage-profile/security/api-tokens with the
read:jira-workandread:page:confluencescopes, then setATLASSIAN_READ_TOKENto the token andATLASSIAN_EMAILto your Atlassian email (in your shell profile, say). Without them the review skips Jira and Confluence and says so. -
The
ghCLI, logged in (gh auth login). It's optional, but without it the review can't read the pull request, if there is one, or other repositories the change refers to.
Then run it from anywhere inside the repository you want reviewed:
~/src/claude-code-reviews/scripts/run-local.sh # against origin's default branch
~/src/claude-code-reviews/scripts/run-local.sh origin/develop # against another baseThe first run builds the container image, which takes a minute or two. After that it's reused.
Run scripts/run-local.sh --rebuild after changing the Dockerfile.
It runs the same review in a container, through docker-compose.yml:
- The image has only the tools: Node, Claude Code (pinned in the
Dockerfile), git, gh, jq and ripgrep. - The rules and helpers are mounted read-only from your clones, along with
scripts/review-in-docker.sh, which starts Claude inside the container. Editing them needs no rebuild. - The repository under review is mounted read-only, and Claude can only read files and run
read-only git and gh commands. Your token can usually write, so
gh-readrefuses anything but GET requests, andgitin the container (scripts/git-in-docker.sh) refuses--output, which would letgit diff,logorshowwrite files. Git also ignores any global config, so a written one couldn't make it run a command anyway. - It uses the same default model as CI, read from
action.yml, or the model inCLAUDE_REVIEW_MODELif you set it in your environment. It limits the findings as CI does withoutmax-findings, or toCLAUDE_REVIEW_MAX_FINDINGSif you set it. Your own Claude Code settings,CLAUDE.mdand plugins aren't in the container, so they don't change the review. - What it leaves out: earlier review threads, hiding summaries and labels, which only apply to pull requests.
If you cloned the rules repository before it was renamed from claude-code-reviews, point its
clone at the new name:
git remote set-url origin git@github.com:redbubble/claude-code-reviews-rules.gitClaude's permission checker rejects any command that expands a variable, so Claude can't run
GH_TOKEN=$TP_READ_TOKEN gh api ..., and the action resets GH_TOKEN to the run's
GITHUB_TOKEN before starting Claude. gh-read takes a gh api path and picks the read-only
token itself from the organisation in it (repos/redbubble/... or repos/teepublic/...), so no
command Claude runs contains a token or a variable. It only sends GET requests and allows only
the flags a read needs (-H, --jq, --template, --cache, -i, --paginate, --slurp,
--silent, -X GET), because local reviews give it your own token.
It also reads any other owner's repository, with the redbubble token, as long as it's public, so
the review can read the upstream release notes of a dependency a PR bumps. It checks the
repository's visibility first: in CI the app's token can't see private repositories outside the
two organisations anyway, but locally your own token may. The review reads them this way rather
than with WebFetch, so every request still goes only to api.github.com, where whoever owns
the repository can't see what was asked. The path can't contain .. or %, which could move a
request out of the repository it names.
The review reads the Jira issues and Confluence pages a PR refers to, so it can check the change
does what the issue asks. It can't use an Atlassian MCP server, which needs someone to log in.
Instead it uses a service account's OAuth credential (see
Atlassian service account). A step before Claude exchanges the
client ID (in action.yml) and the client secret (an organisation secret, passed
as atlassian-client-secret) for an access token that lasts an hour, and only that token is
passed to Claude, as ATLASSIAN_READ_TOKEN. There's no login, approval or refresh token: each
run asks for a new token. If the secret is missing or Atlassian refuses it, the step warns and
the review runs without Jira and Confluence.
Claude passes it a reference, never a URL: atlassian-read jira PROJ-123 or
atlassian-read confluence <page ID or URL>. The script builds each request itself, for
Articore's site only (its cloud ID is in the script), and only sends GETs. It turns the issue or
page into text, cuts it at 20,000 characters, and marks it as untrusted content. Locally it uses
your own scoped API token, with basic auth and ATLASSIAN_EMAIL, since personal tokens need it;
in CI it sends the access token as a Bearer token.
Reading and resolving review threads needs GraphQL, and allowing gh api graphql would let Claude
run any mutation with the run's write-capable GITHUB_TOKEN (close or edit PRs, post reviews).
gh-review-threads only lists the PR's threads, and resolves or replies to threads in the same
repository that a Claude reviewer opened. When the workflow doesn't have contents: write,
resolve hides the thread's first comment as resolved instead (see
Resolving or minimising earlier findings).
It also covers two other PR changes the review makes, for the same reason (the alternatives are
raw GraphQL and gh pr edit, which can change anything about the PR):
hide-summarieshides earlier Claude summaries as outdated, so only the latest one stays expanded. A summary is a comment by a Claude reviewer with a line startingRules applied:, which every summary ends with (in a collapsed block from rules v0.15).labelssets the PR'sclaude-review:*labels (important,security,rollback-risk,needs-tests,performance,cost,rules-weakened) to the ones that match its open findings, creating any the repository doesn't have yet. It never touches other labels. When to apply each is in the rules.
| Path | Purpose |
|---|---|
action.yml |
The action: decides whether to review, gets the tokens, checks out the rules and runs Claude. |
bin/gh-read |
gh api for other repositories, with the right organisation's read-only token, and for public repositories anywhere on GitHub. |
bin/atlassian-read |
Reads one Jira issue or Confluence page, as text, with the review's read-only Atlassian token. |
bin/gh-review-threads |
Lists the PR's review threads, and resolves or replies to Claude's own. Hides Claude's earlier summaries and sets the PR's claude-review:* labels. |
Dockerfile, docker-compose.yml |
The container local reviews run in: tools only. |
scripts/run-local.sh |
Runs a local review of the repository you're in. |
scripts/review-in-docker.sh |
Runs inside the container: starts Claude on the review. |
scripts/git-in-docker.sh |
git inside the container: refuses --output, so git can't write files. |
- Changes go through a pull request, approved by a code owner. This repository isn't reviewed by the action in CI: it's public, and the organisation secrets are only available to private repositories. To have Claude review a change, run a local review on its branch.
- Keep the action working with every rules version a repository may have pinned. Add helpers and inputs; don't remove or change them under rules that use them.
- Claude can only run a new helper in
bin/once it's in--allowedToolsinaction.yml. Check it for a way to write files first (see Security notes). - Changes to the rules go to redbubble/claude-code-reviews-rules, and so do decisions about either repository, as ADRs.
In order to access repositories (read only access) in both our orgs, we need to create and install a custom GH app. You'll need an org admin in each organisation for that.
The app already exists and is owned by the teepublic organisation: claude-code-review-org-reader (settings, for teepublic owners and app managers; public page: https://github.com/apps/claude-code-review-org-reader). It can't be moved to redbubble while it's public, and it has to be public to be installed on both organisations.
The table below is how it's configured, for reference or if it ever needs recreating via Settings → Developer settings → GitHub Apps → New GitHub App.
| Setting | Value |
|---|---|
| Webhook | Uncheck Active |
| Repository permissions | Contents: Read-only · Metadata: Read-only (mandatory) · Pull requests: Read-only · Issues: Read-only |
| Organization and account permissions | None |
| Where can this GitHub App be installed? | Any account, so it can be installed on both organisations |
After creating it:
- Copy the App ID from its settings page into both
app-id:lines ofaction.yml. The ID isn't secret. - Under Private keys, generate a private key. This downloads a
.pemfile: you'll store it as a secret in step 3, then delete the local file.
The app never posts anything: comments use each run's GITHUB_TOKEN.
From the app's public page, install it on redbubble and on teepublic, choosing All repositories for each. That lets Claude read any repository a PR refers to, and the redbubble installation covers redbubble/claude-code-reviews-rules, which every review reads the rules from.
In each of redbubble and teepublic: Settings → Secrets and variables → Actions → New organization secret:
| Name | Value |
|---|---|
REVIEW_APP_PRIVATE_KEY |
The whole contents of the .pem file, including the BEGIN/END lines |
ANTHROPIC_API_KEY |
Anthropic API key |
For Repository access, choose the repositories that use the review (or all private repositories).
To review Dependabot PRs too, add both again under Secrets and variables → Dependabot. Dependabot runs can't read Actions secrets, so without them the review fails on every Dependabot PR.
If an organisation restricts which actions can run (Settings → Actions → General), allow
redbubble/claude-code-reviews, anthropics/claude-code-action,
actions/create-github-app-token and actions/checkout.
For atlassian-read. An Atlassian organisation admin sets this up in admin.atlassian.com.
-
A group for its access: under Directory → Groups, create
claude-review-readers. Give it Browse Projects in the Jira permission schemes of the projects reviews should read, and View in the Confluence spaces they should read. Leave out restricted spaces: whatever this group can see may end up summarised in a PR comment. -
The service account: Directory → Service accounts → Create a service account, named e.g.
claudecodereview, with a description naming its owner and this repository. Give it Jira and Confluence access with the basic user role, and add it toclaude-review-readers. -
Its OAuth credential: on the service account, Create credentials → OAuth 2.0, with exactly the scopes
read:jira-workandread:page:confluence(a granular scope: search for it). Scopes can't be changed after it's created. Copy the client ID and client secret: they're shown once. The client ID isn't secret: put it in theCLIENT_ID:line ofaction.yml. -
Check it, with an issue and a page the group can see. Get an access token, then read both with it. 401 means a wrong scope, and 404 a wrong cloud ID or no access to that issue or page:
curl -X POST https://auth.atlassian.com/oauth/token -d grant_type=client_credentials -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" curl -H "Authorization: Bearer $ACCESS_TOKEN" "https://api.atlassian.com/ex/jira/<cloud ID>/rest/api/3/issue/PROJ-123?fields=summary" curl -H "Authorization: Bearer $ACCESS_TOKEN" "https://api.atlassian.com/ex/confluence/<cloud ID>/wiki/api/v2/pages/<page ID>"
The cloud ID is in
atlassian-read, or athttps://<site>.atlassian.net/_edge/tenant_info. -
Store the client secret in each of redbubble and teepublic as the organisation secret
ATLASSIAN_CLIENT_SECRET(and under Dependabot, as in step 3). -
To replace it (it may have leaked, or its owner left): create a new credential, update the secret in both organisations and the client ID in
action.yml, then revoke the old one. Repositories that followmasterget the new client ID on their next review.
- Every repository passes its secrets to this action. Anyone who can push to
masterhere can read them in every repository that followsmaster.masteris protected: every change needs a pull request approved by a code owner, with no bypass. A repository that needs more control can pin the logic to a commit it has read. - The app's private key is the sensitive part. Anyone holding it can read every repository in
both organisations. Org secrets are readable by any workflow in a repository that can access
them, including a
pull_requestworkflow on a branch someone just pushed. LimitREVIEW_APP_PRIVATE_KEYto the repositories that use the review where practical, and rotate it (generate a new key, update both org secrets, delete the old key) if it may have leaked. - Installations by other accounts are harmless. Making the app installable by any account lets others install it on their own accounts. That gives this app read access to their repositories, never them access to ours. The action always asks for the redbubble or teepublic installation by name.
- The review can't write outside the pull request it reviews. The app is read-only. The only
write-capable token is the run's
GITHUB_TOKEN, scoped to that repository. - None of Claude's allowed tools may write a file, push or run arbitrary requests, whatever
the run's
GITHUB_TOKENcan do (contents: writeis opt-in: see Resolving or minimising earlier findings). There's nogit pushor rawgh api, andgh-review-threadsonly resolves, replies to or hides Claude's own comments and sets its own labels. Check any tool you add for a way to write files:git log,git diffandgit showwrite any file with--output, and a written~/.gitconfigmakes the next git command run what it names. That's why CI doesn't allow them.gh pr commentisn't allowed either: its--body-filecan post any readable file. - Tag mode's write tools are switched off.
track_progressputs claude-code-action in tag mode, which adds--permission-mode acceptEdits(file edits and commands such astouchandmvin the workspace) and git add, commit, rm and push tools.action.ymlpasses--permission-mode defaultafter them, so the later flag wins, and denies those tools with--disallowedTools. - Claude can read the run's tokens and post them.
Readreaches its own process environment, and the inline-comment tool posts any text. The GitHub tokens in the job expire when it ends, so a leak of one through a prompt injection lasts for the rest of the job at most. That's whycontents: writeis opt-in. - Claude can see the read-only tokens. They're in its environment so it can use them. The GitHub tokens expire within an hour and are revoked when the job ends. The Atlassian access token isn't revoked: it lasts its full hour.
- The Atlassian client secret never reaches Claude. Only the hour-long access token made from
it does, so a leaked token reads what
claude-review-readerscan see for an hour at most. Keep that group's access to what reviews need. The client secret is long-lived: likeREVIEW_APP_PRIVATE_KEY, any workflow that can read the org secret can use it, so limit its repository access the same way, and replace it if it may have leaked.