diff --git a/.github/workflows/ai-code-review.yml b/.github/workflows/ai-code-review.yml new file mode 100644 index 00000000..e3183b55 --- /dev/null +++ b/.github/workflows/ai-code-review.yml @@ -0,0 +1,160 @@ +# AI Code Review Workflow +# Uses Claude Code CLI on self-hosted runner with CLIProxyAPI +# Posts as ccs-agy-reviewer[bot] via GitHub App +# +# Triggers: +# - Automatically on PR open/update +# - Manually via /review comment on PR + +name: AI Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + +# Cancel in-progress runs for same PR +concurrency: + group: ai-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + name: Claude Code Review + # Only run on self-hosted runner with cliproxy access + runs-on: [self-hosted, cliproxy] + + # Conditions: + # - PR event: always run + # - Comment event: only if it's a PR and contains /review + if: > + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/review')) + + steps: + - name: Generate App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CCS_REVIEWER_APP_ID }} + private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine PR Number + id: pr + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + fi + + - name: Add reaction to comment + if: github.event_name == 'issue_comment' + run: | + gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + --method POST -f content=eyes + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Ensure Claude Code installed + run: | + if ! command -v claude &> /dev/null; then + echo "[i] Installing Claude Code..." + curl -fsSL https://claude.ai/install.sh | bash + echo "$HOME/.local/bin" >> $GITHUB_PATH + else + echo "[i] Claude Code already installed: $(claude --version)" + fi + + - name: Run Claude Code Review + env: + # CLIProxyAPI configuration + ANTHROPIC_BASE_URL: http://localhost:8317 + ANTHROPIC_API_KEY: ${{ secrets.CLIPROXY_API_KEY }} + ANTHROPIC_MODEL: gemini-3-pro-preview + # GitHub token for gh CLI + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + PR_NUM="${{ steps.pr.outputs.number }}" + REPO="${{ github.repository }}" + + echo "[i] Running Claude Code review for PR #${PR_NUM}" + echo "[i] Model: ${ANTHROPIC_MODEL}" + + # Get PR diff for context + DIFF=$(gh pr diff $PR_NUM --repo $REPO | head -5000) + PR_TITLE=$(gh pr view $PR_NUM --repo $REPO --json title --jq .title) + PR_BODY=$(gh pr view $PR_NUM --repo $REPO --json body --jq .body) + + # Run Claude Code in print mode with review prompt + REVIEW=$(claude -p "You are a senior engineer reviewing PR #${PR_NUM}: '${PR_TITLE}'. + +## Context +Repository: ${REPO} +Description: ${PR_BODY} + +## Diff +\`\`\`diff +${DIFF} +\`\`\` + +## Your Task +Review this PR with analytical depth. Focus on: +- ๐Ÿ” Security: auth bypass, injection, secrets exposure +- ๐Ÿ› Correctness: edge cases, null handling, race conditions +- ๐Ÿงน Maintainability: coupling, naming, hidden complexity +- โšก Performance: only if measurable impact + +## Output Format + +## ๐Ÿ” Code Review (${ANTHROPIC_MODEL}) + +**Verdict**: โœ… Approve | โœ… Approve with notes | โš ๏ธ Request changes + +### ๐Ÿ“‹ What This PR Does +[1-2 sentences] + +### โœจ The Good +[Only if worth mentioning] + +### ๐Ÿšจ Concerns +Use severity: ๐Ÿ”ด Critical | ๐ŸŸก Warning | ๐ŸŸข Suggestion +Format: \`file:line\` - what's wrong โ†’ what could happen + +### โ“ Questions for Author +[Only if clarification needed] + +--- +Be direct. Skip sections if nothing significant. Approve unless ๐Ÿ”ด Critical issue exists." \ + --allowed-tools "Read,Glob,Grep" \ + --max-turns 3) + + # Post review as comment + echo "[i] Posting review..." + echo "$REVIEW" | gh pr comment $PR_NUM --repo $REPO --body-file - + echo "[OK] Review posted" + + - name: Add success reaction + if: success() && github.event_name == 'issue_comment' + run: | + gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + --method POST -f content=rocket + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Add failure reaction + if: failure() && github.event_name == 'issue_comment' + run: | + gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + --method POST -f content=confused + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/package.json b/package.json index 62e2a342..56aa1cec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.15.0", + "version": "7.15.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/code-reviewer.ts b/scripts/code-reviewer.ts new file mode 100755 index 00000000..fe81ccc8 --- /dev/null +++ b/scripts/code-reviewer.ts @@ -0,0 +1,217 @@ +#!/usr/bin/env bun +/** + * AI Code Reviewer for CCS CLI + * + * Fetches PR diff, calls Claude via CLIProxyAPI, posts review as comment. + * Runs on self-hosted runner with localhost access to CLIProxyAPI:8317. + * Posts as ccs-agy-reviewer[bot] via GitHub App token. + * + * Usage: bun run scripts/code-reviewer.ts + * Env: CLIPROXY_API_KEY, GITHUB_REPOSITORY, GH_TOKEN + */ + +import { $ } from 'bun'; + +// Types +interface PRContext { + number: number; + title: string; + body: string; + baseRef: string; + headRef: string; + files: Array<{ path: string; additions: number; deletions: number }>; + diff: string; +} + +// Config +const MAX_DIFF_LINES = 10000; +const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317'; +const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-5-thinking'; + +// System prompt for code review - new style +const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project. + +## Review Guidelines +- Focus ONLY on changes in this PR - don't suggest unrelated improvements +- Be concise - no fluff, no excessive praise +- Provide specific file:line references for issues +- Verify claims before making them (check if patterns exist, check actual code) +- Avoid over-engineering suggestions for simple fixes + +## Check For +1. **Bugs**: Logic errors, edge cases, null handling, race conditions +2. **Security**: Injection, auth bypass, secrets exposure, data leaks +3. **Performance**: N+1 queries, missing indexes, inefficient algorithms +4. **TypeScript**: Proper typing, no \`any\`, null safety +5. **Consistency**: Similar patterns exist elsewhere that need same fix? + +## Output Format +Structure your response EXACTLY like this (no code fences, render as markdown): + +## ๐Ÿ” Code Review + +**Verdict**: [โœ… Approve | โœ… Approve with suggestions | โš ๏ธ Request changes] + +### Summary +[1-2 sentences on what the PR does and if it's correct] + +### โœ… What's Good +- [Bullet points, 2-4 items max] + +### โš ๏ธ Issues Found +| File:Line | Issue | Severity | +|-----------|-------|----------| +| \`file.ts:123\` | Description | ๐Ÿ”ด High / ๐ŸŸก Medium / ๐ŸŸข Low | + +(If no issues, write "None - LGTM") + +### ๐Ÿ’ก Suggestions (Optional) +- [Only if truly valuable, max 2 items] + +IMPORTANT: Output ONLY the markdown review. No JSON, no code blocks wrapping the review.`; + +// Fetch PR context +async function getPRContext(prNumber: number, repo: string): Promise { + $.throws(true); + + // Get PR metadata + const prJson = + await $`gh pr view ${prNumber} --repo ${repo} --json number,title,body,baseRefName,headRefName,files`.text(); + const pr = JSON.parse(prJson); + + // Get diff + let diff = await $`gh pr diff ${prNumber} --repo ${repo}`.text(); + + // Truncate if too large + const lines = diff.split('\n'); + if (lines.length > MAX_DIFF_LINES) { + diff = lines.slice(0, MAX_DIFF_LINES).join('\n') + '\n\n[DIFF TRUNCATED - exceeded 10k lines]'; + } + + return { + number: pr.number, + title: pr.title, + body: pr.body || '', + baseRef: pr.baseRefName, + headRef: pr.headRefName, + files: pr.files || [], + diff, + }; +} + +// Call Claude via CLIProxyAPI +async function callClaude(context: PRContext, repo: string): Promise { + const apiKey = process.env.CLIPROXY_API_KEY; + if (!apiKey) throw new Error('CLIPROXY_API_KEY not set'); + + const userMessage = `REPO: ${repo} +PR NUMBER: ${context.number} + +## Pull Request: ${context.title} + +### Description +${context.body || '(No description provided)'} + +### Changed Files +${context.files.map((f) => `- ${f.path} (+${f.additions}/-${f.deletions})`).join('\n')} + +### Diff +\`\`\`diff +${context.diff} +\`\`\` + +Review this PR following the guidelines. Refer to the project's CLAUDE.md and docs/ folder for conventions.`; + + const response = await fetch(`${CLIPROXY_URL}/v1/messages`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 4096, + system: CODE_REVIEWER_SYSTEM_PROMPT, + messages: [{ role: 'user', content: userMessage }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`CLIProxyAPI error: ${response.status} - ${error}`); + } + + const data = (await response.json()) as { content: Array<{ text: string }> }; + const content = data.content[0]?.text; + + if (!content) { + throw new Error('Empty response from Claude'); + } + + return content; +} + +// Post review as PR comment +async function postReview(prNumber: number, repo: string, reviewContent: string): Promise { + // Use gh pr comment to post the review + await $`gh pr comment ${prNumber} --repo ${repo} --body ${reviewContent}`; +} + +// Check if already reviewed this PR (avoid spam) +async function hasRecentReview(prNumber: number, repo: string): Promise { + try { + const comments = + await $`gh api repos/${repo}/issues/${prNumber}/comments --jq '[.[] | select(.body | contains("๐Ÿ” Code Review"))] | length'`.text(); + return parseInt(comments.trim(), 10) > 0; + } catch { + return false; + } +} + +// Main +async function main() { + const prNumber = parseInt(process.argv[2], 10); + const repo = process.env.GITHUB_REPOSITORY || 'kaitranntt/ccs'; + const forceReview = process.argv.includes('--force'); + + if (!prNumber || isNaN(prNumber)) { + console.error('Usage: bun run scripts/code-reviewer.ts [--force]'); + process.exit(1); + } + + console.log(`[i] Reviewing PR #${prNumber} in ${repo}`); + + try { + // Check for existing review (avoid spam) + if (!forceReview && (await hasRecentReview(prNumber, repo))) { + console.log('[i] Already reviewed this PR. Use --force to review again.'); + process.exit(0); + } + + // 1. Get PR context + console.log('[i] Fetching PR context...'); + const context = await getPRContext(prNumber, repo); + console.log(`[i] PR: "${context.title}" (${context.files.length} files changed)`); + + const diffLines = context.diff.split('\n').length; + if (diffLines > MAX_DIFF_LINES) { + console.log(`[!] Diff too large (${diffLines} lines), truncated to ${MAX_DIFF_LINES}`); + } + + // 2. Call Claude + console.log(`[i] Calling Claude (${MODEL}) for review...`); + const reviewContent = await callClaude(context, repo); + console.log('[i] Review generated'); + + // 3. Post review as comment + console.log('[i] Posting review to PR...'); + await postReview(prNumber, repo, reviewContent); + console.log('[OK] Review posted successfully'); + } catch (error) { + console.error('[X] Review failed:', error); + process.exit(1); + } +} + +main();