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
279 changes: 240 additions & 39 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,44 +1,245 @@
name: Claude Code Review
name: Claude PR Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
pull_request:
types: [opened, synchronize]

# Cancel any in-progress review for the same PR when a new push arrives.
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
review:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pull-requests: write
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Get PR diff and related file context
id: diff
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM=${{ github.event.pull_request.number }}
echo "PR number: $PR_NUM"

# --- Raw diff ---
RAW_DIFF=$(gh pr diff "$PR_NUM") || {
echo "::error::gh pr diff failed with exit code $?"
exit 1
}

RAW_LINES=$(echo "$RAW_DIFF" | wc -l | tr -d ' ')
echo "Raw diff lines: $RAW_LINES"

DIFF=$(echo "$RAW_DIFF" | grep -v -E '^diff --git.*(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Cargo\.lock|\.png|\.jpg|\.svg|\.ico|\.map)' | head -n 8000)
DIFF_LINES=$(echo "$DIFF" | wc -l | tr -d ' ')
echo "Filtered diff lines: $DIFF_LINES"

if [ -z "$RAW_DIFF" ]; then
echo "::warning::gh pr diff returned empty output. Skipping review."
echo "skip=true" >> "$GITHUB_OUTPUT"
: > /tmp/pr_context.txt
exit 0
fi

# --- Cost guard: skip very large diffs (Sonnet ~$0.10-0.20 per run) ---
MAX_DIFF_LINES=4000
if [ "$DIFF_LINES" -gt "$MAX_DIFF_LINES" ]; then
echo "::warning::Diff too large ($DIFF_LINES lines > $MAX_DIFF_LINES). Skipping to stay within cost budget."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "skip_reason=diff_too_large" >> "$GITHUB_OUTPUT"
: > /tmp/pr_context.txt
exit 0
fi

echo "skip=false" >> "$GITHUB_OUTPUT"
echo "diff_lines=$DIFF_LINES" >> "$GITHUB_OUTPUT"
echo "$DIFF" > /tmp/pr_diff.txt

# --- Related file context (TS/JS imports + Rust changed files) ---
CHANGED_FILES=$(gh pr view "$PR_NUM" --json files -q '.files[].path' \
| grep -E '\.(ts|tsx|js|jsx|mjs|cjs|rs)$' \
| grep -v -E '(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|\.d\.ts$)' \
|| true)
echo "Changed code files:"
echo "$CHANGED_FILES"

: > /tmp/pr_context.txt
# CHANGED files get a generous cap so the reviewer sees helpers + exports
# at the bottom of the file (missing them produces false-positive bug reports).
# RELATED files are only for cross-referencing types/signatures — 200 is plenty.
MAX_TOTAL_LINES=4000
CHANGED_MAX=600
RELATED_MAX=200

append_file() {
local path="$1"
local kind="$2"
local limit="$3"
[ ! -f "$path" ] && return 0
local cur
cur=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
if [ "$cur" -ge "$MAX_TOTAL_LINES" ]; then return 0; fi
local total
total=$(wc -l < "$path" | tr -d ' ')
{
printf '\n===== %s: %s =====\n' "$kind" "$path"
head -n "$limit" "$path"
if [ "$total" -gt "$limit" ]; then
printf '\n... [TRUNCATED: %d more lines below — do not flag missing identifiers without checking the raw file] ...\n' "$((total - limit))"
fi
} >> /tmp/pr_context.txt
}

resolve_ts_import() {
local src="$1"
local spec="$2"
local base
case "$spec" in
src/*) base="client/$spec" ;;
./*|../*) base="$(dirname "$src")/$spec" ;;
*) base="$spec" ;;
esac
base=$(realpath -m --relative-to=. "$base" 2>/dev/null || echo "$base")
case "$base" in node_modules/*|/*|../*) return 1 ;; esac
for ext in .ts .tsx .js .jsx .mjs .cjs; do
[ -f "${base}${ext}" ] && { echo "${base}${ext}"; return 0; }
done
for ext in .ts .tsx .js .jsx .mjs .cjs; do
[ -f "${base}/index${ext}" ] && { echo "${base}/index${ext}"; return 0; }
done
return 1
}

declare -A SEEN
while IFS= read -r f; do
[ -z "$f" ] && continue
SEEN["$f"]=1
append_file "$f" "CHANGED" "$CHANGED_MAX"
done <<< "$CHANGED_FILES"

# Resolve related files only for TS/JS sources (Rust mod resolution is non-trivial).
while IFS= read -r f; do
[ -z "$f" ] && continue
[ ! -f "$f" ] && continue
case "$f" in *.rs) continue ;; esac
IMPORTS=$({
sed -nE "s/.*from [\"']([^\"']+)[\"'].*/\1/p" "$f"
sed -nE "s/.*import\([\"']([^\"']+)[\"'].*/\1/p" "$f"
sed -nE "s/.*require\([\"']([^\"']+)[\"'].*/\1/p" "$f"
} | sort -u)
while IFS= read -r spec; do
[ -z "$spec" ] && continue
resolved=$(resolve_ts_import "$f" "$spec") || continue
[ "${SEEN[$resolved]:-0}" = "1" ] && continue
SEEN["$resolved"]=1
append_file "$resolved" "RELATED" "$RELATED_MAX"
done <<< "$IMPORTS"
done <<< "$CHANGED_FILES"

CTX_LINES=$(wc -l < /tmp/pr_context.txt | tr -d ' ')
echo "Context lines: $CTX_LINES"
echo "context_lines=$CTX_LINES" >> "$GITHUB_OUTPUT"

