coderev-cli 1.0.12 → 1.0.13
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/src/cli.js +725 -686
- package/src/cli.js.bak +737 -0
package/src/cli.js
CHANGED
|
@@ -1,719 +1,758 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const { program } = require('commander');
|
|
4
|
-
const chalk = require('chalk');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const pkg = require('../package.json');
|
|
7
|
-
const { reviewDiff } = require('./reviewer');
|
|
8
|
-
const { loadConfig } = require('./config');
|
|
9
|
-
const { resolvePrRef, fetchPrDiff, postPrComment, resolveToken, fetchPrFiles, postInlineComments } = require('./github');
|
|
10
|
-
|
|
11
|
-
program
|
|
12
|
-
.name('coderev')
|
|
13
|
-
.description('AI-powered code review agent')
|
|
14
|
-
.version(pkg.version);
|
|
15
|
-
|
|
16
|
-
program
|
|
17
|
-
.command('review')
|
|
18
|
-
.description('Review a diff or pull request')
|
|
19
|
-
.option('-f, --file <path>', 'Path to diff file (reads stdin if omitted)')
|
|
20
|
-
.option('-r, --repo <path>', 'Path to git repository')
|
|
21
|
-
.option('--base <branch>', 'Base branch for diff (requires --repo)')
|
|
22
|
-
.option('--head <branch>', 'Head branch for diff (requires --repo)')
|
|
23
|
-
.option('-c, --config <path>', 'Path to config file')
|
|
24
|
-
.option('-o, --output <format>', 'Output format (markdown|json|terminal)', 'terminal')
|
|
25
|
-
.option('--pr <ref>', 'GitHub PR to review, e.g. owner/repo#42 or full URL')
|
|
26
|
-
.option('--gl <ref>', 'GitLab MR to review, e.g. owner/repo!42 or full URL')
|
|
27
|
-
.option('--gee <ref>', 'Gitee PR to review, e.g. owner/repo!42 or full URL')
|
|
28
|
-
.option('--gc <ref>', 'GitCode MR to review, e.g. owner/repo!42 or full URL')
|
|
29
|
-
.option('--bb <ref>', 'Bitbucket PR to review, e.g. owner/repo#42 or full URL')
|
|
30
|
-
.option('--all', 'Review all open PRs for the repo (use with --pr owner/repo or --repo)')
|
|
31
|
-
.option('--github-token <token>', 'GitHub personal access token')
|
|
32
|
-
.option('--gitlab-token <token>', 'GitLab personal access token')
|
|
33
|
-
.option('--gee-token <token>', 'Gitee personal access token')
|
|
34
|
-
.option('--gc-token <token>', 'GitCode personal access token')
|
|
35
|
-
.option('--bb-token <token>', 'Bitbucket app password')
|
|
36
|
-
.option('--post', 'Post review result as PR/MR comment')
|
|
37
|
-
.option('--no-cache', 'Skip cache and force fresh review')
|
|
38
|
-
.option('--audit', 'Security audit mode (OWASP-focused review)')
|
|
39
|
-
.option('--single', 'Use single-agent mode (legacy, no parallel review)')
|
|
40
|
-
.option('--min-confidence <number>', 'Minimum confidence threshold 0-100 (default: 60)', '60')
|
|
41
|
-
.option('--agents <list>', 'Comma-separated agent list: security,bugs,quality')
|
|
42
|
-
.action(async (options) => {
|
|
43
|
-
try {
|
|
44
|
-
const config = loadConfig(options.config);
|
|
45
|
-
|
|
46
|
-
let diff;
|
|
47
|
-
let prRef = null;
|
|
48
|
-
|
|
49
|
-
// Load .coderevignore if it exists
|
|
50
|
-
let ignorePattern = '';
|
|
51
|
-
try {
|
|
52
|
-
const fs = require('fs');
|
|
53
|
-
if (fs.existsSync('.coderevignore')) {
|
|
54
|
-
ignorePattern = fs.readFileSync('.coderevignore', 'utf-8')
|
|
55
|
-
.split('\n')
|
|
56
|
-
.filter(l => l.trim() && !l.startsWith('#'))
|
|
57
|
-
.map(l => l.trim())
|
|
58
|
-
.join(',');
|
|
59
|
-
}
|
|
60
|
-
} catch {}
|
|
61
|
-
|
|
62
|
-
if (options.all && prRef) {
|
|
63
|
-
// Batch mode: review all open PRs
|
|
64
|
-
const { listPullRequests } = require('./github');
|
|
65
|
-
const token = resolveToken(options.githubToken, config);
|
|
66
|
-
const repoRef = { owner: prRef.owner, repo: prRef.repo };
|
|
67
|
-
const prList = await listPullRequests(repoRef, token, { state: 'open', limit: 20 });
|
|
68
|
-
|
|
69
|
-
if (prList.length === 0) {
|
|
70
|
-
console.log(chalk.blue(` No open PRs found for ${prRef.owner}/${prRef.repo}`));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
console.error(chalk.bold(`\n📋 Found ${prList.length} open PRs in ${prRef.owner}/${prRef.repo}:`));
|
|
75
|
-
for (const pr of prList) {
|
|
76
|
-
console.error(` #${pr.number} ${pr.title} (${pr.draft ? 'draft' : 'open'})`);
|
|
77
|
-
}
|
|
78
|
-
console.error('');
|
|
79
|
-
|
|
80
|
-
const results = [];
|
|
81
|
-
for (const pr of prList) {
|
|
82
|
-
console.error(chalk.blue(`↻ Reviewing PR #${pr.number}...`));
|
|
83
|
-
const fullRef = { owner: prRef.owner, repo: prRef.repo, pr: pr.number };
|
|
84
|
-
try {
|
|
85
|
-
const prDiff = await fetchPrDiff(fullRef, token);
|
|
86
|
-
const result = await reviewDiff(prDiff, config, { noCache: true, ignorePattern });
|
|
87
|
-
results.push({ number: pr.number, title: pr.title, result });
|
|
88
|
-
|
|
89
|
-
if (options.post) {
|
|
90
|
-
const md = formatMarkdown(result);
|
|
91
|
-
await postPrComment(fullRef, md, token);
|
|
92
|
-
console.error(chalk.green(` ✔ #${pr.number} reviewed & posted`));
|
|
93
|
-
} else {
|
|
94
|
-
const scoreColor = result.score >= 80 ? chalk.green : result.score >= 50 ? chalk.yellow : chalk.red;
|
|
95
|
-
const scoreStr = scoreColor(`${result.score}/100`);
|
|
96
|
-
const issueCount = (result.issues || []).length;
|
|
97
|
-
console.error(` ${scoreStr} (${issueCount} issues) - ${result.summary || ''}`);
|
|
98
|
-
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error(chalk.red(` ✖ #${pr.number}: ${err.message}`));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Summary
|
|
105
|
-
const scores = results.filter(r => r.result).map(r => r.result.score);
|
|
106
|
-
if (scores.length > 0) {
|
|
107
|
-
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1);
|
|
108
|
-
console.error(chalk.bold(`\n📊 Batch Summary: ${results.length}/${prList.length} reviewed, avg score: ${avg}`));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (options.output === 'json') {
|
|
112
|
-
console.log(JSON.stringify(results, null, 2));
|
|
113
|
-
} else if (options.output === 'markdown') {
|
|
114
|
-
for (const r of results) {
|
|
115
|
-
console.log(`## PR #${r.number}: ${r.title}\n`);
|
|
116
|
-
console.log(formatMarkdown(r.result));
|
|
117
|
-
console.log('---\n');
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (options.gl) {
|
|
124
|
-
const { resolveMrRef, fetchMrDiff } = require('./gitlab');
|
|
125
|
-
const glRef = resolveMrRef(options.gl, options.repo);
|
|
126
|
-
const glToken = options.gitlabToken || process.env.GITLAB_TOKEN;
|
|
127
|
-
console.error(chalk.blue(`↻ Fetching GitLab MR ${glRef.owner}/${glRef.repo}!${glRef.mr}...`));
|
|
128
|
-
diff = await fetchMrDiff(glRef, glToken);
|
|
129
|
-
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
130
|
-
} else if (options.gee) {
|
|
131
|
-
const { resolvePrRef: resolveGiteeRef, fetchPrDiff: fetchGiteeDiff } = require('./gitee');
|
|
132
|
-
const geeRef = resolveGiteeRef(options.gee, options.repo);
|
|
133
|
-
const geeToken = options.geeToken || process.env.GITEE_TOKEN;
|
|
134
|
-
console.error(chalk.blue(`↻ Fetching Gitee PR ${geeRef.owner}/${geeRef.repo}!${geeRef.pr}...`));
|
|
135
|
-
diff = await fetchGiteeDiff(geeRef, geeToken);
|
|
136
|
-
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
137
|
-
} else if (options.gc) {
|
|
138
|
-
const { resolveMrRef: resolveGcRef, fetchMrDiff: fetchGcDiff } = require('./gitcode');
|
|
139
|
-
const gcRef = resolveGcRef(options.gc, options.repo);
|
|
140
|
-
const gcToken = options.gcToken || process.env.GITCODE_TOKEN;
|
|
141
|
-
console.error(chalk.blue(`↻ Fetching GitCode MR ${gcRef.owner}/${gcRef.repo}!${gcRef.mr}...`));
|
|
142
|
-
diff = await fetchGcDiff(gcRef, gcToken);
|
|
143
|
-
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
144
|
-
} else if (options.bb) {
|
|
145
|
-
const { resolvePrRef: resolveBbRef, fetchPrDiff: fetchBbDiff } = require('./bitbucket');
|
|
146
|
-
const bbRef = resolveBbRef(options.bb, options.repo);
|
|
147
|
-
if (options.bbToken) process.env.BITBUCKET_USERNAME = options.bbToken.split(':')[0] || '';
|
|
148
|
-
const bbToken = options.bbToken || process.env.BITBUCKET_APP_PASSWORD;
|
|
149
|
-
console.error(chalk.blue(`↻ Fetching Bitbucket PR ${bbRef.owner}/${bbRef.repo}#${bbRef.pr}...`));
|
|
150
|
-
diff = await fetchBbDiff(bbRef, bbToken);
|
|
151
|
-
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
152
|
-
} else if (options.pr) {
|
|
153
|
-
prRef = resolvePrRef(options.pr, options.repo);
|
|
154
|
-
const token = resolveToken(options.githubToken, config);
|
|
155
|
-
console.error(chalk.blue(`↻ Fetching PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
156
|
-
diff = await fetchPrDiff(prRef, token);
|
|
157
|
-
} else if (options.file) {
|
|
158
|
-
const fs = require('fs');
|
|
159
|
-
diff = fs.readFileSync(options.file, 'utf-8');
|
|
160
|
-
} else if (options.repo) {
|
|
161
|
-
diff = await getGitDiff(options.repo, options.base, options.head);
|
|
162
|
-
} else {
|
|
163
|
-
// Read from stdin
|
|
164
|
-
const fs = require('fs');
|
|
165
|
-
const stdinBuffer = fs.readFileSync(0, 'utf-8');
|
|
166
|
-
if (stdinBuffer.trim()) {
|
|
167
|
-
diff = stdinBuffer;
|
|
168
|
-
} else {
|
|
169
|
-
console.error(chalk.red('✖ No diff input provided. Pipe a diff, use --file, use --repo, or use --pr.'));
|
|
170
|
-
process.exit(1);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const result = await reviewDiff(diff, config, {
|
|
175
|
-
noCache: options.noCache === false,
|
|
176
|
-
ignorePattern,
|
|
177
|
-
audit: options.audit || undefined,
|
|
178
|
-
single: options.single || undefined,
|
|
179
|
-
minConfidence: parseInt(options.minConfidence) || undefined,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
let output;
|
|
183
|
-
if (options.output === 'json') {
|
|
184
|
-
output = JSON.stringify(result, null, 2);
|
|
185
|
-
} else if (options.output === 'markdown') {
|
|
186
|
-
output = formatMarkdown(result);
|
|
187
|
-
} else {
|
|
188
|
-
output = formatTerminal(result);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (options.post && prRef) {
|
|
192
|
-
const token = resolveToken(options.githubToken, config);
|
|
193
|
-
if (!token) {
|
|
194
|
-
console.error(chalk.red('✖ --post requires --github-token or GITHUB_TOKEN env var'));
|
|
195
|
-
process.exit(1);
|
|
196
|
-
}
|
|
197
|
-
const mdReport = formatMarkdown(result);
|
|
198
|
-
console.error(chalk.blue(`↻ Posting review to PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
199
|
-
await postPrComment(prRef, mdReport, token);
|
|
200
|
-
console.error(chalk.green('✔ Review posted as PR comment!'));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (options.inline && prRef) {
|
|
204
|
-
const token = resolveToken(options.githubToken, config);
|
|
205
|
-
if (!token) {
|
|
206
|
-
console.error(chalk.red('✖ --inline requires --github-token or GITHUB_TOKEN env var'));
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
209
|
-
console.error(chalk.blue(`↻ Posting inline review to PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
210
|
-
|
|
211
|
-
// Get PR files for commit SHA and file mapping
|
|
212
|
-
const prFiles = await fetchPrFiles(prRef, token);
|
|
213
|
-
// Get PR info for head SHA
|
|
214
|
-
const https = require('https');
|
|
215
|
-
const prInfo = await new Promise((resolve, reject) => {
|
|
216
|
-
https.get('https://api.github.com/repos/' + prRef.owner + '/' + prRef.repo + '/pulls/' + prRef.pr, {
|
|
217
|
-
headers: { 'User-Agent': 'coderev', 'Accept': 'application/vnd.github.v3+json', 'Authorization': 'token ' + token },
|
|
218
|
-
}, (r) => { let b=''; r.on('data',c=>b+=c); r.on('end',()=>{ try{resolve(JSON.parse(b))}catch{reject()}}); }).on('error', reject);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Map issues to inline comments by file name
|
|
222
|
-
const inlineComments = [];
|
|
223
|
-
const fileMap = {};
|
|
224
|
-
for (const f of prFiles) {
|
|
225
|
-
fileMap[f.filename] = f;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
for (const issue of result.issues || []) {
|
|
229
|
-
if (!issue.file) continue;
|
|
230
|
-
const fileInfo = fileMap[issue.file];
|
|
231
|
-
if (!fileInfo) continue;
|
|
232
|
-
// GitHub API wants line number in the NEW file (RIGHT side) or OLD file (LEFT side)
|
|
233
|
-
inlineComments.push({
|
|
234
|
-
path: issue.file,
|
|
235
|
-
line: issue.line || 1,
|
|
236
|
-
side: 'RIGHT',
|
|
237
|
-
type: issue.type || 'info',
|
|
238
|
-
severity: issue.severity || 'low',
|
|
239
|
-
message: issue.message,
|
|
240
|
-
suggestion: issue.suggestion || '',
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (inlineComments.length > 0) {
|
|
245
|
-
// Use PR head SHA
|
|
246
|
-
const headSha = prInfo?.head?.sha;
|
|
247
|
-
if (headSha) {
|
|
248
|
-
await postInlineComments(prRef, headSha, inlineComments, token);
|
|
249
|
-
console.error(chalk.green(`✔ ${inlineComments.length} inline comments posted!`));
|
|
250
|
-
} else {
|
|
251
|
-
console.error(chalk.red('✖ Could not resolve PR head commit SHA'));
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
console.error(chalk.yellow('⚠ No line-level issues to post inline'));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
console.log(output);
|
|
259
|
-
} catch (err) {
|
|
260
|
-
console.error(chalk.red(`✖ ${err.message}`));
|
|
261
|
-
process.exit(1);
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// ── Cache Management ──────────────────────────────────────────
|
|
266
|
-
program
|
|
267
|
-
.command('cache')
|
|
268
|
-
.description('Manage review cache')
|
|
269
|
-
.argument('[action]', 'Action: clear', 'status')
|
|
270
|
-
.action((action) => {
|
|
271
|
-
const { cleanCache } = require('./cache');
|
|
272
|
-
const fs = require('fs');
|
|
273
|
-
const cacheDir = require('path').join(require('os').homedir(), '.coderev', 'cache');
|
|
274
|
-
|
|
275
|
-
if (action === 'clear') {
|
|
276
|
-
const count = cleanCache();
|
|
277
|
-
console.log(chalk.green(`✔ Cache cleared (${count} entries removed)`));
|
|
278
|
-
} else if (action === 'status') {
|
|
279
|
-
if (!fs.existsSync(cacheDir)) {
|
|
280
|
-
console.log(chalk.blue(' Cache is empty'));
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.json'));
|
|
284
|
-
const totalSize = files.reduce((sum, f) => sum + fs.statSync(path.join(cacheDir, f)).size, 0);
|
|
285
|
-
console.log(chalk.bold(`\n📦 Cache: ${files.length} entries, ${(totalSize / 1024).toFixed(1)} KB`));
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// ── Fix ──────────────────────────────────────────────────────
|
|
290
|
-
program
|
|
291
|
-
.command('fix')
|
|
292
|
-
.description('Generate a fix patch for issues found in a diff')
|
|
293
|
-
.option('-f, --file <path>', 'Path to diff file')
|
|
294
|
-
.option('--pr <ref>', 'GitHub PR to fix')
|
|
295
|
-
.option('--apply', 'Apply the fix patch directly')
|
|
296
|
-
.option('--github-token <token>', 'GitHub personal access token')
|
|
297
|
-
.action(async (options) => {
|
|
298
|
-
try {
|
|
299
|
-
const config = loadConfig(options.config);
|
|
300
|
-
|
|
301
|
-
let diff;
|
|
302
|
-
let prRef = null;
|
|
303
|
-
|
|
304
|
-
if (options.pr) {
|
|
305
|
-
const { resolvePrRef, fetchPrDiff } = require('./github');
|
|
306
|
-
prRef = resolvePrRef(options.pr, options.repo);
|
|
307
|
-
const token = resolveToken(options.githubToken, config);
|
|
308
|
-
console.error(chalk.blue(`↻ Fetching PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
309
|
-
diff = await fetchPrDiff(prRef, token);
|
|
310
|
-
} else if (options.file) {
|
|
311
|
-
const fs = require('fs');
|
|
312
|
-
diff = fs.readFileSync(options.file, 'utf-8');
|
|
313
|
-
} else {
|
|
314
|
-
const fs = require('fs');
|
|
315
|
-
const stdinBuffer = fs.readFileSync(0, 'utf-8');
|
|
316
|
-
if (!stdinBuffer.trim()) {
|
|
317
|
-
console.error(chalk.red('✖ No diff input provided.'));
|
|
318
|
-
process.exit(1);
|
|
319
|
-
}
|
|
320
|
-
diff = stdinBuffer;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
console.error(chalk.blue('↻ Generating fix patch...'));
|
|
324
|
-
const { reviewDiff } = require('./reviewer');
|
|
325
|
-
const result = await reviewDiff(diff, config, { noCache: true, single: true });
|
|
326
|
-
|
|
327
|
-
// Build fix prompt from issues
|
|
328
|
-
const apiKey = getApiKey(config);
|
|
329
|
-
const fixPrompt = [
|
|
330
|
-
{
|
|
331
|
-
role: 'system',
|
|
332
|
-
content: `You are an expert programmer. Given a diff and a list of issues, generate a unified patch that fixes ALL the issues. Return ONLY the patch content wrapped in \`\`\`diff \`\`\` blocks. Do NOT explain the fixes, just output the patch.`,
|
|
333
|
-
},
|
|
334
|
-
{
|
|
335
|
-
role: 'user',
|
|
336
|
-
content: `Diff:\n\`\`\`diff\n${diff}\n\`\`\`\n\nIssues to fix:\n${result.issues.map(i => `- [${i.severity}] ${i.message} in ${i.file}:${i.line || '?'}`).join('\n')}\n\n${result.suggestions.map(s => `- Suggestion: ${s}`).join('\n')}\n\nGenerate the fix patch:`,
|
|
337
|
-
},
|
|
338
|
-
];
|
|
339
|
-
|
|
340
|
-
const aiResponse = await callAI(apiKey, fixPrompt, config);
|
|
341
|
-
|
|
342
|
-
// Extract patch from response
|
|
343
|
-
const patchMatch = aiResponse.match(/```diff\n([\s\S]*?)\n```/);
|
|
344
|
-
const patch = patchMatch ? patchMatch[1] : aiResponse;
|
|
345
|
-
|
|
346
|
-
console.log('\n' + chalk.bold('🩹 Fix Patch / 修复补丁:'));
|
|
347
|
-
console.log('━'.repeat(50));
|
|
348
|
-
console.log(patch);
|
|
349
|
-
|
|
350
|
-
if (options.apply) {
|
|
351
|
-
const fs = require('fs');
|
|
352
|
-
const tmpFile = path.join(require('os').tmpdir(), 'coderev-fix.patch');
|
|
353
|
-
fs.writeFileSync(tmpFile, patch);
|
|
354
|
-
console.error(chalk.blue(`↻ Applying patch from ${tmpFile}...`));
|
|
355
|
-
try {
|
|
356
|
-
const { execSync } = require('child_process');
|
|
357
|
-
const cwd = prRef ? undefined : process.cwd();
|
|
358
|
-
execSync(`git apply "${tmpFile}"`, { cwd, stdio: 'pipe' });
|
|
359
|
-
console.log(chalk.green('✔ Patch applied successfully!'));
|
|
360
|
-
} catch (applyErr) {
|
|
361
|
-
console.error(chalk.red(`✖ Failed to apply patch: ${applyErr.stderr || applyErr.message}`));
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
} catch (err) {
|
|
365
|
-
console.error(chalk.red(`✖ ${err.message}`));
|
|
366
|
-
process.exit(1);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// ── Config ─────────────────────────────────────────────────────
|
|
371
|
-
program
|
|
372
|
-
.command('config')
|
|
373
|
-
.description('Manage configuration')
|
|
374
|
-
.argument('[action]', 'Action: show | validate | path', 'show')
|
|
375
|
-
.action((action) => {
|
|
376
|
-
const { loadConfig } = require('./config');
|
|
377
|
-
|
|
378
|
-
if (action === 'show') {
|
|
379
|
-
const config = loadConfig();
|
|
380
|
-
// Mask sensitive fields
|
|
381
|
-
const masked = JSON.parse(JSON.stringify(config));
|
|
382
|
-
if (masked.ai?.apiKey) masked.ai.apiKey = masked.ai.apiKey.slice(0, 8) + '...' + masked.ai.apiKey.slice(-4);
|
|
383
|
-
if (masked.github?.token) masked.github.token = masked.github.token.slice(0, 8) + '...' + masked.github.token.slice(-4);
|
|
384
|
-
console.log(chalk.bold('\n⚙ Active Configuration / 当前配置:'));
|
|
385
|
-
console.log('━'.repeat(50));
|
|
386
|
-
console.log(JSON.stringify(masked, null, 2));
|
|
387
|
-
} else if (action === 'validate') {
|
|
388
|
-
const fs = require('fs');
|
|
389
|
-
let found = null;
|
|
390
|
-
let current = process.cwd();
|
|
391
|
-
while (true) {
|
|
392
|
-
for (const name of ['.coderevrc.json', '.coderevrc', 'coderev.config.json']) {
|
|
393
|
-
const full = path.join(current, name);
|
|
394
|
-
if (fs.existsSync(full)) { found = full; break; }
|
|
395
|
-
}
|
|
396
|
-
if (found) break;
|
|
397
|
-
const parent = path.dirname(current);
|
|
398
|
-
if (parent === current) break;
|
|
399
|
-
current = parent;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (found) {
|
|
403
|
-
try {
|
|
404
|
-
const parsed = JSON.parse(fs.readFileSync(found, 'utf-8'));
|
|
405
|
-
const errors = [];
|
|
406
|
-
if (!parsed.ai) errors.push('Missing "ai" section');
|
|
407
|
-
if (!parsed.ai?.provider) errors.push('Missing "ai.provider"');
|
|
408
|
-
if (!parsed.ai?.model) errors.push('Missing "ai.model"');
|
|
409
|
-
if (errors.length === 0) {
|
|
410
|
-
console.log(chalk.green(`✔ Config valid / 配置有效: ${found}`));
|
|
411
|
-
} else {
|
|
412
|
-
console.log(chalk.yellow(`⚠ Config found but has issues / 配置存在但有问题:`));
|
|
413
|
-
for (const e of errors) console.log(chalk.yellow(` ${e}`));
|
|
414
|
-
}
|
|
415
|
-
} catch (parseErr) {
|
|
416
|
-
console.error(chalk.red(`✖ Invalid JSON in ${found}: ${parseErr.message}`));
|
|
417
|
-
}
|
|
418
|
-
} else {
|
|
419
|
-
console.log(chalk.blue(' No config file found in current or parent directories.'));
|
|
420
|
-
console.log(chalk.blue(' Run `coderev init` to create one.'));
|
|
421
|
-
}
|
|
422
|
-
} else if (action === 'path') {
|
|
423
|
-
let current = process.cwd();
|
|
424
|
-
while (true) {
|
|
425
|
-
for (const name of ['.coderevrc.json', '.coderevrc', 'coderev.config.json']) {
|
|
426
|
-
const full = path.join(current, name);
|
|
427
|
-
if (require('fs').existsSync(full)) {
|
|
428
|
-
console.log(full);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const parent = path.dirname(current);
|
|
433
|
-
if (parent === current) break;
|
|
434
|
-
current = parent;
|
|
435
|
-
}
|
|
436
|
-
console.log(chalk.blue(' No config file found'));
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
// ── Stats ─────────────────────────────────────────────────────
|
|
441
|
-
program
|
|
442
|
-
.command('stats')
|
|
443
|
-
.description('Review statistics and trends')
|
|
444
|
-
.argument('[period]', 'Period: day | week | month | all', 'all')
|
|
445
|
-
.option('--clear', 'Clear all review history')
|
|
446
|
-
.action((period, options) => {
|
|
447
|
-
const { getStats, clearHistory } = require('./stats');
|
|
448
|
-
|
|
449
|
-
if (options.clear) {
|
|
450
|
-
if (clearHistory()) {
|
|
451
|
-
console.log(chalk.green('✔ Review history cleared'));
|
|
452
|
-
} else {
|
|
453
|
-
console.error(chalk.red('✖ Failed to clear history'));
|
|
454
|
-
}
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const stats = getStats({ period });
|
|
459
|
-
|
|
460
|
-
if (stats.total === 0) {
|
|
461
|
-
console.log(chalk.blue('\n No review data for this period.'));
|
|
462
|
-
console.log(chalk.blue(' Run a review first with `coderev review`.'));
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
console.log(chalk.bold('\n📊 Review Statistics / 审查统计'));
|
|
467
|
-
console.log('━'.repeat(50));
|
|
468
|
-
console.log(` Period / 周期: ${chalk.bold(period)}`);
|
|
469
|
-
console.log(` Total reviews / 总数: ${stats.total}`);
|
|
470
|
-
if (stats.totalAllTime > stats.total) {
|
|
471
|
-
console.log(` All time / 累计: ${stats.totalAllTime}`);
|
|
472
|
-
}
|
|
473
|
-
console.log(` Avg score / 平均分: ${chalk.cyan(stats.averageScore)}`);
|
|
474
|
-
console.log(` Highest / 最高: ${chalk.green(stats.highestScore)}`);
|
|
475
|
-
console.log(` Lowest / 最低: ${chalk.red(stats.lowestScore)}`);
|
|
476
|
-
console.log(` Total issues / 问题数: ${chalk.yellow(stats.totalIssues)}`);
|
|
477
|
-
|
|
478
|
-
if (Object.keys(stats.issueTypes).length > 0) {
|
|
479
|
-
console.log(chalk.bold('\n Issue Types / 问题类型:'));
|
|
480
|
-
for (const [type, count] of Object.entries(stats.issueTypes)) {
|
|
481
|
-
const icon = type === 'error' ? chalk.red('✖') : type === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
|
|
482
|
-
console.log(` ${icon} ${type}: ${count}`);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (Object.keys(stats.severityBreakdown).length > 0) {
|
|
487
|
-
console.log(chalk.bold('\n Severity / 严重程度:'));
|
|
488
|
-
for (const [sev, count] of Object.entries(stats.severityBreakdown)) {
|
|
489
|
-
const color = sev === 'high' ? chalk.red : sev === 'medium' ? chalk.yellow : chalk.blue;
|
|
490
|
-
const sevLabel = sev === 'high' ? '严重' : sev === 'medium' ? '中等' : sev === 'low' ? '轻微' : sev;
|
|
491
|
-
console.log(` ${color('●')} ${sevLabel}: ${count}`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (stats.trend.length > 0) {
|
|
496
|
-
console.log(chalk.bold('\n Trend (last ' + stats.trend.length + ' reviews):'));
|
|
497
|
-
for (const t of stats.trend) {
|
|
498
|
-
const bar = '█'.repeat(Math.max(1, Math.round(t.score / 10)));
|
|
499
|
-
const color = t.score >= 80 ? chalk.green : t.score >= 50 ? chalk.yellow : chalk.red;
|
|
500
|
-
console.log(` ${t.date} ${color(bar)} ${t.score} (${t.issues} issues)`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
console.log('');
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
// ── Hook ──────────────────────────────────────────────────────
|
|
507
|
-
program
|
|
508
|
-
.command('hook')
|
|
509
|
-
.description('Install or remove a git hook (pre-commit / pre-push)')
|
|
510
|
-
.argument('<action>', 'Action: install | remove')
|
|
511
|
-
.argument('[hook-type]', 'Hook type: pre-commit | pre-push', 'pre-commit')
|
|
512
|
-
.option('--min-score <number>', 'Minimum score to allow commit (default: 50)', '50')
|
|
513
|
-
.action((action, hookType, options) => {
|
|
514
|
-
const fs = require('fs');
|
|
515
|
-
const gitDir = path.join(process.cwd(), '.git', 'hooks');
|
|
516
|
-
const hookPath = path.join(gitDir, hookType);
|
|
517
|
-
const minScore = options.minScore || '50';
|
|
518
|
-
|
|
519
|
-
if (action === 'install') {
|
|
520
|
-
if (!fs.existsSync(gitDir)) {
|
|
521
|
-
console.error(chalk.red('✖ Not a git repository: ' + process.cwd()));
|
|
522
|
-
process.exit(1);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const hookScript = `#!/bin/sh
|
|
526
|
-
# coderev ${hookType} hook
|
|
527
|
-
export PATH="$PATH:$(npm root -g)/../.bin"
|
|
528
|
-
echo "↻ Running coderev ${hookType} hook..."
|
|
529
|
-
coderev review --repo . --output markdown > /tmp/coderev-hook-report.md 2>/dev/null
|
|
530
|
-
SCORE=$(grep -oP 'Score: \\K\\d+' /tmp/coderev-hook-report.md || echo 0)
|
|
531
|
-
echo "Score: $SCORE/100"
|
|
532
|
-
MIN_SCORE=${minScore}
|
|
533
|
-
if [ "$SCORE" -lt "$MIN_SCORE" ]; then
|
|
534
|
-
echo "✖ Score below threshold ($MIN_SCORE). Aborting ${hookType}."
|
|
535
|
-
cat /tmp/coderev-hook-report.md
|
|
536
|
-
exit 1
|
|
537
|
-
fi
|
|
538
|
-
`;
|
|
539
|
-
|
|
540
|
-
fs.writeFileSync(hookPath, hookScript);
|
|
541
|
-
try {
|
|
542
|
-
fs.chmodSync(hookPath, '755');
|
|
543
|
-
} catch {}
|
|
544
|
-
console.log(chalk.green(`✔ ${hookType} hook installed at ${hookPath}`));
|
|
545
|
-
} else if (action === 'remove') {
|
|
546
|
-
if (fs.existsSync(hookPath)) {
|
|
547
|
-
fs.unlinkSync(hookPath);
|
|
548
|
-
console.log(chalk.green(`✔ ${hookType} hook removed`));
|
|
549
|
-
} else {
|
|
550
|
-
console.log(chalk.blue(' No hook to remove'));
|
|
551
|
-
}
|
|
552
|
-
} else {
|
|
553
|
-
console.error(chalk.red('✖ Unknown action. Use "install" or "remove".'));
|
|
554
|
-
process.exit(1);
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
// ── Init / Setup ──────────────────────────────────────────────
|
|
559
|
-
program
|
|
560
|
-
.command('init')
|
|
561
|
-
.description('Create a default coderev config file')
|
|
562
|
-
.action(() => {
|
|
563
|
-
const fs = require('fs');
|
|
564
|
-
const path = require('path');
|
|
565
|
-
const defaultConfig = {
|
|
566
|
-
ai: {
|
|
567
|
-
provider: 'deepseek',
|
|
568
|
-
model: 'deepseek-chat',
|
|
569
|
-
temperature: 0.3,
|
|
570
|
-
maxTokens: 4096,
|
|
571
|
-
// 填入你的 API Key 或通过环境变量设置
|
|
572
|
-
// apiKey: "sk-xxx",
|
|
573
|
-
// apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
574
|
-
},
|
|
575
|
-
rules: {
|
|
576
|
-
maxLineLength: 100,
|
|
577
|
-
predefined: ['security', 'performance', 'style'],
|
|
578
|
-
custom: []
|
|
579
|
-
},
|
|
580
|
-
output: {
|
|
581
|
-
format: 'terminal',
|
|
582
|
-
includeScore: true,
|
|
583
|
-
},
|
|
584
|
-
};
|
|
585
|
-
const configPath = path.join(process.cwd(), '.coderevrc.json');
|
|
586
|
-
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
587
|
-
console.log(chalk.green(`✔ Default config created at ${configPath}`));
|
|
588
|
-
|
|
589
|
-
// Also create .coderevignore if it doesn't exist
|
|
590
|
-
const ignorePath = path.join(process.cwd(), '.coderevignore');
|
|
591
|
-
if (!fs.existsSync(ignorePath)) {
|
|
592
|
-
const ignoreContent = `# coderev ignore list
|
|
593
|
-
# Files matching these patterns will be skipped during review.
|
|
594
|
-
|
|
595
|
-
*.min.js
|
|
596
|
-
*.bundle.js
|
|
597
|
-
package-lock.json
|
|
598
|
-
yarn.lock
|
|
599
|
-
vendor/
|
|
600
|
-
dist/
|
|
601
|
-
build/
|
|
602
|
-
`;
|
|
603
|
-
fs.writeFileSync(ignorePath, ignoreContent);
|
|
604
|
-
console.log(chalk.green(`✔ Default .coderevignore created at ${ignorePath}`));
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Also create .coderevhint if it doesn't exist
|
|
608
|
-
const hintPath = path.join(process.cwd(), '.coderevhint');
|
|
609
|
-
if (!fs.existsSync(hintPath)) {
|
|
610
|
-
const hintContent = `# Project context for AI code review
|
|
611
|
-
# Describe your project here to get more relevant reviews.
|
|
612
|
-
|
|
613
|
-
## Project Overview
|
|
614
|
-
- Language:
|
|
615
|
-
- Framework:
|
|
616
|
-
- Build system:
|
|
617
|
-
|
|
618
|
-
## Conventions
|
|
619
|
-
- Prefer:
|
|
620
|
-
- Avoid:
|
|
621
|
-
`;
|
|
622
|
-
fs.writeFileSync(hintPath, hintContent);
|
|
623
|
-
console.log(chalk.green(`✔ Default .coderevhint created at ${hintPath}`));
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const pkg = require('../package.json');
|
|
7
|
+
const { reviewDiff } = require('./reviewer');
|
|
8
|
+
const { loadConfig } = require('./config');
|
|
9
|
+
const { resolvePrRef, fetchPrDiff, postPrComment, resolveToken, fetchPrFiles, postInlineComments } = require('./github');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('coderev')
|
|
13
|
+
.description('AI-powered code review agent')
|
|
14
|
+
.version(pkg.version);
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('review')
|
|
18
|
+
.description('Review a diff or pull request')
|
|
19
|
+
.option('-f, --file <path>', 'Path to diff file (reads stdin if omitted)')
|
|
20
|
+
.option('-r, --repo <path>', 'Path to git repository')
|
|
21
|
+
.option('--base <branch>', 'Base branch for diff (requires --repo)')
|
|
22
|
+
.option('--head <branch>', 'Head branch for diff (requires --repo)')
|
|
23
|
+
.option('-c, --config <path>', 'Path to config file')
|
|
24
|
+
.option('-o, --output <format>', 'Output format (markdown|json|terminal)', 'terminal')
|
|
25
|
+
.option('--pr <ref>', 'GitHub PR to review, e.g. owner/repo#42 or full URL')
|
|
26
|
+
.option('--gl <ref>', 'GitLab MR to review, e.g. owner/repo!42 or full URL')
|
|
27
|
+
.option('--gee <ref>', 'Gitee PR to review, e.g. owner/repo!42 or full URL')
|
|
28
|
+
.option('--gc <ref>', 'GitCode MR to review, e.g. owner/repo!42 or full URL')
|
|
29
|
+
.option('--bb <ref>', 'Bitbucket PR to review, e.g. owner/repo#42 or full URL')
|
|
30
|
+
.option('--all', 'Review all open PRs for the repo (use with --pr owner/repo or --repo)')
|
|
31
|
+
.option('--github-token <token>', 'GitHub personal access token')
|
|
32
|
+
.option('--gitlab-token <token>', 'GitLab personal access token')
|
|
33
|
+
.option('--gee-token <token>', 'Gitee personal access token')
|
|
34
|
+
.option('--gc-token <token>', 'GitCode personal access token')
|
|
35
|
+
.option('--bb-token <token>', 'Bitbucket app password')
|
|
36
|
+
.option('--post', 'Post review result as PR/MR comment')
|
|
37
|
+
.option('--no-cache', 'Skip cache and force fresh review')
|
|
38
|
+
.option('--audit', 'Security audit mode (OWASP-focused review)')
|
|
39
|
+
.option('--single', 'Use single-agent mode (legacy, no parallel review)')
|
|
40
|
+
.option('--min-confidence <number>', 'Minimum confidence threshold 0-100 (default: 60)', '60')
|
|
41
|
+
.option('--agents <list>', 'Comma-separated agent list: security,bugs,quality')
|
|
42
|
+
.action(async (options) => {
|
|
43
|
+
try {
|
|
44
|
+
const config = loadConfig(options.config);
|
|
45
|
+
|
|
46
|
+
let diff;
|
|
47
|
+
let prRef = null;
|
|
48
|
+
|
|
49
|
+
// Load .coderevignore if it exists
|
|
50
|
+
let ignorePattern = '';
|
|
51
|
+
try {
|
|
52
|
+
const fs = require('fs');
|
|
53
|
+
if (fs.existsSync('.coderevignore')) {
|
|
54
|
+
ignorePattern = fs.readFileSync('.coderevignore', 'utf-8')
|
|
55
|
+
.split('\n')
|
|
56
|
+
.filter(l => l.trim() && !l.startsWith('#'))
|
|
57
|
+
.map(l => l.trim())
|
|
58
|
+
.join(',');
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
if (options.all && prRef) {
|
|
63
|
+
// Batch mode: review all open PRs
|
|
64
|
+
const { listPullRequests } = require('./github');
|
|
65
|
+
const token = resolveToken(options.githubToken, config);
|
|
66
|
+
const repoRef = { owner: prRef.owner, repo: prRef.repo };
|
|
67
|
+
const prList = await listPullRequests(repoRef, token, { state: 'open', limit: 20 });
|
|
68
|
+
|
|
69
|
+
if (prList.length === 0) {
|
|
70
|
+
console.log(chalk.blue(` No open PRs found for ${prRef.owner}/${prRef.repo}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.error(chalk.bold(`\n📋 Found ${prList.length} open PRs in ${prRef.owner}/${prRef.repo}:`));
|
|
75
|
+
for (const pr of prList) {
|
|
76
|
+
console.error(` #${pr.number} ${pr.title} (${pr.draft ? 'draft' : 'open'})`);
|
|
77
|
+
}
|
|
78
|
+
console.error('');
|
|
79
|
+
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const pr of prList) {
|
|
82
|
+
console.error(chalk.blue(`↻ Reviewing PR #${pr.number}...`));
|
|
83
|
+
const fullRef = { owner: prRef.owner, repo: prRef.repo, pr: pr.number };
|
|
84
|
+
try {
|
|
85
|
+
const prDiff = await fetchPrDiff(fullRef, token);
|
|
86
|
+
const result = await reviewDiff(prDiff, config, { noCache: true, ignorePattern });
|
|
87
|
+
results.push({ number: pr.number, title: pr.title, result });
|
|
88
|
+
|
|
89
|
+
if (options.post) {
|
|
90
|
+
const md = formatMarkdown(result);
|
|
91
|
+
await postPrComment(fullRef, md, token);
|
|
92
|
+
console.error(chalk.green(` ✔ #${pr.number} reviewed & posted`));
|
|
93
|
+
} else {
|
|
94
|
+
const scoreColor = result.score >= 80 ? chalk.green : result.score >= 50 ? chalk.yellow : chalk.red;
|
|
95
|
+
const scoreStr = scoreColor(`${result.score}/100`);
|
|
96
|
+
const issueCount = (result.issues || []).length;
|
|
97
|
+
console.error(` ${scoreStr} (${issueCount} issues) - ${result.summary || ''}`);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error(chalk.red(` ✖ #${pr.number}: ${err.message}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Summary
|
|
105
|
+
const scores = results.filter(r => r.result).map(r => r.result.score);
|
|
106
|
+
if (scores.length > 0) {
|
|
107
|
+
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1);
|
|
108
|
+
console.error(chalk.bold(`\n📊 Batch Summary: ${results.length}/${prList.length} reviewed, avg score: ${avg}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.output === 'json') {
|
|
112
|
+
console.log(JSON.stringify(results, null, 2));
|
|
113
|
+
} else if (options.output === 'markdown') {
|
|
114
|
+
for (const r of results) {
|
|
115
|
+
console.log(`## PR #${r.number}: ${r.title}\n`);
|
|
116
|
+
console.log(formatMarkdown(r.result));
|
|
117
|
+
console.log('---\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (options.gl) {
|
|
124
|
+
const { resolveMrRef, fetchMrDiff } = require('./gitlab');
|
|
125
|
+
const glRef = resolveMrRef(options.gl, options.repo);
|
|
126
|
+
const glToken = options.gitlabToken || process.env.GITLAB_TOKEN;
|
|
127
|
+
console.error(chalk.blue(`↻ Fetching GitLab MR ${glRef.owner}/${glRef.repo}!${glRef.mr}...`));
|
|
128
|
+
diff = await fetchMrDiff(glRef, glToken);
|
|
129
|
+
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
130
|
+
} else if (options.gee) {
|
|
131
|
+
const { resolvePrRef: resolveGiteeRef, fetchPrDiff: fetchGiteeDiff } = require('./gitee');
|
|
132
|
+
const geeRef = resolveGiteeRef(options.gee, options.repo);
|
|
133
|
+
const geeToken = options.geeToken || process.env.GITEE_TOKEN;
|
|
134
|
+
console.error(chalk.blue(`↻ Fetching Gitee PR ${geeRef.owner}/${geeRef.repo}!${geeRef.pr}...`));
|
|
135
|
+
diff = await fetchGiteeDiff(geeRef, geeToken);
|
|
136
|
+
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
137
|
+
} else if (options.gc) {
|
|
138
|
+
const { resolveMrRef: resolveGcRef, fetchMrDiff: fetchGcDiff } = require('./gitcode');
|
|
139
|
+
const gcRef = resolveGcRef(options.gc, options.repo);
|
|
140
|
+
const gcToken = options.gcToken || process.env.GITCODE_TOKEN;
|
|
141
|
+
console.error(chalk.blue(`↻ Fetching GitCode MR ${gcRef.owner}/${gcRef.repo}!${gcRef.mr}...`));
|
|
142
|
+
diff = await fetchGcDiff(gcRef, gcToken);
|
|
143
|
+
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
144
|
+
} else if (options.bb) {
|
|
145
|
+
const { resolvePrRef: resolveBbRef, fetchPrDiff: fetchBbDiff } = require('./bitbucket');
|
|
146
|
+
const bbRef = resolveBbRef(options.bb, options.repo);
|
|
147
|
+
if (options.bbToken) process.env.BITBUCKET_USERNAME = options.bbToken.split(':')[0] || '';
|
|
148
|
+
const bbToken = options.bbToken || process.env.BITBUCKET_APP_PASSWORD;
|
|
149
|
+
console.error(chalk.blue(`↻ Fetching Bitbucket PR ${bbRef.owner}/${bbRef.repo}#${bbRef.pr}...`));
|
|
150
|
+
diff = await fetchBbDiff(bbRef, bbToken);
|
|
151
|
+
console.error(chalk.green(`✔ Diff fetched (${diff.length} chars)`));
|
|
152
|
+
} else if (options.pr) {
|
|
153
|
+
prRef = resolvePrRef(options.pr, options.repo);
|
|
154
|
+
const token = resolveToken(options.githubToken, config);
|
|
155
|
+
console.error(chalk.blue(`↻ Fetching PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
156
|
+
diff = await fetchPrDiff(prRef, token);
|
|
157
|
+
} else if (options.file) {
|
|
158
|
+
const fs = require('fs');
|
|
159
|
+
diff = fs.readFileSync(options.file, 'utf-8');
|
|
160
|
+
} else if (options.repo) {
|
|
161
|
+
diff = await getGitDiff(options.repo, options.base, options.head);
|
|
162
|
+
} else {
|
|
163
|
+
// Read from stdin
|
|
164
|
+
const fs = require('fs');
|
|
165
|
+
const stdinBuffer = fs.readFileSync(0, 'utf-8');
|
|
166
|
+
if (stdinBuffer.trim()) {
|
|
167
|
+
diff = stdinBuffer;
|
|
168
|
+
} else {
|
|
169
|
+
console.error(chalk.red('✖ No diff input provided. Pipe a diff, use --file, use --repo, or use --pr.'));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = await reviewDiff(diff, config, {
|
|
175
|
+
noCache: options.noCache === false,
|
|
176
|
+
ignorePattern,
|
|
177
|
+
audit: options.audit || undefined,
|
|
178
|
+
single: options.single || undefined,
|
|
179
|
+
minConfidence: parseInt(options.minConfidence) || undefined,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
let output;
|
|
183
|
+
if (options.output === 'json') {
|
|
184
|
+
output = JSON.stringify(result, null, 2);
|
|
185
|
+
} else if (options.output === 'markdown') {
|
|
186
|
+
output = formatMarkdown(result);
|
|
187
|
+
} else {
|
|
188
|
+
output = formatTerminal(result);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (options.post && prRef) {
|
|
192
|
+
const token = resolveToken(options.githubToken, config);
|
|
193
|
+
if (!token) {
|
|
194
|
+
console.error(chalk.red('✖ --post requires --github-token or GITHUB_TOKEN env var'));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
const mdReport = formatMarkdown(result);
|
|
198
|
+
console.error(chalk.blue(`↻ Posting review to PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
199
|
+
await postPrComment(prRef, mdReport, token);
|
|
200
|
+
console.error(chalk.green('✔ Review posted as PR comment!'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (options.inline && prRef) {
|
|
204
|
+
const token = resolveToken(options.githubToken, config);
|
|
205
|
+
if (!token) {
|
|
206
|
+
console.error(chalk.red('✖ --inline requires --github-token or GITHUB_TOKEN env var'));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
console.error(chalk.blue(`↻ Posting inline review to PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
210
|
+
|
|
211
|
+
// Get PR files for commit SHA and file mapping
|
|
212
|
+
const prFiles = await fetchPrFiles(prRef, token);
|
|
213
|
+
// Get PR info for head SHA
|
|
214
|
+
const https = require('https');
|
|
215
|
+
const prInfo = await new Promise((resolve, reject) => {
|
|
216
|
+
https.get('https://api.github.com/repos/' + prRef.owner + '/' + prRef.repo + '/pulls/' + prRef.pr, {
|
|
217
|
+
headers: { 'User-Agent': 'coderev', 'Accept': 'application/vnd.github.v3+json', 'Authorization': 'token ' + token },
|
|
218
|
+
}, (r) => { let b=''; r.on('data',c=>b+=c); r.on('end',()=>{ try{resolve(JSON.parse(b))}catch{reject()}}); }).on('error', reject);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Map issues to inline comments by file name
|
|
222
|
+
const inlineComments = [];
|
|
223
|
+
const fileMap = {};
|
|
224
|
+
for (const f of prFiles) {
|
|
225
|
+
fileMap[f.filename] = f;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const issue of result.issues || []) {
|
|
229
|
+
if (!issue.file) continue;
|
|
230
|
+
const fileInfo = fileMap[issue.file];
|
|
231
|
+
if (!fileInfo) continue;
|
|
232
|
+
// GitHub API wants line number in the NEW file (RIGHT side) or OLD file (LEFT side)
|
|
233
|
+
inlineComments.push({
|
|
234
|
+
path: issue.file,
|
|
235
|
+
line: issue.line || 1,
|
|
236
|
+
side: 'RIGHT',
|
|
237
|
+
type: issue.type || 'info',
|
|
238
|
+
severity: issue.severity || 'low',
|
|
239
|
+
message: issue.message,
|
|
240
|
+
suggestion: issue.suggestion || '',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (inlineComments.length > 0) {
|
|
245
|
+
// Use PR head SHA
|
|
246
|
+
const headSha = prInfo?.head?.sha;
|
|
247
|
+
if (headSha) {
|
|
248
|
+
await postInlineComments(prRef, headSha, inlineComments, token);
|
|
249
|
+
console.error(chalk.green(`✔ ${inlineComments.length} inline comments posted!`));
|
|
250
|
+
} else {
|
|
251
|
+
console.error(chalk.red('✖ Could not resolve PR head commit SHA'));
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
console.error(chalk.yellow('⚠ No line-level issues to post inline'));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(output);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(chalk.red(`✖ ${err.message}`));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ── Cache Management ──────────────────────────────────────────
|
|
266
|
+
program
|
|
267
|
+
.command('cache')
|
|
268
|
+
.description('Manage review cache')
|
|
269
|
+
.argument('[action]', 'Action: clear', 'status')
|
|
270
|
+
.action((action) => {
|
|
271
|
+
const { cleanCache } = require('./cache');
|
|
272
|
+
const fs = require('fs');
|
|
273
|
+
const cacheDir = require('path').join(require('os').homedir(), '.coderev', 'cache');
|
|
274
|
+
|
|
275
|
+
if (action === 'clear') {
|
|
276
|
+
const count = cleanCache();
|
|
277
|
+
console.log(chalk.green(`✔ Cache cleared (${count} entries removed)`));
|
|
278
|
+
} else if (action === 'status') {
|
|
279
|
+
if (!fs.existsSync(cacheDir)) {
|
|
280
|
+
console.log(chalk.blue(' Cache is empty'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.json'));
|
|
284
|
+
const totalSize = files.reduce((sum, f) => sum + fs.statSync(path.join(cacheDir, f)).size, 0);
|
|
285
|
+
console.log(chalk.bold(`\n📦 Cache: ${files.length} entries, ${(totalSize / 1024).toFixed(1)} KB`));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Fix ──────────────────────────────────────────────────────
|
|
290
|
+
program
|
|
291
|
+
.command('fix')
|
|
292
|
+
.description('Generate a fix patch for issues found in a diff')
|
|
293
|
+
.option('-f, --file <path>', 'Path to diff file')
|
|
294
|
+
.option('--pr <ref>', 'GitHub PR to fix')
|
|
295
|
+
.option('--apply', 'Apply the fix patch directly')
|
|
296
|
+
.option('--github-token <token>', 'GitHub personal access token')
|
|
297
|
+
.action(async (options) => {
|
|
298
|
+
try {
|
|
299
|
+
const config = loadConfig(options.config);
|
|
300
|
+
|
|
301
|
+
let diff;
|
|
302
|
+
let prRef = null;
|
|
303
|
+
|
|
304
|
+
if (options.pr) {
|
|
305
|
+
const { resolvePrRef, fetchPrDiff } = require('./github');
|
|
306
|
+
prRef = resolvePrRef(options.pr, options.repo);
|
|
307
|
+
const token = resolveToken(options.githubToken, config);
|
|
308
|
+
console.error(chalk.blue(`↻ Fetching PR ${prRef.owner}/${prRef.repo}#${prRef.pr}...`));
|
|
309
|
+
diff = await fetchPrDiff(prRef, token);
|
|
310
|
+
} else if (options.file) {
|
|
311
|
+
const fs = require('fs');
|
|
312
|
+
diff = fs.readFileSync(options.file, 'utf-8');
|
|
313
|
+
} else {
|
|
314
|
+
const fs = require('fs');
|
|
315
|
+
const stdinBuffer = fs.readFileSync(0, 'utf-8');
|
|
316
|
+
if (!stdinBuffer.trim()) {
|
|
317
|
+
console.error(chalk.red('✖ No diff input provided.'));
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
diff = stdinBuffer;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.error(chalk.blue('↻ Generating fix patch...'));
|
|
324
|
+
const { reviewDiff } = require('./reviewer');
|
|
325
|
+
const result = await reviewDiff(diff, config, { noCache: true, single: true });
|
|
326
|
+
|
|
327
|
+
// Build fix prompt from issues
|
|
328
|
+
const apiKey = getApiKey(config);
|
|
329
|
+
const fixPrompt = [
|
|
330
|
+
{
|
|
331
|
+
role: 'system',
|
|
332
|
+
content: `You are an expert programmer. Given a diff and a list of issues, generate a unified patch that fixes ALL the issues. Return ONLY the patch content wrapped in \`\`\`diff \`\`\` blocks. Do NOT explain the fixes, just output the patch.`,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
role: 'user',
|
|
336
|
+
content: `Diff:\n\`\`\`diff\n${diff}\n\`\`\`\n\nIssues to fix:\n${result.issues.map(i => `- [${i.severity}] ${i.message} in ${i.file}:${i.line || '?'}`).join('\n')}\n\n${result.suggestions.map(s => `- Suggestion: ${s}`).join('\n')}\n\nGenerate the fix patch:`,
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
const aiResponse = await callAI(apiKey, fixPrompt, config);
|
|
341
|
+
|
|
342
|
+
// Extract patch from response
|
|
343
|
+
const patchMatch = aiResponse.match(/```diff\n([\s\S]*?)\n```/);
|
|
344
|
+
const patch = patchMatch ? patchMatch[1] : aiResponse;
|
|
345
|
+
|
|
346
|
+
console.log('\n' + chalk.bold('🩹 Fix Patch / 修复补丁:'));
|
|
347
|
+
console.log('━'.repeat(50));
|
|
348
|
+
console.log(patch);
|
|
349
|
+
|
|
350
|
+
if (options.apply) {
|
|
351
|
+
const fs = require('fs');
|
|
352
|
+
const tmpFile = path.join(require('os').tmpdir(), 'coderev-fix.patch');
|
|
353
|
+
fs.writeFileSync(tmpFile, patch);
|
|
354
|
+
console.error(chalk.blue(`↻ Applying patch from ${tmpFile}...`));
|
|
355
|
+
try {
|
|
356
|
+
const { execSync } = require('child_process');
|
|
357
|
+
const cwd = prRef ? undefined : process.cwd();
|
|
358
|
+
execSync(`git apply "${tmpFile}"`, { cwd, stdio: 'pipe' });
|
|
359
|
+
console.log(chalk.green('✔ Patch applied successfully!'));
|
|
360
|
+
} catch (applyErr) {
|
|
361
|
+
console.error(chalk.red(`✖ Failed to apply patch: ${applyErr.stderr || applyErr.message}`));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error(chalk.red(`✖ ${err.message}`));
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ── Config ─────────────────────────────────────────────────────
|
|
371
|
+
program
|
|
372
|
+
.command('config')
|
|
373
|
+
.description('Manage configuration')
|
|
374
|
+
.argument('[action]', 'Action: show | validate | path', 'show')
|
|
375
|
+
.action((action) => {
|
|
376
|
+
const { loadConfig } = require('./config');
|
|
377
|
+
|
|
378
|
+
if (action === 'show') {
|
|
379
|
+
const config = loadConfig();
|
|
380
|
+
// Mask sensitive fields
|
|
381
|
+
const masked = JSON.parse(JSON.stringify(config));
|
|
382
|
+
if (masked.ai?.apiKey) masked.ai.apiKey = masked.ai.apiKey.slice(0, 8) + '...' + masked.ai.apiKey.slice(-4);
|
|
383
|
+
if (masked.github?.token) masked.github.token = masked.github.token.slice(0, 8) + '...' + masked.github.token.slice(-4);
|
|
384
|
+
console.log(chalk.bold('\n⚙ Active Configuration / 当前配置:'));
|
|
385
|
+
console.log('━'.repeat(50));
|
|
386
|
+
console.log(JSON.stringify(masked, null, 2));
|
|
387
|
+
} else if (action === 'validate') {
|
|
388
|
+
const fs = require('fs');
|
|
389
|
+
let found = null;
|
|
390
|
+
let current = process.cwd();
|
|
391
|
+
while (true) {
|
|
392
|
+
for (const name of ['.coderevrc.json', '.coderevrc', 'coderev.config.json']) {
|
|
393
|
+
const full = path.join(current, name);
|
|
394
|
+
if (fs.existsSync(full)) { found = full; break; }
|
|
395
|
+
}
|
|
396
|
+
if (found) break;
|
|
397
|
+
const parent = path.dirname(current);
|
|
398
|
+
if (parent === current) break;
|
|
399
|
+
current = parent;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (found) {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(fs.readFileSync(found, 'utf-8'));
|
|
405
|
+
const errors = [];
|
|
406
|
+
if (!parsed.ai) errors.push('Missing "ai" section');
|
|
407
|
+
if (!parsed.ai?.provider) errors.push('Missing "ai.provider"');
|
|
408
|
+
if (!parsed.ai?.model) errors.push('Missing "ai.model"');
|
|
409
|
+
if (errors.length === 0) {
|
|
410
|
+
console.log(chalk.green(`✔ Config valid / 配置有效: ${found}`));
|
|
411
|
+
} else {
|
|
412
|
+
console.log(chalk.yellow(`⚠ Config found but has issues / 配置存在但有问题:`));
|
|
413
|
+
for (const e of errors) console.log(chalk.yellow(` ${e}`));
|
|
414
|
+
}
|
|
415
|
+
} catch (parseErr) {
|
|
416
|
+
console.error(chalk.red(`✖ Invalid JSON in ${found}: ${parseErr.message}`));
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
console.log(chalk.blue(' No config file found in current or parent directories.'));
|
|
420
|
+
console.log(chalk.blue(' Run `coderev init` to create one.'));
|
|
421
|
+
}
|
|
422
|
+
} else if (action === 'path') {
|
|
423
|
+
let current = process.cwd();
|
|
424
|
+
while (true) {
|
|
425
|
+
for (const name of ['.coderevrc.json', '.coderevrc', 'coderev.config.json']) {
|
|
426
|
+
const full = path.join(current, name);
|
|
427
|
+
if (require('fs').existsSync(full)) {
|
|
428
|
+
console.log(full);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const parent = path.dirname(current);
|
|
433
|
+
if (parent === current) break;
|
|
434
|
+
current = parent;
|
|
435
|
+
}
|
|
436
|
+
console.log(chalk.blue(' No config file found'));
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── Stats ─────────────────────────────────────────────────────
|
|
441
|
+
program
|
|
442
|
+
.command('stats')
|
|
443
|
+
.description('Review statistics and trends')
|
|
444
|
+
.argument('[period]', 'Period: day | week | month | all', 'all')
|
|
445
|
+
.option('--clear', 'Clear all review history')
|
|
446
|
+
.action((period, options) => {
|
|
447
|
+
const { getStats, clearHistory } = require('./stats');
|
|
448
|
+
|
|
449
|
+
if (options.clear) {
|
|
450
|
+
if (clearHistory()) {
|
|
451
|
+
console.log(chalk.green('✔ Review history cleared'));
|
|
452
|
+
} else {
|
|
453
|
+
console.error(chalk.red('✖ Failed to clear history'));
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const stats = getStats({ period });
|
|
459
|
+
|
|
460
|
+
if (stats.total === 0) {
|
|
461
|
+
console.log(chalk.blue('\n No review data for this period.'));
|
|
462
|
+
console.log(chalk.blue(' Run a review first with `coderev review`.'));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
console.log(chalk.bold('\n📊 Review Statistics / 审查统计'));
|
|
467
|
+
console.log('━'.repeat(50));
|
|
468
|
+
console.log(` Period / 周期: ${chalk.bold(period)}`);
|
|
469
|
+
console.log(` Total reviews / 总数: ${stats.total}`);
|
|
470
|
+
if (stats.totalAllTime > stats.total) {
|
|
471
|
+
console.log(` All time / 累计: ${stats.totalAllTime}`);
|
|
472
|
+
}
|
|
473
|
+
console.log(` Avg score / 平均分: ${chalk.cyan(stats.averageScore)}`);
|
|
474
|
+
console.log(` Highest / 最高: ${chalk.green(stats.highestScore)}`);
|
|
475
|
+
console.log(` Lowest / 最低: ${chalk.red(stats.lowestScore)}`);
|
|
476
|
+
console.log(` Total issues / 问题数: ${chalk.yellow(stats.totalIssues)}`);
|
|
477
|
+
|
|
478
|
+
if (Object.keys(stats.issueTypes).length > 0) {
|
|
479
|
+
console.log(chalk.bold('\n Issue Types / 问题类型:'));
|
|
480
|
+
for (const [type, count] of Object.entries(stats.issueTypes)) {
|
|
481
|
+
const icon = type === 'error' ? chalk.red('✖') : type === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
|
|
482
|
+
console.log(` ${icon} ${type}: ${count}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (Object.keys(stats.severityBreakdown).length > 0) {
|
|
487
|
+
console.log(chalk.bold('\n Severity / 严重程度:'));
|
|
488
|
+
for (const [sev, count] of Object.entries(stats.severityBreakdown)) {
|
|
489
|
+
const color = sev === 'high' ? chalk.red : sev === 'medium' ? chalk.yellow : chalk.blue;
|
|
490
|
+
const sevLabel = sev === 'high' ? '严重' : sev === 'medium' ? '中等' : sev === 'low' ? '轻微' : sev;
|
|
491
|
+
console.log(` ${color('●')} ${sevLabel}: ${count}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (stats.trend.length > 0) {
|
|
496
|
+
console.log(chalk.bold('\n Trend (last ' + stats.trend.length + ' reviews):'));
|
|
497
|
+
for (const t of stats.trend) {
|
|
498
|
+
const bar = '█'.repeat(Math.max(1, Math.round(t.score / 10)));
|
|
499
|
+
const color = t.score >= 80 ? chalk.green : t.score >= 50 ? chalk.yellow : chalk.red;
|
|
500
|
+
console.log(` ${t.date} ${color(bar)} ${t.score} (${t.issues} issues)`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
console.log('');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── Hook ──────────────────────────────────────────────────────
|
|
507
|
+
program
|
|
508
|
+
.command('hook')
|
|
509
|
+
.description('Install or remove a git hook (pre-commit / pre-push)')
|
|
510
|
+
.argument('<action>', 'Action: install | remove')
|
|
511
|
+
.argument('[hook-type]', 'Hook type: pre-commit | pre-push', 'pre-commit')
|
|
512
|
+
.option('--min-score <number>', 'Minimum score to allow commit (default: 50)', '50')
|
|
513
|
+
.action((action, hookType, options) => {
|
|
514
|
+
const fs = require('fs');
|
|
515
|
+
const gitDir = path.join(process.cwd(), '.git', 'hooks');
|
|
516
|
+
const hookPath = path.join(gitDir, hookType);
|
|
517
|
+
const minScore = options.minScore || '50';
|
|
518
|
+
|
|
519
|
+
if (action === 'install') {
|
|
520
|
+
if (!fs.existsSync(gitDir)) {
|
|
521
|
+
console.error(chalk.red('✖ Not a git repository: ' + process.cwd()));
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const hookScript = `#!/bin/sh
|
|
526
|
+
# coderev ${hookType} hook
|
|
527
|
+
export PATH="$PATH:$(npm root -g)/../.bin"
|
|
528
|
+
echo "↻ Running coderev ${hookType} hook..."
|
|
529
|
+
coderev review --repo . --output markdown > /tmp/coderev-hook-report.md 2>/dev/null
|
|
530
|
+
SCORE=$(grep -oP 'Score: \\K\\d+' /tmp/coderev-hook-report.md || echo 0)
|
|
531
|
+
echo "Score: $SCORE/100"
|
|
532
|
+
MIN_SCORE=${minScore}
|
|
533
|
+
if [ "$SCORE" -lt "$MIN_SCORE" ]; then
|
|
534
|
+
echo "✖ Score below threshold ($MIN_SCORE). Aborting ${hookType}."
|
|
535
|
+
cat /tmp/coderev-hook-report.md
|
|
536
|
+
exit 1
|
|
537
|
+
fi
|
|
538
|
+
`;
|
|
539
|
+
|
|
540
|
+
fs.writeFileSync(hookPath, hookScript);
|
|
541
|
+
try {
|
|
542
|
+
fs.chmodSync(hookPath, '755');
|
|
543
|
+
} catch {}
|
|
544
|
+
console.log(chalk.green(`✔ ${hookType} hook installed at ${hookPath}`));
|
|
545
|
+
} else if (action === 'remove') {
|
|
546
|
+
if (fs.existsSync(hookPath)) {
|
|
547
|
+
fs.unlinkSync(hookPath);
|
|
548
|
+
console.log(chalk.green(`✔ ${hookType} hook removed`));
|
|
549
|
+
} else {
|
|
550
|
+
console.log(chalk.blue(' No hook to remove'));
|
|
551
|
+
}
|
|
552
|
+
} else {
|
|
553
|
+
console.error(chalk.red('✖ Unknown action. Use "install" or "remove".'));
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ── Init / Setup ──────────────────────────────────────────────
|
|
559
|
+
program
|
|
560
|
+
.command('init')
|
|
561
|
+
.description('Create a default coderev config file')
|
|
562
|
+
.action(() => {
|
|
563
|
+
const fs = require('fs');
|
|
564
|
+
const path = require('path');
|
|
565
|
+
const defaultConfig = {
|
|
566
|
+
ai: {
|
|
567
|
+
provider: 'deepseek',
|
|
568
|
+
model: 'deepseek-chat',
|
|
569
|
+
temperature: 0.3,
|
|
570
|
+
maxTokens: 4096,
|
|
571
|
+
// 填入你的 API Key 或通过环境变量设置
|
|
572
|
+
// apiKey: "sk-xxx",
|
|
573
|
+
// apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
574
|
+
},
|
|
575
|
+
rules: {
|
|
576
|
+
maxLineLength: 100,
|
|
577
|
+
predefined: ['security', 'performance', 'style'],
|
|
578
|
+
custom: []
|
|
579
|
+
},
|
|
580
|
+
output: {
|
|
581
|
+
format: 'terminal',
|
|
582
|
+
includeScore: true,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
const configPath = path.join(process.cwd(), '.coderevrc.json');
|
|
586
|
+
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
587
|
+
console.log(chalk.green(`✔ Default config created at ${configPath}`));
|
|
588
|
+
|
|
589
|
+
// Also create .coderevignore if it doesn't exist
|
|
590
|
+
const ignorePath = path.join(process.cwd(), '.coderevignore');
|
|
591
|
+
if (!fs.existsSync(ignorePath)) {
|
|
592
|
+
const ignoreContent = `# coderev ignore list
|
|
593
|
+
# Files matching these patterns will be skipped during review.
|
|
594
|
+
|
|
595
|
+
*.min.js
|
|
596
|
+
*.bundle.js
|
|
597
|
+
package-lock.json
|
|
598
|
+
yarn.lock
|
|
599
|
+
vendor/
|
|
600
|
+
dist/
|
|
601
|
+
build/
|
|
602
|
+
`;
|
|
603
|
+
fs.writeFileSync(ignorePath, ignoreContent);
|
|
604
|
+
console.log(chalk.green(`✔ Default .coderevignore created at ${ignorePath}`));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Also create .coderevhint if it doesn't exist
|
|
608
|
+
const hintPath = path.join(process.cwd(), '.coderevhint');
|
|
609
|
+
if (!fs.existsSync(hintPath)) {
|
|
610
|
+
const hintContent = `# Project context for AI code review
|
|
611
|
+
# Describe your project here to get more relevant reviews.
|
|
612
|
+
|
|
613
|
+
## Project Overview
|
|
614
|
+
- Language:
|
|
615
|
+
- Framework:
|
|
616
|
+
- Build system:
|
|
617
|
+
|
|
618
|
+
## Conventions
|
|
619
|
+
- Prefer:
|
|
620
|
+
- Avoid:
|
|
621
|
+
`;
|
|
622
|
+
fs.writeFileSync(hintPath, hintContent);
|
|
623
|
+
console.log(chalk.green(`✔ Default .coderevhint created at ${hintPath}`));
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
program.parse(process.argv);
|
|
628
|
+
|
|
629
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
630
|
+
async function getGitDiff(repoPath, base = 'main', head) {
|
|
631
|
+
const { execSync } = require('child_process');
|
|
632
|
+
const args = ['git', 'diff'];
|
|
633
|
+
if (base) args.push(base);
|
|
634
|
+
if (head) args.push(head);
|
|
635
|
+
try {
|
|
636
|
+
return execSync(args.join(' '), { cwd: repoPath, encoding: 'utf-8' });
|
|
637
|
+
} catch (err) {
|
|
638
|
+
throw new Error(`Failed to get git diff: ${err.stderr || err.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function formatTerminal(result) {
|
|
643
|
+
// English section
|
|
644
|
+
const enLines = [];
|
|
645
|
+
enLines.push(chalk.bold('\n📋 Code Review Report'));
|
|
646
|
+
enLines.push('━'.repeat(50));
|
|
647
|
+
if (result.summary) enLines.push('\n' + chalk.bold('Summary:') + ' ' + result.summary);
|
|
648
|
+
if (result.score !== undefined && result.score !== null) {
|
|
649
|
+
const color = result.score >= 80 ? chalk.green : result.score >= 50 ? chalk.yellow : chalk.red;
|
|
650
|
+
enLines.push('\n' + chalk.bold('Score:') + ' ' + color(result.score + '/100'));
|
|
651
|
+
}
|
|
652
|
+
if (result.issues && result.issues.length > 0) {
|
|
653
|
+
enLines.push('\n' + chalk.bold('Issues (' + result.issues.length + '):'));
|
|
654
|
+
for (const issue of result.issues) {
|
|
655
|
+
const typeLabel = issue.type === 'error' ? chalk.red('✖') : issue.type === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
|
|
656
|
+
const sev = issue.severity ? ' [' + issue.severity + ']' : '';
|
|
657
|
+
enLines.push(' ' + typeLabel + sev + ' ' + issue.message);
|
|
658
|
+
if (issue.file) enLines.push(' ' + chalk.gray('File:') + ' ' + issue.file);
|
|
659
|
+
if (issue.line) enLines.push(' ' + chalk.gray('Line:') + ' ' + issue.line);
|
|
660
|
+
if (issue.suggestion) enLines.push(' ' + chalk.gray('Suggestion:') + ' ' + issue.suggestion);
|
|
624
661
|
}
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
program.parse(process.argv);
|
|
628
|
-
|
|
629
|
-
// ── Helpers ───────────────────────────────────────────────────
|
|
630
|
-
async function getGitDiff(repoPath, base = 'main', head) {
|
|
631
|
-
const { execSync } = require('child_process');
|
|
632
|
-
const args = ['git', 'diff'];
|
|
633
|
-
if (base) args.push(base);
|
|
634
|
-
if (head) args.push(head);
|
|
635
|
-
try {
|
|
636
|
-
return execSync(args.join(' '), { cwd: repoPath, encoding: 'utf-8' });
|
|
637
|
-
} catch (err) {
|
|
638
|
-
throw new Error(`Failed to get git diff: ${err.stderr || err.message}`);
|
|
639
662
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const lines = [];
|
|
644
|
-
lines.push(chalk.bold('\n📋 Code Review Report / 代码审查报告'));
|
|
645
|
-
lines.push('━'.repeat(50));
|
|
646
|
-
|
|
647
|
-
if (result.summary) {
|
|
648
|
-
lines.push('\n' + chalk.bold('Summary / 摘要:') + ' ' + result.summary);
|
|
663
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
664
|
+
enLines.push('\n' + chalk.bold('Suggestions:'));
|
|
665
|
+
for (const s of result.suggestions) enLines.push(' 💡 ' + s);
|
|
649
666
|
}
|
|
667
|
+
if (result.praise && result.praise.length > 0) {
|
|
668
|
+
enLines.push('\n' + chalk.bold('👍 Good Practices:'));
|
|
669
|
+
for (const p of result.praise) enLines.push(' ✅ ' + p);
|
|
670
|
+
}
|
|
671
|
+
enLines.push('\n' + '━'.repeat(50));
|
|
650
672
|
|
|
673
|
+
// Chinese section
|
|
674
|
+
const cnLines = [];
|
|
675
|
+
cnLines.push(chalk.bold('\n📋 代码审查报告'));
|
|
676
|
+
cnLines.push('━'.repeat(50));
|
|
677
|
+
if (result.summary) cnLines.push('\n' + chalk.bold('摘要:') + ' ' + result.summary);
|
|
651
678
|
if (result.score !== undefined && result.score !== null) {
|
|
652
679
|
const color = result.score >= 80 ? chalk.green : result.score >= 50 ? chalk.yellow : chalk.red;
|
|
653
|
-
|
|
680
|
+
cnLines.push('\n' + chalk.bold('评分:') + ' ' + color(result.score + '/100'));
|
|
654
681
|
}
|
|
655
|
-
|
|
656
682
|
if (result.issues && result.issues.length > 0) {
|
|
657
|
-
|
|
683
|
+
cnLines.push('\n' + chalk.bold('问题 (' + result.issues.length + '):'));
|
|
658
684
|
for (const issue of result.issues) {
|
|
659
|
-
const typeLabel =
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (issue.
|
|
666
|
-
if (issue.line) lines.push(' ' + chalk.gray('Line / 行:') + ' ' + issue.line);
|
|
667
|
-
if (issue.suggestion) lines.push(' ' + chalk.gray('Suggestion / 建议:') + ' ' + issue.suggestion);
|
|
685
|
+
const typeLabel = issue.type === 'error' ? chalk.red('✖') : issue.type === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
|
|
686
|
+
const sevMap = { high: '严重', medium: '中等', low: '轻微' };
|
|
687
|
+
const sevLabel = issue.severity && sevMap[issue.severity] ? ' [' + sevMap[issue.severity] + ']' : '';
|
|
688
|
+
cnLines.push(' ' + typeLabel + sevLabel + ' ' + issue.message);
|
|
689
|
+
if (issue.file) cnLines.push(' ' + chalk.gray('文件:') + ' ' + issue.file);
|
|
690
|
+
if (issue.line) cnLines.push(' ' + chalk.gray('行号:') + ' ' + issue.line);
|
|
691
|
+
if (issue.suggestion) cnLines.push(' ' + chalk.gray('建议:') + ' ' + issue.suggestion);
|
|
668
692
|
}
|
|
669
693
|
}
|
|
670
|
-
|
|
671
694
|
if (result.suggestions && result.suggestions.length > 0) {
|
|
672
|
-
|
|
673
|
-
for (const s of result.suggestions)
|
|
674
|
-
lines.push(' 💡 ' + s);
|
|
675
|
-
}
|
|
695
|
+
cnLines.push('\n' + chalk.bold('改进建议:'));
|
|
696
|
+
for (const s of result.suggestions) cnLines.push(' 💡 ' + s);
|
|
676
697
|
}
|
|
677
|
-
|
|
678
698
|
if (result.praise && result.praise.length > 0) {
|
|
679
|
-
|
|
680
|
-
for (const p of result.praise)
|
|
681
|
-
lines.push(' ✅ ' + p);
|
|
682
|
-
}
|
|
699
|
+
cnLines.push('\n' + chalk.bold('👍 好的实践:'));
|
|
700
|
+
for (const p of result.praise) cnLines.push(' ✅ ' + p);
|
|
683
701
|
}
|
|
702
|
+
cnLines.push('\n' + '━'.repeat(50));
|
|
684
703
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
704
|
+
return enLines.join('\n') + '\n' + cnLines.join('\n');
|
|
705
|
+
}
|
|
706
|
+
|
|
689
707
|
function formatMarkdown(result) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (result.summary) md += '**Summary
|
|
693
|
-
if (result.score !== undefined) md += '**Score
|
|
694
|
-
|
|
708
|
+
// English section
|
|
709
|
+
let md = '# 📋 Code Review Report\n\n';
|
|
710
|
+
if (result.summary) md += '**Summary:** ' + result.summary + '\n\n';
|
|
711
|
+
if (result.score !== undefined) md += '**Score:** ' + result.score + '/100\n\n';
|
|
695
712
|
if (result.issues?.length) {
|
|
696
|
-
md += '## Issues
|
|
713
|
+
md += '## Issues (' + result.issues.length + ')\n\n';
|
|
697
714
|
for (const issue of result.issues) {
|
|
698
|
-
const sevLabel = issue.severity === 'high' ? '严重' : issue.severity === 'medium' ? '中等' : issue.severity === 'low' ? '轻微' : '';
|
|
699
715
|
md += '- **' + issue.type.toUpperCase() + '**';
|
|
700
|
-
if (
|
|
716
|
+
if (issue.severity) md += ' [' + issue.severity + ']';
|
|
701
717
|
md += ': ' + issue.message + '\n';
|
|
702
|
-
if (issue.file) md += ' - File
|
|
703
|
-
if (issue.line) md += ' - Line
|
|
704
|
-
if (issue.suggestion) md += ' - Suggestion
|
|
718
|
+
if (issue.file) md += ' - File: \`' + issue.file + '\`\n';
|
|
719
|
+
if (issue.line) md += ' - Line: ' + issue.line + '\n';
|
|
720
|
+
if (issue.suggestion) md += ' - Suggestion: ' + issue.suggestion + '\n';
|
|
705
721
|
}
|
|
706
722
|
}
|
|
707
|
-
|
|
708
723
|
if (result.suggestions?.length) {
|
|
709
|
-
md += '\n## Suggestions
|
|
724
|
+
md += '\n## Suggestions\n\n';
|
|
710
725
|
for (const s of result.suggestions) md += '- 💡 ' + s + '\n';
|
|
711
726
|
}
|
|
727
|
+
if (result.praise?.length) {
|
|
728
|
+
md += '\n## 👍 Good Practices\n\n';
|
|
729
|
+
for (const p of result.praise) md += '- ✅ ' + p + '\n';
|
|
730
|
+
}
|
|
712
731
|
|
|
732
|
+
// Chinese section
|
|
733
|
+
md += '\n---\n\n';
|
|
734
|
+
md += '# 📋 代码审查报告\n\n';
|
|
735
|
+
if (result.summary) md += '**摘要:** ' + result.summary + '\n\n';
|
|
736
|
+
if (result.score !== undefined) md += '**评分:** ' + result.score + '/100\n\n';
|
|
737
|
+
if (result.issues?.length) {
|
|
738
|
+
md += '## 问题 (' + result.issues.length + ')\n\n';
|
|
739
|
+
for (const issue of result.issues) {
|
|
740
|
+
const sevMap = { high: '严重', medium: '中等', low: '轻微' };
|
|
741
|
+
const sevLabel = issue.severity && sevMap[issue.severity] ? ' [' + sevMap[issue.severity] + ']' : '';
|
|
742
|
+
md += '- **' + issue.type.toUpperCase() + '**' + sevLabel + ': ' + issue.message + '\n';
|
|
743
|
+
if (issue.file) md += ' - 文件: \`' + issue.file + '\`\n';
|
|
744
|
+
if (issue.line) md += ' - 行号: ' + issue.line + '\n';
|
|
745
|
+
if (issue.suggestion) md += ' - 建议: ' + issue.suggestion + '\n';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (result.suggestions?.length) {
|
|
749
|
+
md += '\n## 改进建议\n\n';
|
|
750
|
+
for (const s of result.suggestions) md += '- 💡 ' + s + '\n';
|
|
751
|
+
}
|
|
713
752
|
if (result.praise?.length) {
|
|
714
|
-
md += '\n## 👍
|
|
753
|
+
md += '\n## 👍 好的实践\n\n';
|
|
715
754
|
for (const p of result.praise) md += '- ✅ ' + p + '\n';
|
|
716
755
|
}
|
|
717
756
|
|
|
718
757
|
return md;
|
|
719
|
-
}
|
|
758
|
+
}
|