Skip to content

Commit 601bcd4

Browse files
Zachary-wWclaude
andcommitted
ci: add comment-triggered Claude PR review workflow
Add a PR comment and workflow_dispatch triggered review workflow that calls Claude, validates inline comments against added diff lines, and posts review results through the GitHub API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ee6cdce commit 601bcd4

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

.github/scripts/claude-review.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 The LoongForge Authors.
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Call Claude API to review a PR diff, then post inline review comments."""
5+
import json
6+
import os
7+
import re
8+
import subprocess
9+
import sys
10+
import urllib.request
11+
12+
13+
def call_claude(api_key, base_url, model, prompt):
14+
payload = {
15+
"model": model,
16+
"max_tokens": 8192,
17+
"messages": [{"role": "user", "content": prompt}],
18+
}
19+
20+
req = urllib.request.Request(
21+
f"{base_url}/v1/messages",
22+
data=json.dumps(payload).encode(),
23+
headers={
24+
"x-api-key": api_key,
25+
"anthropic-version": "2023-06-01",
26+
"content-type": "application/json",
27+
},
28+
)
29+
30+
with urllib.request.urlopen(req, timeout=180) as resp:
31+
result = json.loads(resp.read())
32+
33+
for block in result.get("content", []):
34+
if block.get("type") == "text" and "text" in block:
35+
return block["text"]
36+
37+
print(
38+
f"Claude response did not include a text content block: {json.dumps(result)[:1000]}",
39+
file=sys.stderr,
40+
)
41+
raise RuntimeError("Claude response missing text content block")
42+
43+
44+
def parse_diff_files(diff_text):
45+
"""Extract file paths and their line ranges from a unified diff."""
46+
files = {}
47+
current_file = None
48+
current_line = 0
49+
50+
for line in diff_text.split("\n"):
51+
if line.startswith("+++ b/"):
52+
current_file = line[6:]
53+
files[current_file] = set()
54+
elif line.startswith("@@ "):
55+
match = re.search(r"\+(\d+)", line)
56+
if match:
57+
current_line = int(match.group(1)) - 1
58+
elif current_file:
59+
if line.startswith("+") and not line.startswith("+++"):
60+
current_line += 1
61+
files[current_file].add(current_line)
62+
elif line.startswith("-"):
63+
pass
64+
else:
65+
current_line += 1
66+
67+
return files
68+
69+
70+
def main():
71+
api_key = os.environ.get("ANTHROPIC_API_KEY")
72+
base_url = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
73+
model = os.environ.get("CLAUDE_MODEL", "Claude Sonnet 4.6")
74+
skill_path = os.environ.get("SKILL_PATH", "skills/loongforge-review/SKILL.md")
75+
diff_path = os.environ.get("DIFF_PATH", "/tmp/pr-diff.txt")
76+
pr_title = os.environ.get("PR_TITLE", "")
77+
pr_author = os.environ.get("PR_AUTHOR", "")
78+
pr_number = os.environ.get("PR_NUMBER", "")
79+
repo = os.environ.get("GITHUB_REPOSITORY", "")
80+
81+
if not api_key:
82+
print("ERROR: ANTHROPIC_API_KEY not set", file=sys.stderr)
83+
sys.exit(1)
84+
85+
with open(skill_path, "r") as f:
86+
skill = f.read()
87+
88+
with open(diff_path, "r") as f:
89+
diff = f.read()
90+
91+
truncated = len(diff) > 80000
92+
diff_trimmed = diff[:80000]
93+
94+
prompt = f"""{skill}
95+
96+
## PR Information
97+
98+
- Title: {pr_title}
99+
- Author: {pr_author}
100+
101+
## Diff
102+
103+
```diff
104+
{diff_trimmed}
105+
```
106+
107+
{"⚠️ Note: The diff was truncated to 80,000 characters. Review may be incomplete." if truncated else ""}
108+
109+
## Output Requirements
110+
111+
You MUST respond with a valid JSON object (no markdown fencing, no extra text). The JSON must have this exact structure:
112+
113+
{{
114+
"verdict": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
115+
"summary": "1-3 sentence overall assessment",
116+
"inline_comments": [
117+
{{
118+
"path": "relative/path/to/file.py",
119+
"line": 42,
120+
"body": "Issue description with severity prefix: [Critical] or [Warning] or [Suggestion]"
121+
}}
122+
]
123+
}}
124+
125+
Rules for inline_comments:
126+
- "path" must be a file path that appears in the diff (after +++ b/)
127+
- "line" must be a line number within a ADDED (+) hunk of the diff
128+
- "body" should start with [Critical], [Warning], or [Suggestion] followed by the issue
129+
- Only comment on lines that are CHANGED in this diff, not pre-existing code
130+
- Keep each comment actionable and concise
131+
132+
Now review this pull request and respond with ONLY the JSON object."""
133+
134+
try:
135+
response = call_claude(api_key, base_url, model, prompt)
136+
except urllib.error.HTTPError as e:
137+
body = e.read().decode()
138+
print(f"API Error ({e.code}): {body}", file=sys.stderr)
139+
sys.exit(1)
140+
except Exception as e:
141+
print(f"Error: {e}", file=sys.stderr)
142+
sys.exit(1)
143+
144+
# Parse JSON from response (handle possible markdown fencing)
145+
text = response.strip()
146+
if text.startswith("```"):
147+
text = re.sub(r"^```(?:json)?\n?", "", text)
148+
text = re.sub(r"\n?```$", "", text)
149+
150+
try:
151+
review = json.loads(text)
152+
except json.JSONDecodeError:
153+
print(f"Failed to parse Claude response as JSON:\n{response[:500]}", file=sys.stderr)
154+
# Fall back to posting raw response as comment
155+
with open("/tmp/review-result.json", "w") as f:
156+
json.dump({"verdict": "COMMENT", "summary": response[:2000], "inline_comments": []}, f)
157+
sys.exit(0)
158+
159+
# Validate inline comments against actual diff
160+
diff_files = parse_diff_files(diff)
161+
valid_comments = []
162+
for comment in review.get("inline_comments", []):
163+
path = comment.get("path", "")
164+
line = comment.get("line", 0)
165+
if path in diff_files and line in diff_files[path]:
166+
valid_comments.append(comment)
167+
elif path in diff_files:
168+
# Line not in diff hunks, try to find nearest valid line
169+
valid_lines = sorted(diff_files[path])
170+
if valid_lines:
171+
nearest = min(valid_lines, key=lambda x: abs(x - line))
172+
comment["line"] = nearest
173+
comment["body"] = f"{comment['body']} (originally flagged at line {line})"
174+
valid_comments.append(comment)
175+
176+
review["inline_comments"] = valid_comments
177+
178+
with open("/tmp/review-result.json", "w") as f:
179+
json.dump(review, f, ensure_ascii=False)
180+
181+
print(f"Review complete: {review['verdict']}, {len(valid_comments)} inline comments")
182+
183+
184+
if __name__ == "__main__":
185+
main()
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
name: Claude PR Review
2+
3+
permissions:
4+
contents: read
5+
pull-requests: write
6+
7+
on:
8+
issue_comment:
9+
types: [created]
10+
workflow_dispatch:
11+
inputs:
12+
pr_number:
13+
description: "PR number to review"
14+
required: true
15+
type: string
16+
17+
jobs:
18+
auto-review:
19+
if: >-
20+
github.event_name == 'workflow_dispatch' ||
21+
(
22+
github.event_name == 'issue_comment' &&
23+
github.event.issue.pull_request &&
24+
github.event.comment.body == '/claude-review' &&
25+
(
26+
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.comment.author_association) ||
27+
github.event.comment.user.login == github.event.issue.user.login
28+
)
29+
)
30+
runs-on: [self-hosted, macOS, ARM64]
31+
timeout-minutes: 15
32+
steps:
33+
- uses: actions/checkout@v4
34+
with:
35+
fetch-depth: 0
36+
37+
- name: Determine PR number
38+
id: pr
39+
run: |
40+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
41+
echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
42+
else
43+
echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT"
44+
fi
45+
46+
- name: Get PR diff
47+
env:
48+
GH_TOKEN: ${{ github.token }}
49+
run: |
50+
gh pr diff ${{ steps.pr.outputs.number }} > /tmp/pr-diff.txt
51+
[ -s /tmp/pr-diff.txt ] || { echo "Empty diff"; exit 1; }
52+
53+
- name: Get PR metadata
54+
id: meta
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
run: |
58+
gh pr view ${{ steps.pr.outputs.number }} --json title,author --jq '"title=" + .title' >> "$GITHUB_OUTPUT"
59+
gh pr view ${{ steps.pr.outputs.number }} --json author --jq '"author=" + .author.login' >> "$GITHUB_OUTPUT"
60+
61+
- name: Run Claude review
62+
env:
63+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
64+
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
65+
CLAUDE_MODEL: "Claude Sonnet 4.6"
66+
SKILL_PATH: "skills/loongforge-review/SKILL.md"
67+
DIFF_PATH: "/tmp/pr-diff.txt"
68+
PR_TITLE: ${{ steps.meta.outputs.title }}
69+
PR_AUTHOR: ${{ steps.meta.outputs.author }}
70+
PR_NUMBER: ${{ steps.pr.outputs.number }}
71+
GITHUB_REPOSITORY: ${{ github.repository }}
72+
run: |
73+
python3 .github/scripts/claude-review.py
74+
75+
- name: Delete previous bot reviews
76+
env:
77+
GH_TOKEN: ${{ github.token }}
78+
PR_NUMBER: ${{ steps.pr.outputs.number }}
79+
REPO: ${{ github.repository }}
80+
run: |
81+
gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --jq \
82+
'.[] | select(.user.login == "github-actions[bot]") | .id' | \
83+
while read -r review_id; do
84+
echo "Dismissing old review: $review_id"
85+
gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews/${review_id}" \
86+
--method DELETE 2>/dev/null || true
87+
done
88+
89+
- name: Post review with inline comments
90+
env:
91+
GH_TOKEN: ${{ github.token }}
92+
PR_NUMBER: ${{ steps.pr.outputs.number }}
93+
REPO: ${{ github.repository }}
94+
run: |
95+
python3 << 'PYEOF'
96+
import json
97+
import subprocess
98+
import os
99+
100+
with open("/tmp/review-result.json", "r") as f:
101+
review = json.load(f)
102+
103+
verdict = review.get("verdict", "COMMENT")
104+
summary = review.get("summary", "")
105+
comments = review.get("inline_comments", [])
106+
107+
body = "## 🤖 Claude Code Review\n\n"
108+
body += f"**Verdict: {verdict}**\n\n"
109+
body += f"{summary}\n\n"
110+
if comments:
111+
body += f"📝 {len(comments)} inline comment(s) posted below.\n\n"
112+
body += "---\n*Automated review by Claude Sonnet 4.6 · [Review Skill](skills/loongforge-review/SKILL.md)*"
113+
114+
pr_number = os.environ["PR_NUMBER"]
115+
repo = os.environ["REPO"]
116+
117+
# Use COMMENT event to avoid blocking merges
118+
payload = {
119+
"event": "COMMENT",
120+
"body": body,
121+
"comments": [
122+
{"path": c["path"], "line": c["line"], "body": c["body"]}
123+
for c in comments
124+
],
125+
}
126+
127+
payload_json = json.dumps(payload)
128+
result = subprocess.run(
129+
["gh", "api", f"repos/{repo}/pulls/{pr_number}/reviews",
130+
"--method", "POST", "--input", "-"],
131+
input=payload_json.encode(),
132+
capture_output=True,
133+
)
134+
135+
if result.returncode != 0:
136+
print(f"Failed to post review: {result.stderr.decode()}")
137+
# Fallback: post as simple comment
138+
subprocess.run(
139+
["gh", "pr", "comment", pr_number, "--body", body],
140+
check=True,
141+
)
142+
else:
143+
print("Review posted successfully with inline comments")
144+
PYEOF

0 commit comments

Comments
 (0)