- name: Post skip notice
if: steps.diff.outputs.skip == 'true' && steps.diff.outputs.skip_reason == 'diff_too_large'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body "## 🤖 Claude Code Review

Skipped — diff is too large for automated review (> 4000 lines). Use \`@claude\` in a comment to ask targeted questions about specific files or sections."

- name: Review with Claude
id: review
if: steps.diff.outputs.skip != 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
PR_BODY_TRIMMED=$(echo "$PR_BODY" | head -c 2000)

jq -n \
--rawfile diff /tmp/pr_diff.txt \
--rawfile context /tmp/pr_context.txt \
--arg title "$PR_TITLE" \
--arg body "$PR_BODY_TRIMMED" \
'{
model: "claude-sonnet-4-6",
max_tokens: 1024,
system: "Concise code reviewer for the CoreMQ project — Rust MQTT broker (Tokio/Axum/ReDB) in `server/coremq-server/` and a React 19 + TypeScript + MUI 7 + Zustand admin dashboard in `client/`. FILE CONTEXT has post-change files — verify before flagging. Truncated files (marked [TRUNCATED]) may define identifiers you cannot see; do not flag them. Do not invent issues.\n\nCheck: bugs (panics, unwrap on Option::None, race conditions, channel deadlocks, QoS handling), security (auth bypass, JWT misuse, SQL injection), type safety, perf (blocking calls in async, unbounded channels). Skip style.\n\nProject conventions: Rust uses `/* */` block comments only. TypeScript uses `type` over `interface`, `export default function`, JSDoc-only comments, single quotes. Frontend: pages are thin wrappers, sections orchestrate, Zustand stores split State/Actions/initialState/reset, theme tokens via `sx`. Backend: AdminCommand → Engine → Service via mpsc + oneshot reply. i18n: any user-facing string must be added to en.json, ko.json, AND uz.json.\n\nFormat — keep it SHORT:\n### Summary (2-3 sentences max)\n### Issues (only real bugs — each: severity critical/major/minor, file:line, one-line problem, one-line fix)\n### Nits (max 2, optional)\n\nIf clean, say so in one sentence. Total response under 400 words for small diffs (<200 lines), under 800 words for large diffs.",
messages: [
{
role: "user",
content: ("PR: " + $title + "\n\n" + $body + "\n\n# Diff\n```\n" + $diff + "\n```\n\n# File Context\n```\n" + $context + "\n```")
}
]
}' > /tmp/payload.json

RESPONSE=$(curl -s https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d @/tmp/payload.json)

echo "Stop reason: $(echo "$RESPONSE" | jq -r '.stop_reason // .error.message // "unknown"')"

REVIEW=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')

if [ -z "$REVIEW" ]; then
echo "::error::Claude API returned no review text"
echo "$RESPONSE" | jq .
exit 1
fi

echo "$REVIEW" > /tmp/review.txt

IN_TOK=$(echo "$RESPONSE" | jq -r '.usage.input_tokens // 0')
OUT_TOK=$(echo "$RESPONSE" | jq -r '.usage.output_tokens // 0')
CW_TOK=$(echo "$RESPONSE" | jq -r '.usage.cache_creation_input_tokens // 0')
CR_TOK=$(echo "$RESPONSE" | jq -r '.usage.cache_read_input_tokens // 0')

# Sonnet 4.6 pricing: input $3/MTok, output $15/MTok, cache write $3.75/MTok, cache read $0.30/MTok
COST=$(awk -v i="$IN_TOK" -v o="$OUT_TOK" -v cw="$CW_TOK" -v cr="$CR_TOK" \
'BEGIN { printf "%.6f", (i*3.0 + o*15.0 + cw*3.75 + cr*0.30) / 1000000 }')

echo "Tokens: input=$IN_TOK output=$OUT_TOK cache_write=$CW_TOK cache_read=$CR_TOK"
echo "Estimated cost: \$$COST USD"

{
echo "input_tokens=$IN_TOK"
echo "output_tokens=$OUT_TOK"
echo "cache_write_tokens=$CW_TOK"
echo "cache_read_tokens=$CR_TOK"
echo "cost_usd=$COST"
} >> "$GITHUB_OUTPUT"

- name: Post review comment
if: steps.diff.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
{
echo "## 🤖 Claude Code Review"
echo ""
cat /tmp/review.txt
echo ""
echo "---"
echo "*Reviewed by claude-sonnet-4-6 · diff: ${{ steps.diff.outputs.diff_lines }} lines · context: ${{ steps.diff.outputs.context_lines }} lines · tokens: ${{ steps.review.outputs.input_tokens }} in / ${{ steps.review.outputs.output_tokens }} out · cost: \$${{ steps.review.outputs.cost_usd }} · tag `@claude` to ask follow-up questions*"
} > /tmp/comment.txt

gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body-file /tmp/comment.txt
82 changes: 40 additions & 42 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
@@ -1,50 +1,48 @@
name: Claude Code

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]

jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Queue @claude requests per issue/PR so rapid comments don't spawn parallel agents.
concurrency:
group: claude-agent-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false

# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
additional_permissions: |
actions: read

claude_args: '--model claude-sonnet-4-6 --allowedTools "Bash(gh pr *),Bash(gh api *),Bash(git diff *),Bash(git log *),Bash(git show *),Bash(cargo check*),Bash(cargo build*),Bash(cargo test*),Bash(cargo clippy*),Bash(cargo fmt*),Bash(npm run *),Bash(npx prettier*),Bash(npx eslint*),Bash(yarn *),Read,Glob,Grep,Edit,Write"'
Loading