Devesh/sk 2832 claude setup v2 #25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Claude PR Review | |
| on: | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - 'src/**/*.java' | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'PR number to re-run review on' | |
| required: false | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| # Debounce: when several pushes land on the same PR in quick succession, cancel | |
| # superseded runs so only the latest commit (current HEAD) gets reviewed. | |
| concurrency: | |
| group: claude-pr-review-${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| cancel-in-progress: true | |
| jobs: | |
| sdk-review: | |
| name: SDK PR Review (changed lines only) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Claude CLI | |
| run: npm install -g @anthropic-ai/claude-code | |
| # Incremental review: find the commit the bot last reviewed (the commit_id of | |
| # its most recent review on this PR). Subsequent runs diff only lines added | |
| # since then, so unchanged commits are not re-reviewed and not re-commented. | |
| - name: Determine last reviewed commit | |
| id: lastrev | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const prNumber = context.payload.pull_request?.number | |
| || '${{ github.event.inputs.pr_number }}'; | |
| if (!prNumber) { core.setOutput('sha', ''); return; } | |
| let sha = ''; | |
| try { | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| ...context.repo, pull_number: Number(prNumber), per_page: 100, | |
| }); | |
| const mine = reviews | |
| .filter(r => r.user && r.user.login === 'github-actions[bot]' && r.commit_id) | |
| .sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at)); | |
| if (mine.length) sha = mine[0].commit_id; | |
| } catch (e) { | |
| core.warning(`listReviews failed (${e.message}); falling back to full review.`); | |
| } | |
| core.setOutput('sha', sha); | |
| core.info(`last reviewed commit: ${sha || '(none — full review)'}`); | |
| - name: Check for non-generated Java changes | |
| id: check | |
| env: | |
| LAST_SHA: ${{ steps.lastrev.outputs.sha }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| BASE_BRANCH: ${{ github.base_ref }} | |
| run: | | |
| BASE_REF="origin/${BASE_BRANCH:-main}" | |
| # Default to a full review against the PR base. Switch to incremental only | |
| # for a real push event when the last reviewed commit is still an ancestor | |
| # of HEAD (a rebase/force-push or manual re-run forces a full re-review). | |
| RANGE_BASE="$BASE_REF" | |
| if [ "$EVENT_NAME" != "workflow_dispatch" ] && [ -n "$LAST_SHA" ] \ | |
| && git merge-base --is-ancestor "$LAST_SHA" HEAD 2>/dev/null; then | |
| RANGE_BASE="$LAST_SHA" | |
| echo "Incremental review: ${LAST_SHA}...HEAD" | |
| else | |
| echo "Full review: ${BASE_REF}...HEAD" | |
| fi | |
| echo "range_base=$RANGE_BASE" >> $GITHUB_OUTPUT | |
| CHANGED=$(git diff --name-only "$RANGE_BASE"...HEAD \ | |
| | grep '\.java$' | grep -v 'generated') | |
| if [ -z "$CHANGED" ]; then | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| else | |
| echo "has_changes=true" >> $GITHUB_OUTPUT | |
| LINES=$(git diff "$RANGE_BASE"...HEAD -- '*.java' \ | |
| | grep -c '^[+-][^+-]' 2>/dev/null || echo 1) | |
| echo "changed_lines=$LINES" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Verify Claude CLI | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| if [ -z "$ANTHROPIC_API_KEY" ]; then | |
| echo "::error::ANTHROPIC_API_KEY secret is not set." | |
| exit 1 | |
| fi | |
| echo "Claude version: $(claude --version)" | |
| set +e | |
| TEST=$(claude --dangerously-skip-permissions --output-format json -p "Reply with only the word: WORKING" 2>&1) | |
| EXIT=$? | |
| set -e | |
| IS_ERROR=$(echo "$TEST" | jq -r '.is_error // empty' 2>/dev/null) | |
| if [ "$EXIT" -ne 0 ] || [ "$IS_ERROR" = "true" ]; then | |
| MSG=$(echo "$TEST" | jq -r '.result // .error // .' 2>/dev/null | head -c 300) | |
| echo "::error::Claude CLI check failed (invalid key, no API credits, or connectivity): $MSG" | |
| exit 1 | |
| fi | |
| echo "Claude CLI OK" | |
| - name: Run code review | |
| if: steps.check.outputs.has_changes == 'true' | |
| id: review | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" | |
| GITHUB_BASE_REF: ${{ github.base_ref }} | |
| GITHUB_ACTIONS: "true" | |
| # Incremental base: last reviewed commit (or PR base for a full review). | |
| REVIEW_BASE_SHA: ${{ steps.check.outputs.range_base }} | |
| run: | | |
| set +e | |
| RAW=$(claude --dangerously-skip-permissions --output-format json --model claude-sonnet-4-6 -p "/code-review" 2>&1) | |
| CLAUDE_EXIT=$? | |
| set -e | |
| IS_ERROR=$(echo "$RAW" | jq -r '.is_error // empty' 2>/dev/null) | |
| RESULT=$(echo "$RAW" | jq -r '.result // "_Review failed to produce output._"') | |
| INPUT_TOK=$(echo "$RAW" | jq '[.modelUsage[].inputTokens // 0] | add // 0') | |
| OUTPUT_TOK=$(echo "$RAW" | jq '[.modelUsage[].outputTokens // 0] | add // 0') | |
| CACHED_TOK=$(echo "$RAW" | jq '[.modelUsage[].cacheReadInputTokens // 0] | add // 0') | |
| CACHE_WRITE_TOK=$(echo "$RAW" | jq '[.modelUsage[].cacheCreationInputTokens // 0] | add // 0') | |
| COST=$(echo "$RAW" | jq -r '.total_cost_usd // 0') | |
| LINES="${{ steps.check.outputs.changed_lines }}" | |
| echo "result<<EOF" >> $GITHUB_OUTPUT | |
| echo "$RESULT" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| { | |
| echo "## Token Usage — SDK Review" | |
| echo "| Metric | Value |" | |
| echo "|--------|-------|" | |
| echo "| Input tokens | $INPUT_TOK |" | |
| echo "| Output tokens | $OUTPUT_TOK |" | |
| echo "| Cache hits (read) | $CACHED_TOK |" | |
| echo "| Cache writes | $CACHE_WRITE_TOK |" | |
| echo "| Total cost | \$$COST |" | |
| echo "| Changed lines | $LINES |" | |
| } >> $GITHUB_STEP_SUMMARY | |
| # Fail the check on a real API/runtime error (invalid key, no credits, etc.) | |
| # instead of posting the error text as a "review". | |
| if [ "$CLAUDE_EXIT" -ne 0 ] || [ "$IS_ERROR" = "true" ]; then | |
| echo "::error::Claude review failed (invalid key, no API credits, or runtime error): $(echo "$RESULT" | head -c 300)" | |
| exit 1 | |
| fi | |
| - name: Post review comment | |
| if: steps.check.outputs.has_changes == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| REVIEW_BODY: ${{ steps.review.outputs.result }} | |
| with: | |
| script: | | |
| const raw = process.env.REVIEW_BODY || '_Review output unavailable._'; | |
| const pull_number = context.payload.pull_request.number; | |
| // Extract inline findings, then strip the block so it never leaks into the comment. | |
| // Primary format: HTML-comment sentinel <!-- ai-review-inline [...] --> (never renders, | |
| // and backticks inside the JSON can't break extraction). Legacy fallback: ```json:inline fence. | |
| let summary = raw; | |
| let inline = []; | |
| const html = raw.match(/<!--\s*ai-review-inline\s*([\s\S]*?)-->/); | |
| const fenced = raw.match(/```+\s*json:inline\s*([\s\S]*?)```/); | |
| const block = html || fenced; | |
| if (block) { try { inline = JSON.parse(block[1].trim()); } catch (e) { inline = []; } } | |
| // Strip the sentinel block and any stray json:inline fence (to end-of-text, since it must | |
| // be last) so a malformed block can never appear in the visible summary. | |
| summary = summary | |
| .replace(/<!--\s*ai-review-inline[\s\S]*?-->/g, '') | |
| .replace(/```+\s*json:inline[\s\S]*$/i, '') | |
| .trim(); | |
| // Strip any leading model preamble/narration before the verdict line | |
| // (tolerates markdown decoration like **REQUEST CHANGES** or a heading). | |
| const lines = summary.split('\n'); | |
| const idx = lines.findIndex(l => | |
| /\b(REQUEST CHANGES|APPROVE WITH FIXES|APPROVE)\b/.test(l) || /^#{1,6}\s/.test(l)); | |
| if (idx > 0) summary = lines.slice(idx).join('\n').trim(); | |
| // Total silence: the diff is incremental, so a bare APPROVE means the new | |
| // lines introduced no blocking or advisory finding — post nothing at all. | |
| // (Alternation order matters: APPROVE WITH FIXES must be tried before APPROVE.) | |
| const verdict = (summary.match(/\b(REQUEST CHANGES|APPROVE WITH FIXES|APPROVE)\b/) || [])[1]; | |
| if (verdict === 'APPROVE') { | |
| core.info('Verdict APPROVE — no new issues on the changed lines; posting nothing.'); | |
| return; | |
| } | |
| const comments = (Array.isArray(inline) ? inline : []) | |
| .filter(f => f && f.path && Number.isInteger(f.line)) | |
| .map(f => ({ | |
| path: f.path, | |
| line: f.line, | |
| side: 'RIGHT', | |
| body: `**${f.severity || 'Finding'}${f.category ? ' · ' + f.category : ''}**: ${f.comment || ''}`.trim() | |
| })); | |
| const body = `## AI Code Review\n\n${summary}`; | |
| // One PR review: summary as the body + inline comments anchored to changed lines. | |
| try { | |
| await github.rest.pulls.createReview({ | |
| ...context.repo, | |
| pull_number, | |
| event: 'COMMENT', | |
| body, | |
| comments | |
| }); | |
| } catch (e) { | |
| // Inline anchoring fails if a line is not in the diff — fall back to a single | |
| // summary comment and list the would-be inline findings so nothing is lost. | |
| const list = comments.length | |
| ? '\n\n<details><summary>Inline findings (could not attach to lines)</summary>\n\n' | |
| + comments.map(c => `- \`${c.path}:${c.line}\` — ${c.body}`).join('\n') | |
| + '\n</details>' | |
| : ''; | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: pull_number, | |
| body: body + list | |
| }); | |
| } |