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_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