Files
ccs/scripts/code-reviewer.ts
T
Kai (Tam Nhu) TranGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
051805074e feat: account safety, quota monitoring, and stability fixes (#530)
* fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names (#515)

* fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names

CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
Model names in catalog, base config, and user settings are migrated to upstream
claude-* names. Auto-migration in env-builder rewrites existing user settings on
load and persists the change.

Closes #513

* fix: address code review feedback — sync UI layer and add migration tests

- Sync UI isNativeGeminiModel() with backend (remove gemini-claude- exclusion)
- Update UI model catalog agy entries from gemini-claude-* to claude-*
- Update CI/CD workflow and code-reviewer default model names
- Add unit tests for migrateDeprecatedModelNames() logic

* fix(hooks): isolate image type check before error-prone processing (#514)

* fix(hooks): isolate image type check before error-prone processing

Restructure processHook() into two phases so non-image Read calls
never see hook error messages. Phase 1 defensively checks tool name
and file extension, exiting 0 silently on any failure. Phase 2 only
runs for confirmed image/PDF files where errors are relevant.

Closes #511

* fix(hooks): sync image analyzer hook file on every profile launch

Add installImageAnalyzerHook() call to cliproxy executor, matching
the existing installWebSearchHook() pattern. This ensures the .cjs
file in ~/.ccs/hooks/ gets refreshed from the npm package on every
launch, so users receive hook updates after npm update.

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

* fix(cliproxy): add fork:true for Claude model aliases in config generator (#523)

Config generator now outputs fork:true for Claude model alias entries,
ensuring both upstream (claude-*) and aliased (gemini-claude-*) model
names appear in /v1/models listings. Also preserves fork flag when
parsing user-added aliases during config regeneration.

Bumps config version to v7 to trigger regeneration on next ccs doctor.

Closes #522

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

* feat(cliproxy): add account safety guards to prevent Google account bans (#516)

* feat(cliproxy): add account safety guards to prevent Google account bans

Implements cross-provider isolation to prevent Google from flagging
concurrent OAuth usage across different client IDs (ref: #509, #512).

Three pillars:
1. Auto-pause enforcement at session launch — conflicting accounts in
   other Google OAuth providers are paused so CLIProxyAPI can't use them,
   restored on session exit with crash recovery via auto-paused.json
2. Ban/disable detection — error responses matching Google ban patterns
   auto-pause the affected account to prevent further damage
3. Cross-provider conflict warnings during OAuth registration

Key design decisions:
- PID-based session tracking for crash recovery (dead PID = restore)
- Timestamp comparison prevents restoring ban-paused accounts on exit
- Schema validation on auto-paused.json prevents corrupted state
- Falls back to warn-only when another session is managing isolation

* fix(cliproxy): address code review feedback (attempt 1/5)

- Re-read auto-paused.json before write in enforceProviderIsolation to
  reduce concurrent write race window
- Use actual email from registry for display instead of raw accountId
- Export maskEmail for testability
- Add 27 unit tests covering ban detection, email masking,
  cross-provider duplicate detection, enforcement lifecycle,
  crash recovery, and timestamp-guarded restore

* fix(cliproxy): address remaining review feedback (attempt 2/5)

- Add handleBanDetection test verifying account pause on ban error
- Add warnCrossProviderDuplicates tests (true/false/non-Google)
- Document PID reuse limitation in isPidAlive JSDoc comment

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

* feat(cliproxy): runtime quota monitoring during active sessions (#529)

* feat(cliproxy): add runtime quota monitoring during active sessions

Adds adaptive background quota polling to detect and respond to quota
exhaustion during active CLIProxy sessions. Prevents rate-limit-driven
account bans by auto-cooling exhausted accounts and switching defaults.

- Adaptive polling: 300s normal, 60s at 20% threshold, stops at 0%
- Stderr warnings at 20%, boxed exhaustion alerts at 0%
- Cooldown + default switch on exhaustion (existing patterns)
- Configurable via quota_management.runtime_monitor in config.yaml
- Timer.unref() prevents blocking process exit
- monitorStopped guard for in-flight poll safety

Closes #524

* fix: address code review feedback (attempt 1/5)

- M1: Round quotaPercent display with Math.round() to avoid ugly floats
- M2: Rename exhaust_threshold -> exhaustion_threshold for consistency
  with existing auto.exhaustion_threshold config field
- M3: Replace async not.toThrow() with direct await assertion pattern

* fix: address code review feedback (attempt 2/5)

- Remove .claude/agent-memory/ from tracking and add to .gitignore
- Unify cooldown_minutes default to 5 (was 10 in runtime_monitor, 5 in auto)
- Add threshold validation in startQuotaMonitor (warn > exhaustion)
- Document intentional post-switch monitoring gap in code comment

* chore(release): 7.41.0-dev.4 [skip ci]

* fix(cliproxy): mask email in ban detection and fix JSDoc default

- Use maskEmail() in handleBanDetection output for consistency
- Fix cooldown_minutes JSDoc: default is 5, not 10

* chore(release): 7.41.0-dev.5 [skip ci]

* fix(cliproxy): address all review feedback (Low + informational)

- Add sync constraint comment on process.exit handler (executor)
- Add TOCTOU race acceptability comment (account-safety)
- Mask email in handleQuotaExhaustion reason string
- Use realistic exhaustion_threshold (5) in test configs

* chore(release): 7.41.0-dev.6 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-12 00:48:29 +07:00

218 lines
6.6 KiB
TypeScript
Executable File

#!/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 || 'claude-opus-4-6-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();