Skip to content

Commit f660aa6

Browse files
committed
ci: privilege-separate Claude review workflow
Split the workflow into two jobs for least-privilege: 1. 'review' job — runs Claude with read-only permissions (contents: read, id-token: write for AWS OIDC, actions: read). Claude produces a structured JSON review via --json-schema. Its tools are restricted to read-only operations (no Write, Edit, or MCP comment tools). 2. 'post' job — only has pull-requests: write. Reads the structured JSON output from the review job and posts it as a single PR review using Octokit via actions/github-script. Deduplicates against existing inline comments before posting. Also fix: - Missing closing parenthesis in the if condition - issue_comment events not resolving PR number (github.event.pull_request is unavailable, fall back to github.event.issue.number) - Concurrency group using the same fallback - Drop tracking comment support Co-developed-by: Claude <claude@anthropic.com>
1 parent fd10abf commit f660aa6

1 file changed

Lines changed: 223 additions & 67 deletions

File tree

Lines changed: 223 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Integrates Claude Code as an AI assistant for reviewing pull requests.
2-
# Mention @claude in any PR review to request a review. Claude authenticates
2+
# Mention @claude in any PR comment to request a review. Claude authenticates
33
# via AWS Bedrock using OIDC — no long-lived API keys required.
4+
#
5+
# Architecture: The workflow is split into two jobs for least-privilege:
6+
# 1. "review" — runs Claude with read-only permissions, produces structured JSON
7+
# 2. "post" — reads the JSON and posts comments to the PR with write permissions
48

59
name: Claude Review
610

@@ -13,11 +17,13 @@ on:
1317
types: [created]
1418
pull_request_review:
1519
types: [submitted]
20+
1621
concurrency:
17-
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}
18-
cancel-in-progress: true
22+
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}
23+
cancel-in-progress: true
24+
1925
jobs:
20-
claude-review:
26+
review:
2127
runs-on: ubuntu-latest
2228
env:
2329
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
@@ -35,10 +41,14 @@ jobs:
3541
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.review.author_association)))
3642
3743
permissions:
38-
contents: read # Read repository contents
39-
pull-requests: write # Post comments and reviews on PRs
44+
contents: read
4045
id-token: write # Authenticate with AWS via OIDC
41-
actions: read # Access workflow run metadata
46+
actions: read
47+
48+
outputs:
49+
structured_output: ${{ steps.claude.outputs.structured_output }}
50+
pr_number: ${{ steps.pr.outputs.number }}
51+
head_sha: ${{ steps.pr.outputs.head_sha }}
4252

4353
steps:
4454
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
@@ -60,28 +70,72 @@ jobs:
6070
aws-region: us-east-1
6171

