coderev-cli 1.0.17 → 1.0.19
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 +3 -2
- package/src/cli.js +21 -0
- package/src/github-app.js +511 -0
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
- [coderev cache(缓存管理)](#coderev-cache缓存管理)
|
|
27
27
|
- [coderev config(配置管理)](#coderev-config配置管理)
|
|
28
28
|
- [coderev init(初始化)](#coderev-init初始化)
|
|
29
|
+
- [coderev serve(GitHub App 自动审查)](#coderev-servegithub-app-自动审查)
|
|
29
30
|
- [配置详解](#配置详解)
|
|
30
31
|
- [平台集成](#平台集成)
|
|
31
32
|
- [CI/CD 集成](#cicd-集成)
|
|
@@ -437,6 +438,49 @@ coderev init
|
|
|
437
438
|
|
|
438
439
|
---
|
|
439
440
|
|
|
441
|
+
### coderev serve(GitHub App 自动审查)
|
|
442
|
+
|
|
443
|
+
**作用**:启动 webhook 服务器,监听 GitHub App 的 pull_request 事件,自动对每个新 PR 进行代码审查。
|
|
444
|
+
|
|
445
|
+
**适用场景**:团队仓库每个 PR 自动审查 / 开源项目自动反馈 / CI/CD 增强
|
|
446
|
+
|
|
447
|
+
**参数**:
|
|
448
|
+
|
|
449
|
+
| 参数 | 说明 | 示例 |
|
|
450
|
+
|------|------|------|
|
|
451
|
+
| `--port` | 服务器端口(默认 3000) | `--port 8080` |
|
|
452
|
+
| `--app-id` | GitHub App ID | `--app-id 123456` |
|
|
453
|
+
| `--private-key` | GitHub App 私钥 PEM | `--private-key "$(cat key.pem)"` |
|
|
454
|
+
| `--webhook-secret` | Webhook 签名密钥 | `--webhook-secret xxx` |
|
|
455
|
+
| `--review-mode` | 审查模式(comment/inline/check) | `--review-mode inline` |
|
|
456
|
+
| `--auto-approve` | 无问题 PR 自动 approve | `--auto-approve` |
|
|
457
|
+
| `--min-confidence` | 最低置信度阈值 | `--min-confidence 70` |
|
|
458
|
+
|
|
459
|
+
**示例**:
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
# 启动服务器
|
|
463
|
+
coderev serve --app-id 123456 --webhook-secret mysecret --private-key "$(cat /path/to/key.pem)"
|
|
464
|
+
|
|
465
|
+
# 使用环境变量
|
|
466
|
+
GITHUB_APP_ID=123456 GITHUB_APP_WEBHOOK_SECRET=mysecret coderev serve
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**事件处理**:
|
|
470
|
+
- `pull_request.opened` — 新 PR 自动审查
|
|
471
|
+
- `pull_request.synchronize` — PR 更新时重新审查
|
|
472
|
+
- `pull_request.reopened` — 重新审查
|
|
473
|
+
- Draft PR 和 Bot PR 默认跳过
|
|
474
|
+
|
|
475
|
+
**输出**:审查完成后自动:
|
|
476
|
+
1. 发布 PR review comment(Markdown 格式)
|
|
477
|
+
2. 设置 commit status(pending → success/failure/neutral)
|
|
478
|
+
3. 可选 auto-approve(无问题的 PR)
|
|
479
|
+
|
|
480
|
+
> 完整部署指南见 [docs/github-app.md](docs/github-app.md)
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
440
484
|
## 配置详解
|
|
441
485
|
|
|
442
486
|
### 配置加载顺序
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coderev-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"description": "Multi-agent AI code review for git -- parallel agents with confidence scoring",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"chalk": "^4.1.2",
|
|
43
43
|
"commander": "^12.0.0",
|
|
44
44
|
"diff": "^5.2.0",
|
|
45
|
-
"openai": "^4.0.0"
|
|
45
|
+
"openai": "^4.0.0",
|
|
46
|
+
"puppeteer": "^25.1.0"
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/cli.js
CHANGED
|
@@ -710,6 +710,27 @@ build/
|
|
|
710
710
|
}
|
|
711
711
|
});
|
|
712
712
|
|
|
713
|
+
// ── Serve (GitHub App) ────────────────────────────────────────────
|
|
714
|
+
program
|
|
715
|
+
.command('serve')
|
|
716
|
+
.description('Start GitHub App webhook server for automatic PR review')
|
|
717
|
+
.option('-p, --port <number>', 'Server port (default: 3000)')
|
|
718
|
+
.option('--webhook-secret <secret>', 'GitHub App webhook secret')
|
|
719
|
+
.option('--app-id <id>', 'GitHub App ID')
|
|
720
|
+
.option('--private-key <key>', 'GitHub App private key (PEM)')
|
|
721
|
+
.option('--review-mode <mode>', 'Review output: comment (default) | inline | check')
|
|
722
|
+
.option('--auto-approve', 'Auto-approve PRs with no issues')
|
|
723
|
+
.option('--min-confidence <number>', 'Minimum confidence threshold 0-100 (default: 60)')
|
|
724
|
+
.action(async (options) => {
|
|
725
|
+
try {
|
|
726
|
+
const { serveCommand } = require('./github-app');
|
|
727
|
+
await serveCommand(options);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
console.error(chalk.red(`✖ ${err.message}`));
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
713
734
|
program.parse(process.argv);
|
|
714
735
|
|
|
715
736
|
// ── Helpers ───────────────────────────────────────────────────
|
|
@@ -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 || process.env.GITHUB_APP_PRIVATE_KEY_FILE ? require('fs').readFileSync(process.env.GITHUB_APP_PRIVATE_KEY_FILE, 'utf8') : 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');
|