@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaitranntt/ccs",
3
- "version": "7.14.0-dev.4",
3
+ "version": "7.15.0-dev.1",
4
4
  "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
5
5
  "keywords": [
6
6
  "cli",
@@ -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();