@kaitranntt/ccs 7.14.0-dev.4 → 7.15.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/code-reviewer.ts +308 -0
package/package.json
CHANGED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* AI Code Reviewer for CCS CLI
|
|
4
|
+
*
|
|
5
|
+
* Fetches PR diff, calls Claude via CLIProxyAPI, posts review to GitHub.
|
|
6
|
+
* Runs on self-hosted runner with localhost access to CLIProxyAPI:8317.
|
|
7
|
+
*
|
|
8
|
+
* Usage: bun run scripts/code-reviewer.ts <PR_NUMBER>
|
|
9
|
+
* Env: CLIPROXY_API_KEY, GITHUB_REPOSITORY (optional)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { $ } from 'bun';
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
interface PRContext {
|
|
16
|
+
number: number;
|
|
17
|
+
title: string;
|
|
18
|
+
body: string;
|
|
19
|
+
baseRef: string;
|
|
20
|
+
headRef: string;
|
|
21
|
+
files: Array<{ path: string; additions: number; deletions: number }>;
|
|
22
|
+
diff: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ReviewComment {
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
body: string;
|
|
29
|
+
severity: 'critical' | 'warning' | 'suggestion' | 'nitpick';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ReviewResult {
|
|
33
|
+
summary: string;
|
|
34
|
+
approvalStatus: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
|
|
35
|
+
comments: ReviewComment[];
|
|
36
|
+
securityIssues: string[];
|
|
37
|
+
suggestions: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// System prompt for code review
|
|
41
|
+
const CODE_REVIEWER_SYSTEM_PROMPT = `You are an expert code reviewer for the CCS CLI project, a TypeScript/Node.js tool for managing Claude Code accounts.
|
|
42
|
+
|
|
43
|
+
## Your Role
|
|
44
|
+
Review pull requests thoroughly for:
|
|
45
|
+
1. **Bugs & Logic Errors** - Race conditions, off-by-one, null handling
|
|
46
|
+
2. **Security Issues** - Injection, secrets exposure, auth bypass
|
|
47
|
+
3. **Code Quality** - YAGNI, KISS, DRY violations; readability
|
|
48
|
+
4. **TypeScript Best Practices** - Proper typing, no \`any\`, null safety
|
|
49
|
+
5. **CCS Conventions** - ASCII only (no emojis), conventional commits
|
|
50
|
+
|
|
51
|
+
## Output Format
|
|
52
|
+
Respond with ONLY valid JSON matching this schema:
|
|
53
|
+
|
|
54
|
+
\`\`\`json
|
|
55
|
+
{
|
|
56
|
+
"summary": "2-3 sentence overall assessment",
|
|
57
|
+
"approvalStatus": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
|
|
58
|
+
"comments": [
|
|
59
|
+
{
|
|
60
|
+
"path": "relative/path/to/file.ts",
|
|
61
|
+
"line": 42,
|
|
62
|
+
"body": "Specific feedback for this line",
|
|
63
|
+
"severity": "critical" | "warning" | "suggestion" | "nitpick"
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"securityIssues": ["List of security concerns if any"],
|
|
67
|
+
"suggestions": ["General improvement suggestions"]
|
|
68
|
+
}
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
## Guidelines
|
|
72
|
+
- Be constructive, not harsh
|
|
73
|
+
- Prioritize critical issues over nitpicks
|
|
74
|
+
- Reference specific lines and provide fix suggestions
|
|
75
|
+
- If no issues: approvalStatus = "APPROVE", comments = []
|
|
76
|
+
- Max 10 inline comments (focus on most important)`;
|
|
77
|
+
|
|
78
|
+
// Config
|
|
79
|
+
const MAX_DIFF_LINES = 10000;
|
|
80
|
+
const MAX_INLINE_COMMENTS = 10;
|
|
81
|
+
const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317';
|
|
82
|
+
const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-5-thinking';
|
|
83
|
+
|
|
84
|
+
// Fetch PR context
|
|
85
|
+
async function getPRContext(prNumber: number, repo: string): Promise<PRContext> {
|
|
86
|
+
$.throws(true);
|
|
87
|
+
|
|
88
|
+
// Get PR metadata
|
|
89
|
+
const prJson =
|
|
90
|
+
await $`gh pr view ${prNumber} --repo ${repo} --json number,title,body,baseRefName,headRefName,files`.text();
|
|
91
|
+
const pr = JSON.parse(prJson);
|
|
92
|
+
|
|
93
|
+
// Get diff
|
|
94
|
+
let diff = await $`gh pr diff ${prNumber} --repo ${repo}`.text();
|
|
95
|
+
|
|
96
|
+
// Truncate if too large
|
|
97
|
+
const lines = diff.split('\n');
|
|
98
|
+
if (lines.length > MAX_DIFF_LINES) {
|
|
99
|
+
diff = lines.slice(0, MAX_DIFF_LINES).join('\n') + '\n\n[DIFF TRUNCATED - exceeded 10k lines]';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
number: pr.number,
|
|
104
|
+
title: pr.title,
|
|
105
|
+
body: pr.body || '',
|
|
106
|
+
baseRef: pr.baseRefName,
|
|
107
|
+
headRef: pr.headRefName,
|
|
108
|
+
files: pr.files || [],
|
|
109
|
+
diff,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Call Claude via CLIProxyAPI
|
|
114
|
+
async function callClaude(context: PRContext): Promise<ReviewResult> {
|
|
115
|
+
const apiKey = process.env.CLIPROXY_API_KEY;
|
|
116
|
+
if (!apiKey) throw new Error('CLIPROXY_API_KEY not set');
|
|
117
|
+
|
|
118
|
+
const userMessage = `## Pull Request: ${context.title}
|
|
119
|
+
|
|
120
|
+
### Description
|
|
121
|
+
${context.body || '(No description provided)'}
|
|
122
|
+
|
|
123
|
+
### Changed Files
|
|
124
|
+
${context.files.map((f) => `- ${f.path} (+${f.additions}/-${f.deletions})`).join('\n')}
|
|
125
|
+
|
|
126
|
+
### Diff
|
|
127
|
+
\`\`\`diff
|
|
128
|
+
${context.diff}
|
|
129
|
+
\`\`\`
|
|
130
|
+
|
|
131
|
+
Please review this pull request and provide your assessment in the specified JSON format.`;
|
|
132
|
+
|
|
133
|
+
const response = await fetch(`${CLIPROXY_URL}/v1/messages`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
'x-api-key': apiKey,
|
|
137
|
+
'anthropic-version': '2023-06-01',
|
|
138
|
+
'content-type': 'application/json',
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
model: MODEL,
|
|
142
|
+
max_tokens: 8192,
|
|
143
|
+
system: CODE_REVIEWER_SYSTEM_PROMPT,
|
|
144
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const error = await response.text();
|
|
150
|
+
throw new Error(`CLIProxyAPI error: ${response.status} - ${error}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = (await response.json()) as { content: Array<{ text: string }> };
|
|
154
|
+
const content = data.content[0]?.text;
|
|
155
|
+
|
|
156
|
+
if (!content) {
|
|
157
|
+
throw new Error('Empty response from Claude');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Parse JSON from response (may be wrapped in markdown code block)
|
|
161
|
+
const jsonMatch = content.match(/```json\n?([\s\S]*?)\n?```/);
|
|
162
|
+
const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(jsonStr) as ReviewResult;
|
|
166
|
+
} catch {
|
|
167
|
+
// Fallback: create basic review from raw text
|
|
168
|
+
console.warn('[!] Failed to parse JSON, using fallback');
|
|
169
|
+
return {
|
|
170
|
+
summary: content.slice(0, 500),
|
|
171
|
+
approvalStatus: 'COMMENT',
|
|
172
|
+
comments: [],
|
|
173
|
+
securityIssues: [],
|
|
174
|
+
suggestions: [],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Format inline comment with severity badge
|
|
180
|
+
function formatComment(comment: ReviewComment): string {
|
|
181
|
+
const badge: Record<string, string> = {
|
|
182
|
+
critical: '[!] **Critical**',
|
|
183
|
+
warning: '[~] **Warning**',
|
|
184
|
+
suggestion: '[i] **Suggestion**',
|
|
185
|
+
nitpick: '[ ] Nitpick',
|
|
186
|
+
};
|
|
187
|
+
return `${badge[comment.severity] || '[i]'}\n\n${comment.body}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Post review to GitHub
|
|
191
|
+
async function postReview(prNumber: number, repo: string, review: ReviewResult): Promise<void> {
|
|
192
|
+
// Build review body
|
|
193
|
+
let body = `## AI Code Review\n\n${review.summary}\n`;
|
|
194
|
+
|
|
195
|
+
if (review.securityIssues.length > 0) {
|
|
196
|
+
body += `\n### Security Issues\n${review.securityIssues.map((i) => `- ${i}`).join('\n')}\n`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (review.suggestions.length > 0) {
|
|
200
|
+
body += `\n### Suggestions\n${review.suggestions.map((s) => `- ${s}`).join('\n')}\n`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (review.comments.length > 0) {
|
|
204
|
+
body += `\n### Inline Comments\n${review.comments.length} comment(s) posted on specific lines.\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
body += '\n---\n*Automated review by CCS AGY Code Reviewer*';
|
|
208
|
+
|
|
209
|
+
// Map approval status to gh flag
|
|
210
|
+
const eventFlag: Record<string, string> = {
|
|
211
|
+
APPROVE: '--approve',
|
|
212
|
+
REQUEST_CHANGES: '--request-changes',
|
|
213
|
+
COMMENT: '--comment',
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Post main review (try APPROVE/REQUEST_CHANGES, fallback to COMMENT if self-PR)
|
|
217
|
+
const flag = eventFlag[review.approvalStatus] || '--comment';
|
|
218
|
+
const reviewResult = await $`gh pr review ${prNumber} --repo ${repo} ${flag} --body ${body}`.nothrow();
|
|
219
|
+
|
|
220
|
+
if (reviewResult.exitCode !== 0) {
|
|
221
|
+
const stderr = reviewResult.stderr.toString();
|
|
222
|
+
// GitHub doesn't allow self-approval or self-request-changes - fallback to comment
|
|
223
|
+
if (stderr.includes('your own pull request')) {
|
|
224
|
+
console.log('[i] Self-PR detected, falling back to COMMENT');
|
|
225
|
+
await $`gh pr review ${prNumber} --repo ${repo} --comment --body ${body}`;
|
|
226
|
+
} else {
|
|
227
|
+
throw new Error(`Failed to post review: ${stderr}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Post inline comments via REST API
|
|
232
|
+
for (const comment of review.comments.slice(0, MAX_INLINE_COMMENTS)) {
|
|
233
|
+
try {
|
|
234
|
+
const commentBody = formatComment(comment);
|
|
235
|
+
// Get the PR head SHA for the comment
|
|
236
|
+
const prInfo = await $`gh pr view ${prNumber} --repo ${repo} --json headRefOid --jq .headRefOid`.text();
|
|
237
|
+
const commitId = prInfo.trim();
|
|
238
|
+
|
|
239
|
+
await $`gh api repos/${repo}/pulls/${prNumber}/comments --method POST \
|
|
240
|
+
-f body=${commentBody} \
|
|
241
|
+
-f path=${comment.path} \
|
|
242
|
+
-F line=${comment.line} \
|
|
243
|
+
-f side=RIGHT \
|
|
244
|
+
-f commit_id=${commitId}`;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`[!] Failed to post inline comment on ${comment.path}:${comment.line}`, err);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check if already reviewed this PR (avoid spam)
|
|
252
|
+
async function hasRecentReview(prNumber: number, repo: string): Promise<boolean> {
|
|
253
|
+
try {
|
|
254
|
+
const reviews =
|
|
255
|
+
await $`gh api repos/${repo}/pulls/${prNumber}/reviews --jq '[.[] | select(.body | contains("AI Code Review"))] | length'`.text();
|
|
256
|
+
return parseInt(reviews.trim(), 10) > 0;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Main
|
|
263
|
+
async function main() {
|
|
264
|
+
const prNumber = parseInt(process.argv[2], 10);
|
|
265
|
+
const repo = process.env.GITHUB_REPOSITORY || 'kaitranntt/ccs';
|
|
266
|
+
const forceReview = process.argv.includes('--force');
|
|
267
|
+
|
|
268
|
+
if (!prNumber || isNaN(prNumber)) {
|
|
269
|
+
console.error('Usage: bun run scripts/code-reviewer.ts <PR_NUMBER> [--force]');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log(`[i] Reviewing PR #${prNumber} in ${repo}`);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
// Check for existing review (avoid spam)
|
|
277
|
+
if (!forceReview && (await hasRecentReview(prNumber, repo))) {
|
|
278
|
+
console.log('[i] Already reviewed this PR. Use --force to review again.');
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 1. Get PR context
|
|
283
|
+
console.log('[i] Fetching PR context...');
|
|
284
|
+
const context = await getPRContext(prNumber, repo);
|
|
285
|
+
console.log(`[i] PR: "${context.title}" (${context.files.length} files changed)`);
|
|
286
|
+
|
|
287
|
+
const diffLines = context.diff.split('\n').length;
|
|
288
|
+
if (diffLines > MAX_DIFF_LINES) {
|
|
289
|
+
console.log(`[!] Diff too large (${diffLines} lines), truncated to ${MAX_DIFF_LINES}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 2. Call Claude
|
|
293
|
+
console.log(`[i] Calling Claude (${MODEL}) for review...`);
|
|
294
|
+
const review = await callClaude(context);
|
|
295
|
+
console.log(`[i] Review complete: ${review.approvalStatus}`);
|
|
296
|
+
console.log(`[i] Comments: ${review.comments.length}, Security issues: ${review.securityIssues.length}`);
|
|
297
|
+
|
|
298
|
+
// 3. Post review
|
|
299
|
+
console.log('[i] Posting review to PR...');
|
|
300
|
+
await postReview(prNumber, repo, review);
|
|
301
|
+
console.log('[OK] Review posted successfully');
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error('[X] Review failed:', error);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
main();
|