feat(ci): Claude Code CLI for AI reviews (#290)

* feat(ci): add AI code review workflow with Claude via CLIProxyAPI

- Self-hosted runner calls CLIProxyAPI at localhost:8317
- Triggers on PR open/update and /review comment
- Uses gemini-claude-opus-4-5-thinking for deep reviews
- Posts summary + inline comments via gh CLI
- Handles self-PR fallback to COMMENT mode

* chore(release): 7.15.0-dev.1 [skip ci]

* refactor(ci): use GitHub App for reviewer identity + new review format

- Posts as ccs-agy-reviewer[bot] via GitHub App token
- New review format: structured markdown with verdict, summary, issues table
- Single PR comment instead of inline comments
- Concise, focused on PR changes only

* chore(release): 7.15.0-dev.2 [skip ci]

* refactor(ci): switch to Claude Code CLI for reviews

- Use claude -p instead of custom TypeScript script
- Auto-install if not present on runner
- CLIProxyAPI via env vars (ANTHROPIC_BASE_URL, ANTHROPIC_MODEL)
- Allowed tools: Read, Glob, Grep
- Max 3 turns for file exploration

* chore(release): 7.15.0-dev.3 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-07 02:11:14 -08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent a0ac773ead
commit 49c4d299c0
3 changed files with 378 additions and 1 deletions
+160
View File
@@ -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 }}
+1 -1
View File
@@ -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",
+217
View File
@@ -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 <PR_NUMBER>
* 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<PRContext> {
$.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<string> {
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<void> {
// 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<boolean> {
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 <PR_NUMBER> [--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();