mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
a0ac773ead
commit
49c4d299c0
@@ -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
@@ -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",
|
||||
|
||||
Executable
+217
@@ -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();
|
||||
Reference in New Issue
Block a user