Skip to content

Claude Code Review

Claude Code Review #32

Workflow file for this run

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.ref }}
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: '<img src="https://upload.wikimedia.org/wikipedia/commons/b/b0/Claude_AI_symbol.svg" alt="Claude" width="20" height="20"> Claude'
CLAUDE_COST_SUMMARY_MARKER: '<!-- claude-cost-summary -->'
CLAUDE_SETUP_REMINDER_MARKER: '<!-- claude-review-setup-reminder -->'
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}<!-- claude-review onboarding -->`,
'',
'> [!NOTE] To trigger a Claude review, invoke the `@claude review` command.',
'',
'<!-- end claude-review onboarding -->'
].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 || '<!-- claude-review-setup-reminder -->';
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@e8132bc5e637a42c27763fc757faa37e1ee43b34
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: true
- 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": <integer line number on the new code>,
"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 || '<!-- claude-cost-summary -->';
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