coderev-cli 1.0.16 → 1.0.18
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/README.md +44 -0
- package/package.json +1 -1
- package/src/blame.js +247 -0
- package/src/cli.js +223 -170
- package/src/config.js +86 -8
- package/src/github-app.js +511 -0
- package/src/reviewer.js +19 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* coderev GitHub App Server
|
|
5
|
+
*
|
|
6
|
+
* 一个独立的 webhook 服务器,接收 GitHub App 的 pull_request 事件,
|
|
7
|
+
* 自动运行 coderev 审查并将结果作为 PR review 发布。
|
|
8
|
+
*
|
|
9
|
+
* 运行方式:
|
|
10
|
+
* coderev serve --port 3000 --webhook-secret your-secret --app-id 123456 --private-key ./key.pem
|
|
11
|
+
* 或
|
|
12
|
+
* GITHUB_APP_ID=123456 GITHUB_APP_PRIVATE_KEY="$(cat key.pem)" coderev serve
|
|
13
|
+
*
|
|
14
|
+
* 部署建议:
|
|
15
|
+
* - Railway / Render / Fly.io / 自建 VPS
|
|
16
|
+
* - 配合 PM2 持久化运行
|
|
17
|
+
* - 设置 GitHub App Webhook URL → https://your-domain.com/webhook
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const { loadConfig, getApiKey } = require('./config');
|
|
23
|
+
const { reviewDiff } = require('./reviewer');
|
|
24
|
+
const chalk = require('chalk');
|
|
25
|
+
|
|
26
|
+
// ── 配置 ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load GitHub App config from env vars (or .coderevrc.json later).
|
|
30
|
+
*/
|
|
31
|
+
function loadAppConfig() {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
const appConfig = config.githubApp || {};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
// Required
|
|
37
|
+
appId: process.env.GITHUB_APP_ID || appConfig.appId,
|
|
38
|
+
privateKey: process.env.GITHUB_APP_PRIVATE_KEY || appConfig.privateKey,
|
|
39
|
+
webhookSecret: process.env.GITHUB_APP_WEBHOOK_SECRET || appConfig.webhookSecret || '',
|
|
40
|
+
|
|
41
|
+
// Optional
|
|
42
|
+
port: parseInt(process.env.PORT || appConfig.port || 3000, 10),
|
|
43
|
+
host: process.env.HOST || appConfig.host || '0.0.0.0',
|
|
44
|
+
autoApprove: process.env.AUTO_APPROVE === 'true' || appConfig.autoApprove === true,
|
|
45
|
+
minConfidence: parseInt(process.env.MIN_CONFIDENCE || appConfig.minConfidence || 60, 10),
|
|
46
|
+
reviewMode: process.env.REVIEW_MODE || appConfig.reviewMode || 'comment',
|
|
47
|
+
// 'comment' — single PR comment summary
|
|
48
|
+
// 'inline' — inline review comments (needs file SHA mapping)
|
|
49
|
+
// 'check' — commit status check (set pending → success/failure)
|
|
50
|
+
skipDrafts: process.env.SKIP_DRAFTS !== 'false',
|
|
51
|
+
skipBotPRs: process.env.SKIP_BOT_PRS !== 'false',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── GitHub App JWT Token ────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a JWT for GitHub App authentication.
|
|
59
|
+
* @param {string} appId - GitHub App ID
|
|
60
|
+
* @param {string} privateKeyPem - RSA private key in PEM format
|
|
61
|
+
* @returns {{ token: string, expiresAt: number }}
|
|
62
|
+
*/
|
|
63
|
+
function generateAppJWT(appId, privateKeyPem) {
|
|
64
|
+
if (!appId || !privateKeyPem) {
|
|
65
|
+
throw new Error('Missing GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = Math.floor(Date.now() / 1000);
|
|
69
|
+
const payload = {
|
|
70
|
+
iat: now - 60, // 1 minute leeway
|
|
71
|
+
exp: now + 600, // 10 minute expiry (GitHub max)
|
|
72
|
+
iss: parseInt(appId, 10),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const header = {
|
|
76
|
+
alg: 'RS256',
|
|
77
|
+
typ: 'JWT',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Base64url encode
|
|
81
|
+
const b64u = (obj) => Buffer.from(JSON.stringify(obj))
|
|
82
|
+
.toString('base64')
|
|
83
|
+
.replace(/=/g, '')
|
|
84
|
+
.replace(/\+/g, '-')
|
|
85
|
+
.replace(/\//g, '_');
|
|
86
|
+
|
|
87
|
+
const headerEnc = b64u(header);
|
|
88
|
+
const payloadEnc = b64u(payload);
|
|
89
|
+
|
|
90
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
91
|
+
sign.update(headerEnc + '.' + payloadEnc);
|
|
92
|
+
const sig = sign.sign(privateKeyPem, 'base64')
|
|
93
|
+
.replace(/=/g, '')
|
|
94
|
+
.replace(/\+/g, '-')
|
|
95
|
+
.replace(/\//g, '_');
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
token: headerEnc + '.' + payloadEnc + '.' + sig,
|
|
99
|
+
expiresAt: payload.exp,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── GitHub API 调用(已认证) ──────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Call GitHub API with JWT or installation token.
|
|
107
|
+
*/
|
|
108
|
+
function githubApi(path, token, method = 'GET', body = null) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const opts = {
|
|
111
|
+
hostname: 'api.github.com',
|
|
112
|
+
path,
|
|
113
|
+
method,
|
|
114
|
+
headers: {
|
|
115
|
+
'User-Agent': 'coderev-github-app',
|
|
116
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
117
|
+
'Authorization': `Bearer ${token}`,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
if (body) {
|
|
121
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
122
|
+
opts.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(body));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const req = https.request(opts, (res) => {
|
|
126
|
+
let data = '';
|
|
127
|
+
res.on('data', (c) => (data += c));
|
|
128
|
+
res.on('end', () => {
|
|
129
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
130
|
+
try { resolve(data ? JSON.parse(data) : {}); }
|
|
131
|
+
catch { resolve(data); }
|
|
132
|
+
} else if (res.statusCode === 204) {
|
|
133
|
+
resolve({});
|
|
134
|
+
} else {
|
|
135
|
+
reject(new Error(`GitHub API ${res.statusCode}: ${data.slice(0, 300)}`));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
req.on('error', reject);
|
|
140
|
+
if (body) req.write(JSON.stringify(body));
|
|
141
|
+
req.end();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Exchange JWT for an installation access token.
|
|
147
|
+
* @param {string} jwt
|
|
148
|
+
* @param {number} installationId
|
|
149
|
+
* @returns {Promise<{ token: string, expiresAt: string }>}
|
|
150
|
+
*/
|
|
151
|
+
async function getInstallationToken(jwt, installationId) {
|
|
152
|
+
const result = await githubApi(`/app/installations/${installationId}/access_tokens`, jwt, 'POST');
|
|
153
|
+
return { token: result.token, expiresAt: result.expires_at };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Webhook 处理 ────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Verify webhook signature.
|
|
160
|
+
*/
|
|
161
|
+
function verifySignature(payload, signature, secret) {
|
|
162
|
+
if (!secret) return true; // No secret configured — skip verification
|
|
163
|
+
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
164
|
+
// Constant-time comparison
|
|
165
|
+
if (sig.length !== signature.length) return false;
|
|
166
|
+
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(signature));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Handle pull_request.opened and pull_request.synchronize events.
|
|
171
|
+
*/
|
|
172
|
+
async function handlePREvent(event, payload, appConfig) {
|
|
173
|
+
const action = payload.action;
|
|
174
|
+
const pr = payload.pull_request;
|
|
175
|
+
|
|
176
|
+
// Validate event
|
|
177
|
+
if (!['opened', 'synchronize', 'reopened'].includes(action)) {
|
|
178
|
+
return { handled: false, reason: `unsupported action: ${action}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skip drafts
|
|
182
|
+
if (appConfig.skipDrafts !== false && pr.draft) {
|
|
183
|
+
return { handled: false, reason: 'draft PR, skipped' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Skip bot PRs
|
|
187
|
+
if (appConfig.skipBotPRs !== false && (pr.user?.type === 'Bot' || (pr.title || '').startsWith('[bot]'))) {
|
|
188
|
+
return { handled: false, reason: 'bot PR, skipped' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const owner = payload.repository.owner.login;
|
|
192
|
+
const repo = payload.repository.name;
|
|
193
|
+
const prNumber = pr.number;
|
|
194
|
+
const ref = { owner, repo, pr: prNumber };
|
|
195
|
+
const installationId = payload.installation?.id;
|
|
196
|
+
|
|
197
|
+
if (!installationId) {
|
|
198
|
+
return { handled: false, reason: 'no installation id' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.error(chalk.blue(`[${owner}/${repo}#${prNumber}] Processing ${action}...`));
|
|
202
|
+
|
|
203
|
+
// 1. Get installation token
|
|
204
|
+
const jwt = generateAppJWT(appConfig.appId, appConfig.privateKey);
|
|
205
|
+
let instToken;
|
|
206
|
+
try {
|
|
207
|
+
instToken = await getInstallationToken(jwt.token, installationId);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error(chalk.red(`[${owner}/${repo}#${prNumber}] Failed to get installation token: ${err.message}`));
|
|
210
|
+
return { handled: false, error: err.message };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const token = instToken.token;
|
|
214
|
+
|
|
215
|
+
// 2. Fetch PR diff
|
|
216
|
+
const { fetchPrDiff } = require('./github');
|
|
217
|
+
let diff;
|
|
218
|
+
try {
|
|
219
|
+
diff = await fetchPrDiff(ref, token);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(chalk.red(`[${owner}/${repo}#${prNumber}] Failed to fetch diff: ${err.message}`));
|
|
222
|
+
await setCommitStatus(token, ref, pr.head.sha, 'error', 'Failed to fetch diff');
|
|
223
|
+
return { handled: false, error: err.message };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. Set commit status to pending
|
|
227
|
+
if (appConfig.reviewMode === 'check' || true) {
|
|
228
|
+
try {
|
|
229
|
+
await setCommitStatus(token, ref, pr.head.sha, 'pending', 'coderev is reviewing...');
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 4. Run review
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = await reviewDiff(diff, null, {
|
|
237
|
+
noCache: true,
|
|
238
|
+
minConfidence: appConfig.minConfidence,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(chalk.red(`[${owner}/${repo}#${prNumber}] Review failed: ${err.message}`));
|
|
242
|
+
await setCommitStatus(token, ref, pr.head.sha, 'error', 'Review failed: ' + err.message.slice(0, 100));
|
|
243
|
+
return { handled: false, error: err.message };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const issueCount = (result.issues || []).length;
|
|
247
|
+
|
|
248
|
+
console.error(chalk.cyan(`[${owner}/${repo}#${prNumber}] Review complete: ${result.score}/100, ${issueCount} issues`));
|
|
249
|
+
|
|
250
|
+
// 5. Post review
|
|
251
|
+
try {
|
|
252
|
+
if (appConfig.reviewMode === 'inline') {
|
|
253
|
+
// Inline mode — post review comments at file level
|
|
254
|
+
await postInlineReview(token, ref, pr, result);
|
|
255
|
+
} else {
|
|
256
|
+
// Default: post a PR comment summary
|
|
257
|
+
const { postPrComment } = require('./github');
|
|
258
|
+
const md = formatAppMarkdown(result, ref);
|
|
259
|
+
await postPrComment(ref, md, token);
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(chalk.yellow(`[${owner}/${repo}#${prNumber}] Failed to post comment: ${err.message}`));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 6. Set final commit status (check mode or always)
|
|
266
|
+
try {
|
|
267
|
+
const state = issueCount === 0 ? 'success' : (result.score >= 60 ? 'neutral' : 'failure');
|
|
268
|
+
const description = issueCount === 0
|
|
269
|
+
? '✅ coderev: no issues found'
|
|
270
|
+
: `⚠ coderev: ${issueCount} issues (score: ${result.score}/100)`;
|
|
271
|
+
await setCommitStatus(token, ref, pr.head.sha, state, description);
|
|
272
|
+
} catch {}
|
|
273
|
+
|
|
274
|
+
// 7. Auto-approve if configured and no issues
|
|
275
|
+
if (appConfig.autoApprove && issueCount === 0) {
|
|
276
|
+
try {
|
|
277
|
+
await approvePR(token, ref, pr.head.sha);
|
|
278
|
+
console.error(chalk.green(`[${owner}/${repo}#${prNumber}] Auto-approved`));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(chalk.yellow(`[${owner}/${repo}#${prNumber}] Auto-approve failed: ${err.message}`));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { handled: true, score: result.score, issues: issueCount };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Set a commit status on GitHub.
|
|
289
|
+
*/
|
|
290
|
+
async function setCommitStatus(token, ref, sha, state, description) {
|
|
291
|
+
const body = {
|
|
292
|
+
state, // 'pending', 'success', 'failure', 'error', 'neutral'
|
|
293
|
+
description: description || 'coderev review',
|
|
294
|
+
context: 'coderev/review',
|
|
295
|
+
target_url: `https://github.com/${ref.owner}/${ref.repo}/pull/${ref.pr}`,
|
|
296
|
+
};
|
|
297
|
+
return githubApi(`/repos/${ref.owner}/${ref.repo}/statuses/${sha}`, token, 'POST', body);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Approve a pull request.
|
|
302
|
+
*/
|
|
303
|
+
async function approvePR(token, ref, sha) {
|
|
304
|
+
const body = {
|
|
305
|
+
commit_id: sha,
|
|
306
|
+
event: 'APPROVE',
|
|
307
|
+
body: '✅ coderev: no issues found. Auto-approved.',
|
|
308
|
+
};
|
|
309
|
+
return githubApi(`/repos/${ref.owner}/${ref.repo}/pulls/${ref.pr}/reviews`, token, 'POST', body);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Post inline review comments.
|
|
314
|
+
*/
|
|
315
|
+
async function postInlineReview(token, ref, pr, result) {
|
|
316
|
+
const { fetchPrFiles } = require('./github');
|
|
317
|
+
const prFiles = await fetchPrFiles(ref, token);
|
|
318
|
+
|
|
319
|
+
const fileMap = {};
|
|
320
|
+
for (const f of prFiles) fileMap[f.filename] = f;
|
|
321
|
+
|
|
322
|
+
const comments = [];
|
|
323
|
+
for (const issue of result.issues || []) {
|
|
324
|
+
if (!issue.file) continue;
|
|
325
|
+
if (!fileMap[issue.file]) continue;
|
|
326
|
+
comments.push({
|
|
327
|
+
path: issue.file,
|
|
328
|
+
line: issue.line || 1,
|
|
329
|
+
side: 'RIGHT',
|
|
330
|
+
body: `**${issue.type.toUpperCase()}** [${issue.severity}]: ${issue.message}${issue.suggestion ? '\n\n> 💡 ' + issue.suggestion : ''}`,
|
|
331
|
+
});
|
|
332
|
+
if (comments.length >= 50) break; // GitHub limit
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const body = {
|
|
336
|
+
commit_id: pr.head.sha,
|
|
337
|
+
event: 'COMMENT',
|
|
338
|
+
body: `## 📋 coderev review\n\n**Score: ${result.score}/100** | ${(result.issues || []).length} issues found\n\n${result.summary || ''}`,
|
|
339
|
+
comments,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return githubApi(`/repos/${ref.owner}/${ref.repo}/pulls/${ref.pr}/reviews`, token, 'POST', body);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Webhook Server ──────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Start the GitHub App webhook server.
|
|
349
|
+
*/
|
|
350
|
+
function startServer(appConfig) {
|
|
351
|
+
const server = http.createServer(async (req, res) => {
|
|
352
|
+
// Health check
|
|
353
|
+
if (req.url === '/health') {
|
|
354
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
355
|
+
res.end(JSON.stringify({ status: 'ok', version: '1.0.0', uptime: process.uptime() }));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Only accept POST /webhook
|
|
360
|
+
if (req.method !== 'POST' || req.url !== '/webhook') {
|
|
361
|
+
res.writeHead(404);
|
|
362
|
+
res.end('Not found');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Read payload
|
|
367
|
+
const buffers = [];
|
|
368
|
+
for await (const chunk of req) buffers.push(chunk);
|
|
369
|
+
const rawBody = Buffer.concat(buffers);
|
|
370
|
+
|
|
371
|
+
// Verify signature
|
|
372
|
+
const signature = req.headers['x-hub-signature-256'] || '';
|
|
373
|
+
if (!verifySignature(rawBody, signature, appConfig.webhookSecret)) {
|
|
374
|
+
console.error(chalk.red('✖ Invalid webhook signature'));
|
|
375
|
+
res.writeHead(401);
|
|
376
|
+
res.end('Invalid signature');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const event = req.headers['x-github-event'];
|
|
381
|
+
let payload;
|
|
382
|
+
try {
|
|
383
|
+
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
384
|
+
} catch {
|
|
385
|
+
res.writeHead(400);
|
|
386
|
+
res.end('Invalid JSON');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Ack immediately
|
|
391
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
392
|
+
res.end(JSON.stringify({ received: true }));
|
|
393
|
+
|
|
394
|
+
// Handle PR events
|
|
395
|
+
if (event === 'pull_request') {
|
|
396
|
+
try {
|
|
397
|
+
const result = await handlePREvent(event, payload, appConfig);
|
|
398
|
+
console.error(chalk.green(`✔ ${payload.repository?.full_name || '?'}#${payload.pull_request?.number || '?'}: ${result.handled ? 'Reviewed' : 'Skipped (' + result.reason + ')'}`));
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.error(chalk.red(`✖ Webhook handler error: ${err.message}`));
|
|
401
|
+
}
|
|
402
|
+
} else if (event === 'installation' || event === 'installation_repositories') {
|
|
403
|
+
const action = payload.action;
|
|
404
|
+
const account = payload.installation?.account?.login || payload.sender?.login || 'unknown';
|
|
405
|
+
const repos = (payload.repositories || []).map(r => r.full_name).join(', ') || 'N/A';
|
|
406
|
+
console.error(chalk.cyan(`📦 Installation ${action}: ${account} — ${repos}`));
|
|
407
|
+
} else {
|
|
408
|
+
console.error(chalk.gray(`ℹ Ignored event: ${event}`));
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
server.listen(appConfig.port, appConfig.host, () => {
|
|
413
|
+
console.error(chalk.bold.green('\n🚀 coderev GitHub App Server'));
|
|
414
|
+
console.error(chalk.gray('━').repeat(50));
|
|
415
|
+
console.error(chalk.cyan(` Webhook URL: http://${appConfig.host}:${appConfig.port}/webhook`));
|
|
416
|
+
console.error(chalk.cyan(` Health: http://${appConfig.host}:${appConfig.port}/health`));
|
|
417
|
+
console.error(chalk.cyan(` App ID: ${appConfig.appId || '(not set)'}`));
|
|
418
|
+
console.error(chalk.cyan(` Review mode: ${appConfig.reviewMode}`));
|
|
419
|
+
console.error(chalk.cyan(` Auto-approve: ${appConfig.autoApprove ? 'yes' : 'no'}`));
|
|
420
|
+
console.error(chalk.gray('━').repeat(50));
|
|
421
|
+
console.error(chalk.gray(' Listening...'));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return server;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Markdown 格式化(App 版本) ─────────────────────────────
|
|
428
|
+
|
|
429
|
+
function formatAppMarkdown(result, ref) {
|
|
430
|
+
const TAG = `<!-- coderev:${ref.owner}/${ref.repo}#${ref.pr} -->`;
|
|
431
|
+
let md = `## 📋 coderev review\n\n${TAG}\n`;
|
|
432
|
+
md += `**Score:** ${result.score}/100\n`;
|
|
433
|
+
md += `**Issues found:** ${(result.issues || []).length}\n\n`;
|
|
434
|
+
|
|
435
|
+
if (result.summary) md += `${result.summary}\n\n`;
|
|
436
|
+
|
|
437
|
+
if (result.issues && result.issues.length > 0) {
|
|
438
|
+
md += '### Issues\n\n';
|
|
439
|
+
for (const issue of result.issues) {
|
|
440
|
+
const icons = { error: '🔴', warning: '🟡', info: '🔵' };
|
|
441
|
+
md += `- ${icons[issue.type] || '⚪'} **${issue.type.toUpperCase()}**`;
|
|
442
|
+
if (issue.severity) md += ` [${issue.severity}]`;
|
|
443
|
+
md += `: ${issue.message}`;
|
|
444
|
+
if (issue.file) md += ` (\`${issue.file}\``;
|
|
445
|
+
if (issue.line) md += `:${issue.line}`;
|
|
446
|
+
if (issue.file) md += `)`;
|
|
447
|
+
md += '\n';
|
|
448
|
+
if (issue.suggestion) md += ` - 💡 ${issue.suggestion}\n`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (result.praise && result.praise.length > 0) {
|
|
453
|
+
md += '\n### ✅ Good Practices\n\n';
|
|
454
|
+
for (const p of result.praise) md += `- ${p}\n`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (result.score !== undefined) {
|
|
458
|
+
const scoreVal = result.score;
|
|
459
|
+
const emoji = scoreVal >= 80 ? '🟢' : scoreVal >= 50 ? '🟡' : '🔴';
|
|
460
|
+
md += `\n${emoji} **Overall Score:** ${result.score}/100\n`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return md;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── CLI 入口 ────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Parse CLI args and start the server.
|
|
470
|
+
* Called from cli.js 'serve' command.
|
|
471
|
+
*/
|
|
472
|
+
async function serveCommand(options) {
|
|
473
|
+
const config = loadConfig();
|
|
474
|
+
const appConfig = loadAppConfig();
|
|
475
|
+
|
|
476
|
+
// CLI overrides
|
|
477
|
+
if (options.port) appConfig.port = parseInt(options.port, 10);
|
|
478
|
+
if (options.webhookSecret) appConfig.webhookSecret = options.webhookSecret;
|
|
479
|
+
if (options.appId) appConfig.appId = options.appId;
|
|
480
|
+
if (options.privateKey) appConfig.privateKey = options.privateKey;
|
|
481
|
+
if (options.reviewMode) appConfig.reviewMode = options.reviewMode;
|
|
482
|
+
if (options.autoApprove !== undefined) appConfig.autoApprove = options.autoApprove;
|
|
483
|
+
if (options.minConfidence) appConfig.minConfidence = parseInt(options.minConfidence, 10);
|
|
484
|
+
|
|
485
|
+
// Validate required fields
|
|
486
|
+
const missing = [];
|
|
487
|
+
if (!appConfig.appId) missing.push('--app-id / GITHUB_APP_ID');
|
|
488
|
+
if (!appConfig.privateKey) missing.push('--private-key / GITHUB_APP_PRIVATE_KEY');
|
|
489
|
+
|
|
490
|
+
if (missing.length > 0) {
|
|
491
|
+
console.error(chalk.red('✖ Missing required configuration:'));
|
|
492
|
+
for (const m of missing) console.error(chalk.red(` ${m}`));
|
|
493
|
+
console.error('');
|
|
494
|
+
console.error(chalk.yellow('To create a GitHub App:'));
|
|
495
|
+
console.error(chalk.yellow(' 1. Go to https://github.com/settings/apps/new'));
|
|
496
|
+
console.error(chalk.yellow(' 2. Set Webhook URL to your server URL + /webhook'));
|
|
497
|
+
console.error(chalk.yellow(' 3. Subscribe to "Pull requests" event'));
|
|
498
|
+
console.error(chalk.yellow(' 4. Download the private key (.pem file)'));
|
|
499
|
+
console.error(chalk.yellow(' 5. Run:'));
|
|
500
|
+
console.error(chalk.yellow(' coderev serve --app-id <ID> --private-key "$(cat key.pem)"'));
|
|
501
|
+
console.error('');
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return startServer(appConfig);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = { serveCommand, handlePREvent, generateAppJWT, getInstallationToken, formatAppMarkdown };
|
|
509
|
+
|
|
510
|
+
// Required for inline require('https') in functions
|
|
511
|
+
const https = require('https');
|
package/src/reviewer.js
CHANGED
|
@@ -2,6 +2,7 @@ const { loadConfig, getApiKey } = require('./config');
|
|
|
2
2
|
const { cacheKey, getCached, setCached } = require('./cache');
|
|
3
3
|
const { recordReview } = require('./stats');
|
|
4
4
|
const { getRuleDescriptions } = require('./rules');
|
|
5
|
+
const { analyzeDiffContext, tagIssuesWithBlame } = require('./blame');
|
|
5
6
|
|
|
6
7
|
// ── 多智能体并行审查 ──
|
|
7
8
|
|
|
@@ -217,6 +218,7 @@ async function runParallelAgents(apiKey, config, prompts) {
|
|
|
217
218
|
* @param {string} [options.context] - Previous review context (for incremental reviews)
|
|
218
219
|
* @param {string} [options.ignorePattern] - File patterns to ignore
|
|
219
220
|
* @param {number} [options.minConfidence] - Minimum confidence threshold (default: 60)
|
|
221
|
+
* @param {boolean} [options.blame] - Enable git blame context analysis
|
|
220
222
|
* @returns {Promise<object>} Review result with issues, suggestions, score, etc.
|
|
221
223
|
*/
|
|
222
224
|
async function reviewDiff(diff, config, options = {}) {
|
|
@@ -292,6 +294,23 @@ async function reviewDiff(diff, config, options = {}) {
|
|
|
292
294
|
}
|
|
293
295
|
}
|
|
294
296
|
|
|
297
|
+
// ── Git blame context analysis ──
|
|
298
|
+
if (options.blame && result.issues && result.issues.length > 0) {
|
|
299
|
+
try {
|
|
300
|
+
const fileContexts = await analyzeDiffContext(diff);
|
|
301
|
+
result.issues = tagIssuesWithBlame(result.issues, fileContexts);
|
|
302
|
+
result._blameContext = {
|
|
303
|
+
filesAnalyzed: fileContexts.length,
|
|
304
|
+
newIssues: result.issues.filter(i => i.isNew === true).length,
|
|
305
|
+
preExistingIssues: result.issues.filter(i => i.isNew === false).length,
|
|
306
|
+
unknownIssues: result.issues.filter(i => i.isNew === null).length,
|
|
307
|
+
};
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// Blame analysis is a best-effort enhancement; don't fail the review
|
|
310
|
+
result._blameContext = { error: err.message };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
295
314
|
// Record to stats
|
|
296
315
|
try { recordReview(result); } catch {}
|
|
297
316
|
|