diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4f6145b..4675704 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -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 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 4848be3..6bdf94e 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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"'