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
25 changes: 16 additions & 9 deletions .github/workflows/code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ name: Code review
# check run, a summary comment with a verdict, and inline threads. Nothing requires it; its findings are advice. It
# reviews a pull request when it opens (drafts included, since that's when its author reviews it) and when it's marked
# ready, unless that commit was already reviewed; pushes in between start nothing. A top-level `/review` comment from
# someone with write access reviews the latest commit on demand. A `skip-review` label skips the automatic reviews.
# When a pull request closes, merged or not, the review records what became of each finding (fixed, dismissed,
# acknowledged or ignored) in the summary comment, by rules alone: no review and no model call. Releases up to v0.3.1
# treat a closed pull request like an opened one and would pay to review it, so keep `closed` out of the types below
# when pinning one of them.
# someone with write access reviews the latest commit on demand; `/review explain F3` says whether a finding still
# holds, and `/review teach <rule>` files a rule as an issue, both on a closed pull request too. A `skip-review` label
# skips the automatic reviews.
# When a pull request closes, merged or not, the outcomes job records what became of each finding (fixed, dismissed,
# acknowledged or ignored) in the summary comment, by rules alone: no review and no model call. It needs no environment:
# the app's key is an organization secret, and the code-review environment holds none; it only lets Anthropic's
# federation rule trust the review job.
#
# pull_request_target and issue_comment both run this file from the default branch, so a pull request can't change its
# own review. The engine fetches its code only to read it: nothing from it is built, run or installed.
Expand All @@ -17,8 +19,9 @@ name: Code review
# moved back. The action is pinned to a release's full commit SHA, with its tag in a comment: never a tag, a branch or
# a floating major, because it receives the organization's keys, and a SHA is the one pin nobody can move (CodeQL's
# extended suite flags any other). Dependabot's github-actions entry opens each bump and updates the SHA and the tag.
# Run tools/Enable-CodeReview.ps1 from a clone of NCodeGroup/code-review once for this
# repository, which creates the code-review environment and gives it the organization's secrets.
# Run tools/Enable-CodeReview.ps1 from a clone of NCodeGroup/code-review once for this repository: it creates the
# code-review environment, which only the default branch can deploy to, and gives the repository access to the
# organization's two secrets.

on:
pull_request_target:
Expand All @@ -30,14 +33,18 @@ permissions: {}

jobs:
review:
# The engine checks a comment again (exactly `/review`, from a writer, on a pull request that isn't from a fork) and
# stops before any model call when it isn't one.
# The engine checks a comment again (`/review`, `/review explain` or `/review teach`, from a writer, on a pull request
# that isn't from a fork) and stops before any model call when it isn't one. A plain `/review` on a closed pull
# request doesn't run, so it can't cancel or displace the outcomes job, which shares its concurrency group. The
# explain and teach checks here and in the concurrency group must name the same commands.
if: >-
(github.event_name == 'pull_request_target' && github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.sender.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'skip-review')) ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot' && github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/review') &&
(github.event.issue.state == 'open' || startsWith(github.event.comment.body, '/review explain') ||
startsWith(github.event.comment.body, '/review teach')) &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
# Anthropic's federation rule trusts only jobs in this environment, which only the default branch can deploy to.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Changed

- This repository's own workflow and the README's examples pin v1.0.0.
- The caller runs a plain `/review` only on an open pull request, so a `/review` on a pull request that just closed can't cancel or displace the outcomes job in their shared concurrency group; `/review explain` and `/review teach` still answer on a closed one.

## [1.0.0] - 2026-11-01

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,17 @@ permissions: {}
jobs:
review:
# A pull request when it opens or is marked ready, or a top-level `/review`, `/review explain` or `/review teach`
# comment on one from someone with write access. The engine checks the comment again and skips anything else.
# comment on one from someone with write access. The engine checks the comment again and skips anything else. A plain
# `/review` on a closed pull request doesn't run, so it can't cancel or displace the outcomes job. The explain and
# teach checks here and in the concurrency group must name the same commands.
if: >-
(github.event_name == 'pull_request_target' && github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.sender.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'skip-review')) ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot' && github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/review') &&
(github.event.issue.state == 'open' || startsWith(github.event.comment.body, '/review explain') ||
startsWith(github.event.comment.body, '/review teach')) &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
environment: code-review
Expand Down
2 changes: 2 additions & 0 deletions test/outcomes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,8 @@ function assertOutcomesJob(text: string, label: string): void {
const { review, outcomes } = jobs;
assert.equal(review.environment, 'code-review', label);
assert.ok(review.if?.includes("github.event.action != 'closed'"), label);
assert.ok(review.if?.includes("github.event.issue.state == 'open'"), label);
Comment thread
polewskm marked this conversation as resolved.
Resolved
for (const command of ['/review explain', '/review teach']) assert.ok(review.if?.includes(`startsWith(github.event.comment.body, '${command}')`), `${label}: ${command} answers on a closed pull request`);
assert.equal(outcomes.environment, undefined, label);
assert.deepEqual(outcomes.permissions, { contents: 'read', 'pull-requests': 'read' }, label);
assert.ok(outcomes.if?.includes("github.event.action == 'closed'"), label);
Expand Down
Loading