6272
- name: Run Claude Code
73+
id: claude
6374
uses: anthropics/claude-code-action@1fc90f3ed982521116d8ff6d85b948c9b12cae3e
75+
env:
76+
REVIEW_SCHEMA: >-
77+
{
78+
"type": "object",
79+
"required": ["comments", "summary"],
80+
"properties": {
81+
"summary": {
82+
"type": "string",
83+
"description": "A markdown summary of the review to post as a top-level tracking comment"
84+
},
85+
"comments": {
86+
"type": "array",
87+
"items": {
88+
"type": "object",
89+
"required": ["file", "line", "severity", "body"],
90+
"properties": {
91+
"file": {
92+
"type": "string",
93+
"description": "Path to the file relative to the repo root"
94+
},
95+
"line": {
96+
"type": "integer",
97+
"description": "Line number in the diff (new file side) to attach the comment to"
98+
},
99+
"severity": {
100+
"type": "string",
101+
"enum": ["must-fix", "suggestion", "nit"]
102+
},
103+
"body": {
104+
"type": "string",
105+
"description": "The review comment body in markdown"
106+
}
107+
}
108+
}
109+
}
110+
}
111+
}
64112
with:
65113
use_bedrock: "true"
114+
# We still have to pass GITHUB_TOKEN here because claude-code-action
115+
# requires it, but we restrict Claude's tools to read-only operations
116+
# so it cannot post comments or modify the PR.
66117
github_token: ${{ secrets.GITHUB_TOKEN }}
67-
# This requires "tag mode", which is currently bugged:
68-
# https://github.com/anthropics/claude-code-action/issues/939
69118
track_progress: false
119+
show_full_output: true
70120
claude_args: |
71121
--model us.anthropic.claude-opus-4-6-v1
72122
--max-turns 100
73123
--allowedTools "
74-
Read,Write,Edit,MultiEdit,LS,Grep,Glob,Task,
75-
Bash(cat:*),Bash(test:*),Bash(printf:*),Bash(jq:*),Bash(head:*),Bash(git:*),Bash(gh:*),
76-
mcp__github_inline_comment__create_inline_comment,
124+
Read,LS,Grep,Glob,Task,
125+
Bash(cat:*),Bash(test:*),Bash(printf:*),Bash(jq:*),Bash(head:*),Bash(tail:*),
126+
Bash(git:*),Bash(gh:*),Bash(grep:*),Bash(find:*),Bash(ls:*),Bash(wc:*),
127+
Bash(diff:*),Bash(sed:*),Bash(awk:*),Bash(sort:*),Bash(uniq:*),
77128
"
129+
--json-schema '${{ env.REVIEW_SCHEMA }}'
78130
prompt: |
79131
REPO: ${{ github.repository }}
80132
PR NUMBER: ${{ steps.pr.outputs.number }}
81133
HEAD SHA: ${{ steps.pr.outputs.head_sha }}
82134
83-
Review this pull request.
84-
You are in the upstream repo without the patch applied. Do not apply it.
135+
You are a code reviewer for the systemd project. Review this pull request and
136+
produce a structured JSON result containing your review comments. Do NOT attempt
137+
to post comments yourself — just return the JSON. You are in the upstream repo
138+
without the patch applied. Do not apply it.
85139
86140
## Phase 1: Gather context
87141
@@ -92,81 +146,183 @@ jobs:
92146
- `gh api --paginate repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments`
93147
- `gh api --paginate repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews`
94148
149+
Also look for an existing tracking comment (containing `<!-- claude-pr-review -->`)
150+
in the top-level issue comments. If one exists, you will use it as the basis for
151+
your `summary` in Phase 3.
152+
95153
## Phase 2: Parallel review subagents
96154
97155
Review:
98156
- Code quality, style, and best practices
99157
- Potential bugs, issues, incorrect logic
100158
- Security implications
101-
- CLAUDE.md - compliance
159+
- CLAUDE.md compliance
102160
103161
For every category, launch subagents to review them in parallel. Group related sections
104162
as needed — use 2-4 subagents based on PR size and scope.
105163
106164
Give each subagent the PR title, description, full patch, and the list of changed files.
107165
108166
Each subagent must return a JSON array of issues:
109-
`[{"file": "path", "line": <number or null>, "severity": "must-fix|suggestion|nit", "title": "...", "body": "..."}]`
167+
`[{"file": "path", "line": <number>, "severity": "must-fix|suggestion|nit", "body": "..."}]`
110168
111-
Subagents must ONLY return the JSON array — they must NOT post comments,
112-
call `gh`, or use `mcp__github_inline_comment__create_inline_comment`.
113-
All posting happens in Phase 3.
169+
`line` must be a line number from the NEW side of the diff (i.e. where the comment
170+
should appear in the changed file after the patch is applied).
114171
115172
Each subagent MUST verify its findings before returning them:
116173
- For style/convention claims, check at least 3 existing examples in the codebase to confirm
117174
the pattern actually exists before flagging a violation.
118175
- For "use X instead of Y" suggestions, confirm X actually exists and works for this case.
119176
- If unsure, don't include the issue.
120177
121-
## Phase 3: Collect and post
178+
## Phase 3: Collect, deduplicate, and summarize
122179
123180
After ALL subagents complete:
124181
1. Collect all issues. Merge duplicates (same file, lines within 3 of each other, same problem).
125182
2. Drop low-confidence findings.
126-
3. For CLAUDE.md violations that appear in 3+ existing places in the codebase, do NOT post inline comments.
127-
Instead, add them to the 'CLAUDE.md improvements' section of the tracking comment
128-
4. Check existing inline review comments (fetched in Phase 1). Do NOT post an inline comment if
129-
one already exists on the same file+line about the same problem.
130-
5. Check for author replies that dismiss or reject a previous comment. Do NOT re-raise an issue
131-
the PR author has already responded to disagreeing with.
132-
6. Post new inline comments with `mcp__github_inline_comment__create_inline_comment`.
133-
134-
Prefix ALL comments with "Claude: ".
135-
Link format: https://github.com/${{ github.repository }}/blob/${{ steps.pr.outputs.head_sha }}/README.md#L10-L15
136-
137-
Then maintain a single top-level "tracking comment" listing ALL issues as checkboxes.
138-
Use a hidden HTML marker to find it: `<!-- claude-pr-review -->`.
139-
Look through the top-level comments fetched in Phase 1 for one containing that marker.
140-
141-
**If no tracking comment exists (first run):**
142-
Create one with `gh pr comment ${{ steps.pr.outputs.number }} --body "..."` using this format:
143-
```
144-
Claude: review of <REPO> #<PR NUMBER> (<HEAD SHA>)
145-
146-
<!-- claude-pr-review -->
147-
148-
### Must fix
149-
- [ ] **title** — `file:line` — short explanation
150-
151-
### Suggestions
152-
- [ ] **title** — `file:line` — short explanation
153-
154-
### Nits
155-
- [ ] **title** — `file:line` — short explanation
156-
157-
### CLAUDE.md improvements
158-
- improvement suggestion
159-
```
160-
Omit empty sections.
161-
162-
**If a tracking comment already exists (subsequent run):**
163-
1. Parse the existing checkboxes. For each old issue, check if the current patch still has
164-
that problem (re-check the relevant lines in the new diff). If fixed, mark it `- [x]`.
165-
If the author dismissed it, mark it `- [x] ~~title~~ (dismissed)`.
166-
2. Append any NEW issues found in this run that aren't already listed.
167-
3. Update the HEAD SHA in the header line.
168-
4. Edit the comment in-place.
169-
```
170-
printf '%s' "$BODY" > pr-review-body.txt
171-
gh api --method PATCH repos/${{ github.repository }}/issues/comments/<comment-id> -F body=@pr-review-body.txt
172-
```
183+
3. Check the existing inline review comments fetched in Phase 1. Do NOT include a
184+
comment if one already exists on the same file and line about the same problem.
185+
Also check for author replies that dismiss or reject a previous comment — do NOT
186+
re-raise an issue the PR author has already responded to disagreeing with.
187+
4. Prefix ALL comment bodies with a severity tag: `**must-fix**: `, `**suggestion**: `,
188+
or `**nit**: `.
189+
5. Write a `summary` field in markdown for a top-level tracking comment.
190+
191+
**If no existing tracking comment was found (first run):**
192+
Use this format:
193+
194+
```
195+
## Claude review of PR #<number> (<HEAD SHA>)
196+
197+
<!-- claude-pr-review -->
198+
199+
### Must fix
200+
- [ ] **short title** — `file:line` — brief explanation
201+
202+
### Suggestions
203+
- [ ] **short title** — `file:line` — brief explanation
204+
205+
### Nits
206+
- [ ] **short title** — `file:line` — brief explanation
207+
```
208+
209+
Omit empty sections. Each checkbox item must correspond to an entry in `comments`.
210+
If there are no issues at all, write a short message saying the PR looks good.
211+
212+
**If an existing tracking comment was found (subsequent run):**
213+
Use the existing comment as the starting point. Preserve the order and wording
214+
of all existing items. Then apply these updates:
215+
- Update the HEAD SHA in the header line.
216+
- For each existing item, re-check whether the issue is still present in the
217+
current diff. If it has been fixed, mark it checked: `- [x]`.
218+
- If the PR author replied dismissing an item, mark it:
219+
`- [x] ~~short title~~ (dismissed)`.
220+
- Preserve checkbox state that was already set by previous runs or by hand.
221+
- Append any NEW issues found in this run that aren't already listed,
222+
in the appropriate severity section, after the existing items.
223+
- Do NOT reorder, reword, or remove existing items.
224+
225+
Return the final JSON object with your `comments` array and `summary` string.
226+
Do NOT attempt to post comments, call `gh`, or use any MCP tools to interact with the PR.
227+
228+
post:
229+
runs-on: ubuntu-latest
230+
needs: review
231+
if: always() && needs.review.result == 'success'
232+
233+
permissions:
234+
pull-requests: write
235+
236+
steps:
237+
- name: Post review comments
238+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
239+
env:
240+
STRUCTURED_OUTPUT: ${{ needs.review.outputs.structured_output }}
241+
PR_NUMBER: ${{ needs.review.outputs.pr_number }}
242+
HEAD_SHA: ${{ needs.review.outputs.head_sha }}
243+
with:
244+
script: |
245+
const owner = context.repo.owner;
246+
const repo = context.repo.repo;
247+
const prNumber = parseInt(process.env.PR_NUMBER, 10);
248+
const headSha = process.env.HEAD_SHA;
249+
250+
/* Parse Claude's structured output. */
251+
const raw = process.env.STRUCTURED_OUTPUT;
252+
console.log("Structured output from Claude:");
253+
console.log(raw || "(empty)");
254+
255+
let comments = [];
256+
let summary = "";
257+
if (raw) {
258+
try {
259+
const review = JSON.parse(raw);
260+
if (Array.isArray(review.comments))
261+
comments = review.comments;
262+
if (typeof review.summary === "string")
263+
summary = review.summary;
264+
} catch (e) {
265+
console.log(`Failed to parse structured output: ${e.message}`);
266+
}
267+
}
268+
269+
console.log(`Claude produced ${comments.length} review comment(s).`);
270+
271+
/* Post each inline comment individually. Deduplication against existing
272+
* comments is handled by Claude in the prompt, so we just post whatever
273+
* it returns. Using individual comments (rather than a review) means
274+
* re-runs only add new comments instead of creating a whole new review. */
275+
for (const c of comments) {
276+
console.log(` Posting comment on ${c.file}:${c.line}`);
277+
await github.rest.pulls.createReviewComment({
278+
owner,
279+
repo,
280+
pull_number: prNumber,
281+
commit_id: headSha,
282+
path: c.file,
283+
line: c.line,
284+
body: `Claude: ${c.body}`,
285+
});
286+
}
287+
288+
if (comments.length > 0)
289+
console.log(`Posted ${comments.length} inline comment(s).`);
290+
else
291+
console.log("No inline comments to post.");
292+
293+
/* Create or update the tracking comment. */
294+
const MARKER = "<!-- claude-pr-review -->";
295+
if (!summary)
296+
summary = "Claude review: no issues found :tada:\n\n" + MARKER;
297+
else if (!summary.includes(MARKER))
298+
summary = summary.replace(/\n/, `\n${MARKER}\n`);
299+
300+
/* Find an existing tracking comment. */
301+
const {data: issueComments} = await github.rest.issues.listComments({
302+
owner,
303+
repo,
304+
issue_number: prNumber,
305+
per_page: 100,
306+
});
307+
308+
const existing = issueComments.find((c) => c.body && c.body.includes(MARKER));
309+
310+
if (existing) {
311+
console.log(`Updating existing tracking comment ${existing.id}.`);
312+
await github.rest.issues.updateComment({
313+
owner,
314+
repo,
315+
comment_id: existing.id,
316+
body: summary,
317+
});
318+
} else {
319+
console.log("Creating new tracking comment.");
320+
await github.rest.issues.createComment({
321+
owner,
322+
repo,
323+
issue_number: prNumber,
324+
body: summary,
325+
});
326+
}
327+
328+
console.log("Tracking comment posted successfully.");

0 commit comments

Comments
 (0)