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
59name : Claude Review
610
1317 types : [created]
1418 pull_request_review :
1519 types : [submitted]
20+
1621concurrency :
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+
1925jobs :
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