diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000..646c962 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,553 @@ +name: Claude Code Review + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to analyze (optional, for manual runs)' + required: false + type: string + issue_comment: + types: + - created + pull_request_review_comment: + types: + - created +concurrency: + group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + actions: read + +jobs: + claude-review: + name: Claude Code Analysis + if: >- + ( + github.event_name == 'pull_request' + ) || ( + github.event_name == 'issue_comment' && + github.event.comment && + contains(github.event.comment.body, '@claude') + ) || ( + github.event_name == 'pull_request_review_comment' && + github.event.comment && + contains(github.event.comment.body, '@claude') + ) || ( + github.event_name == 'workflow_dispatch' + ) + runs-on: self-hosted + env: + COMMENT_PREFIX: 'Claude Claude' + CLAUDE_COST_SUMMARY_MARKER: '' + CLAUDE_SETUP_REMINDER_MARKER: '' + steps: + - name: Post Claude onboarding note + if: ${{ github.event_name == 'pull_request' && github.event.action == 'opened' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const commentPrefix = process.env.COMMENT_PREFIX || ''; + const onboardingBody = [ + `${commentPrefix}`, + '', + '> [!NOTE] To trigger a Claude review, invoke the `@claude review` command.', + '', + '' + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: onboardingBody + }); + + - name: Determine Claude trigger + id: trigger + run: | + should_run=false + + case "$EVENT_NAME" in + issue_comment) + if printf '%s' "$COMMENT_BODY" | grep -qiE '@claude[[:space:]]+review'; then + if [ "$AUTHOR_ASSOCIATION" = "OWNER" ] || [ "$AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$AUTHOR_ASSOCIATION" = "COLLABORATOR" ]; then + if [ "$HAS_PR_OBJECT" = "true" ]; then + echo "✓ Trusted user requested @claude review on PR comment" + should_run=true + else + echo "✗ Comment is on an issue, not a PR. Skipping." + fi + else + echo "✗ Comment author association '$AUTHOR_ASSOCIATION' not permitted" + fi + else + echo "✗ Comment does not contain '@claude review'" + fi + ;; + pull_request_review_comment) + if printf '%s' "$COMMENT_BODY" | grep -qiE '@claude[[:space:]]+review'; then + if [ "$AUTHOR_ASSOCIATION" = "OWNER" ] || [ "$AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$AUTHOR_ASSOCIATION" = "COLLABORATOR" ]; then + echo "✓ Trusted user requested @claude review on PR review comment" + should_run=true + else + echo "✗ Review author association '$AUTHOR_ASSOCIATION' not permitted" + fi + else + echo "✗ PR review comment does not contain '@claude review'" + fi + ;; + pull_request) + if [ "$IS_FORK" = "true" ]; then + echo "✗ Pull request originates from a fork; skipping self-hosted run" + else + echo "✓ Pull request event triggered" + should_run=true + fi + ;; + workflow_dispatch) + echo "✓ Manual workflow dispatch triggered" + should_run=true + ;; + *) + echo "✗ Event type '$EVENT_NAME' does not require trigger check" + ;; + esac + + echo "should_run=$should_run" >> "$GITHUB_OUTPUT" + echo "Final decision: should_run=$should_run" + env: + EVENT_NAME: ${{ github.event_name }} + COMMENT_BODY: ${{ github.event.comment.body || '' }} + HAS_PR_OBJECT: ${{ github.event.issue.pull_request != null || github.event.pull_request != null }} + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association || github.event.pull_request.author_association || 'UNKNOWN' }} + IS_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork && 'true' || 'false' }} + + - name: Detect Claude API key + if: ${{ steps.trigger.outputs.should_run == 'true' }} + id: claude_token + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -z "${ANTHROPIC_API_KEY}" ]; then + echo "ANTHROPIC_API_KEY not configured, skipping Claude analysis."; + echo "available=false" >> "$GITHUB_OUTPUT" + else + echo "available=true" >> "$GITHUB_OUTPUT" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + + - name: Resolve PR context + if: ${{ steps.trigger.outputs.should_run == 'true' }} + id: pr_context + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + ISSUE_NUMBER: ${{ github.event.issue.number || '' }} + PULL_URL: ${{ github.event.issue.pull_request.url || '' }} + COMMENT_PR_URL: ${{ github.event.comment.pull_request_url || '' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const parseNumberFromUrl = (url) => { + if (!url || typeof url !== 'string') { + return 0; + } + const segments = url.trim().split('/').filter(Boolean); + const last = segments[segments.length - 1]; + const value = Number(last); + return Number.isFinite(value) ? value : 0; + }; + + let prNumber = Number(process.env.PR_NUMBER || 0); + const issueNumberEnv = Number(process.env.ISSUE_NUMBER || 0); + + if (!prNumber && context.payload.pull_request?.number) { + prNumber = context.payload.pull_request.number; + } + + if (!prNumber && context.payload.issue?.number) { + prNumber = context.payload.issue.number; + } + + if (!prNumber && issueNumberEnv) { + prNumber = issueNumberEnv; + } + + if (!prNumber && context.payload.issue?.pull_request?.url) { + prNumber = parseNumberFromUrl(context.payload.issue.pull_request.url); + } + + if (!prNumber && process.env.PULL_URL) { + prNumber = parseNumberFromUrl(process.env.PULL_URL); + } + + if (!prNumber && process.env.COMMENT_PR_URL) { + prNumber = parseNumberFromUrl(process.env.COMMENT_PR_URL); + } + + if (!prNumber) { + core.info('PR number not resolved; proceeding without PR context.'); + core.setOutput('pr_number', ''); + core.setOutput('head_sha', ''); + core.setOutput('head_ref', ''); + core.setOutput('checkout_ref', ''); + core.setOutput('head_repo', ''); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const headSha = pr.head.sha || ''; + const headRef = pr.head.ref || ''; + const checkoutRef = headSha || (headRef ? `refs/heads/${headRef}` : `refs/pull/${prNumber}/head`); + const headRepo = pr.head.repo?.full_name || `${context.repo.owner}/${context.repo.repo}`; + + core.setOutput('pr_number', pr.number.toString()); + core.setOutput('head_sha', headSha); + core.setOutput('head_ref', headRef); + core.setOutput('checkout_ref', checkoutRef); + core.setOutput('head_repo', headRepo); + + - name: Notify missing Anthropic key + if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'false' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = process.env.CLAUDE_SETUP_REMINDER_MARKER || ''; + const docUrl = process.env.CLAUDE_REVIEW_DOC_URL || 'https://wiki.gluzdov.com/doc/claude-review-workflow-setup-Dbg0WdgMsk'; + const commentPrefix = process.env.COMMENT_PREFIX || ''; + const prNumber = Number('${{ steps.pr_context.outputs.pr_number || '' }}'); + if (!prNumber) { + core.info('PR number unavailable; skipping reminder comment.'); + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }); + const existing = comments.find(comment => comment.body && comment.body.includes(marker)); + + if (!existing) { + const body = `${commentPrefix}${marker} + + ### Claude Review Setup Required + + The Claude review workflow is disabled because \`ANTHROPIC_API_KEY\` is not configured. + + Please follow the setup guide: ${docUrl} + `; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body + }); + } + + - name: Checkout repository + if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + repository: ${{ steps.pr_context.outputs.head_repo || github.repository }} + ref: ${{ steps.pr_context.outputs.checkout_ref || github.event.pull_request.head.sha || github.event.pull_request.head.ref || github.sha }} + + - name: Run Claude Code Action v3.5 Sonnet + if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' }} + id: claude_review + continue-on-error: true + uses: anthropics/claude-code-base-action@v0.0.63 # NOSONAR + env: + ANTHROPIC_MODEL: claude-3-5-sonnet-latest + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + allowed_tools: | + Bash(git diff --name-only HEAD~1) + Bash(git diff HEAD~1) + View + Glob + Grep + Read + claude_env: | + - DEBUG: ${{ vars.PR_REVIEW_DEBUG || 'false' }} + - LOG_LEVEL: debug + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ steps.pr_context.outputs.pr_number || github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + + Review only the changed files and diff hunks in this pull request. Focus on defects, security/performance risks, missing tests, or violations of project conventions. + + Respond STRICTLY with valid JSON matching this schema (no extra text): + { + "summary": "Concise markdown summary (optional)", + "inline_comments": [ + { + "path": "relative/path/from/repo/root.ext", + "line": , + "severity": "BUG|SECURITY|PERFORMANCE|SUGGESTION", + "category": "blocking_operation|missing_origin_check|etc", + "rule_id": "RULE_ID", + "summary": "Brief description", + "body": "**[EMOJI] [SEVERITY]: [Brief description]**\n\n[Details]\n\n**Suggestion:**\n```rust\n[fix]\n```\n\n**Why this matters:** [Impact]", + "confidence": 0.9 + } + ] + } + + Rules: + - Include an inline comment only when it points to a concrete issue in the diff. Skip praise. + - If you found nothing important, return {"summary": "", "inline_comments": []}. + - For each inline comment, reference the exact changed line so the position is unambiguous. + + max_turns: 5 + + - name: Process Claude review + if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' && steps.claude_review.outcome == 'success' }} + uses: actions/github-script@v7 + env: + EXECUTION_FILE: ${{ steps.claude_review.outputs.execution_file }} + PR_NUMBER: ${{ steps.pr_context.outputs.pr_number || github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + HEAD_SHA: ${{ steps.pr_context.outputs.head_sha || github.event.pull_request.head.sha || github.sha }} + OUTPUT_DIR: pr-review-results + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + const executionFile = process.env.EXECUTION_FILE; + if (!executionFile || !fs.existsSync(executionFile)) { + core.warning('Claude execution file not found; skipping processing.'); + return; + } + + const prNumber = Number(process.env.PR_NUMBER || 0); + if (!prNumber) { + core.warning('PR number unavailable; cannot post review.'); + return; + } + + const executionLog = JSON.parse(fs.readFileSync(executionFile, 'utf8')); + + const commentPrefix = process.env.COMMENT_PREFIX || ''; + let totalCost = null; + const considerCost = (candidate) => { + if (!candidate || typeof candidate !== 'object') { + return; + } + if (typeof candidate.total_cost_usd === 'number') { + totalCost = candidate.total_cost_usd; + } else if (candidate.result && typeof candidate.result.total_cost_usd === 'number') { + totalCost = candidate.result.total_cost_usd; + } + }; + + const visit = (value) => { + if (Array.isArray(value)) { + value.forEach(visit); + } else if (value && typeof value === 'object') { + considerCost(value); + visit(value.message); + visit(value.data); + } + }; + visit(executionLog); + + const entries = Array.isArray(executionLog) ? executionLog : [executionLog]; + const assistantEntry = [...entries].reverse().find((entry) => entry && entry.type === 'assistant' && Array.isArray(entry.message?.content) && entry.message.content.length); + if (!assistantEntry) { + core.info('No assistant response found to extract review from.'); + return; + } + + const textPayload = assistantEntry.message.content + .filter((part) => part && part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n') + .trim(); + + if (!textPayload) { + core.info('Assistant response did not include textual content to parse.'); + return; + } + + const tryParseJson = (input) => { + try { + return JSON.parse(input); + } catch (error) { + return null; + } + }; + + let reviewPayload = tryParseJson(textPayload); + if (!reviewPayload) { + const jsonBlockMatch = textPayload.match(/```json\s*([\s\S]*?)```/i) || textPayload.match(/```\s*([\s\S]*?)```/); + if (jsonBlockMatch && jsonBlockMatch[1]) { + reviewPayload = tryParseJson(jsonBlockMatch[1].trim()); + } + } + if (!reviewPayload) { + const firstBrace = textPayload.indexOf('{'); + const lastBrace = textPayload.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + reviewPayload = tryParseJson(textPayload.slice(firstBrace, lastBrace + 1)); + } + } + + if (!reviewPayload || typeof reviewPayload !== 'object') { + core.warning('Claude response did not include a parsable JSON review payload.'); + return; + } + + const summary = typeof reviewPayload.summary === 'string' ? reviewPayload.summary.trim() : ''; + const inlineComments = Array.isArray(reviewPayload.inline_comments) ? reviewPayload.inline_comments : []; + + const comments = inlineComments + .map((comment) => { + if (!comment || typeof comment.path !== 'string' || typeof comment.line !== 'number' || !comment.body) { + return null; + } + const body = String(comment.body).trim(); + if (!body) { + return null; + } + return { + path: comment.path, + line: comment.line, + side: 'RIGHT', + body: `${commentPrefix}${body}` + }; + }) + .filter(Boolean); + + if (comments.length === 0 && !summary && totalCost === null) { + core.info('Claude returned no actionable feedback.'); + return; + } + + const costLine = typeof totalCost === 'number' ? `Estimated cost: $${totalCost.toFixed(5)}` : ''; + let reviewBody = ''; + if (summary || costLine) { + const bodySegments = ['## Claude Code Review']; + if (summary) { + bodySegments.push('', summary); + } + if (costLine) { + bodySegments.push('', `*${costLine}*`); + } + reviewBody = `${commentPrefix}${bodySegments.join('\n')}`; + } + + let headSha = process.env.HEAD_SHA || ''; + if (!headSha) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + headSha = pr.head.sha; + } + + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + commit_id: headSha, + event: 'COMMENT', + body: reviewBody, + comments + }); + + const resultsDir = process.env.OUTPUT_DIR || 'pr-review-results'; + fs.mkdirSync(resultsDir, { recursive: true }); + const resultPayload = { + service: 'claude', + status: 'success', + cost_usd: totalCost || 0, + stats: { + comments: comments.length, + summary: Boolean(summary) + }, + timestamp: new Date().toISOString() + }; + fs.writeFileSync(path.join(resultsDir, 'claude.json'), JSON.stringify(resultPayload, null, 2)); + + const summaryLines = ['### Claude Code Review', '', `- Comments posted: ${comments.length}`, `- Estimated cost: $${(totalCost || 0).toFixed(5)}`]; + if (summary) { + summaryLines.push('', summary); + } + fs.writeFileSync(path.join(resultsDir, 'claude-summary.md'), summaryLines.join('\n')); + + await core.summary + .addHeading('Claude Code Cost Summary') + .addList([ + 'Outcome: success', + `Estimated cost: $${(totalCost || 0).toFixed(5)}` + ]) + .write(); + + const marker = process.env.CLAUDE_COST_SUMMARY_MARKER || ''; + const stickyLines = [ + marker, + '### Claude Code Cost Summary', + '', + '- Outcome: success', + `- Estimated cost: $${(totalCost || 0).toFixed(5)}`, + '' + ]; + const stickyBody = `${commentPrefix}${stickyLines.join('\n')}`; + + const existingComments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }); + + const existing = [...existingComments].reverse().find((comment) => comment.body && comment.body.includes(marker)); + if (existing) { + if (existing.body.trim() !== stickyBody.trim()) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: stickyBody + }); + } else { + core.info('Claude cost summary already up to date.'); + } + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: stickyBody + }); + } + + - name: Upload Claude execution log + if: ${{ steps.trigger.outputs.should_run == 'true' && steps.claude_token.outputs.available == 'true' && steps.claude_review.outputs.execution_file != '' }} + uses: actions/upload-artifact@v4 + with: + name: pr-review-claude-execution-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} + path: ${{ steps.claude_review.outputs.execution_file }} + retention-days: ${{ vars.PR_REVIEW_ARTIFACT_RETENTION_DAYS || 7 }} + if-no-files-found: ignore diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a8aa1d4..6a3e9fe 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -26,7 +26,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Install dependencies run: cd workers/main && npm install - name: Run tests with coverage