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
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
package/src/blame.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git blame context analysis for coderev.
|
|
3
|
+
*
|
|
4
|
+
* Runs `git blame` on modified files to distinguish:
|
|
5
|
+
* - **New issues**: introduced in the current diff/commit
|
|
6
|
+
* - **Pre-existing issues**: already present before this change
|
|
7
|
+
*
|
|
8
|
+
* This helps reviewers focus on what's actually new vs. inherited debt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a unified diff into per-file line additions.
|
|
17
|
+
* @param {string} diff - The git diff text
|
|
18
|
+
* @returns {Array<{file: string, addedLines: number[]}>}
|
|
19
|
+
*/
|
|
20
|
+
function parseDiffAddedLines(diff) {
|
|
21
|
+
if (!diff || typeof diff !== 'string') return [];
|
|
22
|
+
|
|
23
|
+
const result = [];
|
|
24
|
+
const lines = diff.split('\n');
|
|
25
|
+
let currentFile = null;
|
|
26
|
+
let currentAdded = [];
|
|
27
|
+
let oldStart = 0, newStart = 0, oldCount = 0, newCount = 0;
|
|
28
|
+
let newLineOffset = 0;
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Detect file header
|
|
32
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.*)/);
|
|
33
|
+
if (fileMatch) {
|
|
34
|
+
if (currentFile && currentAdded.length > 0) {
|
|
35
|
+
result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
|
|
36
|
+
}
|
|
37
|
+
currentFile = fileMatch[1];
|
|
38
|
+
currentAdded = [];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Chunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
43
|
+
const chunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
44
|
+
if (chunkMatch) {
|
|
45
|
+
oldStart = parseInt(chunkMatch[1], 10);
|
|
46
|
+
oldCount = chunkMatch[2] ? parseInt(chunkMatch[2], 10) : 1;
|
|
47
|
+
newStart = parseInt(chunkMatch[3], 10);
|
|
48
|
+
newCount = chunkMatch[4] ? parseInt(chunkMatch[4], 10) : 1;
|
|
49
|
+
newLineOffset = newStart - 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Track added lines (context or removed = old)
|
|
54
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
55
|
+
newLineOffset++;
|
|
56
|
+
currentAdded.push(newLineOffset);
|
|
57
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
58
|
+
// Removed lines don't advance new position
|
|
59
|
+
continue;
|
|
60
|
+
} else {
|
|
61
|
+
// Context line: advances both old and new
|
|
62
|
+
newLineOffset++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Push last file
|
|
67
|
+
if (currentFile && currentAdded.length > 0) {
|
|
68
|
+
result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run `git blame` on a file for specific line numbers.
|
|
76
|
+
* @param {string} filePath - Path relative to git repo root
|
|
77
|
+
* @param {number[]} lineNumbers - Array of line numbers to blame
|
|
78
|
+
* @param {string} [repoPath] - Path to git repo (default: cwd)
|
|
79
|
+
* @returns {Promise<object>} Map of lineNumber -> { author, commit, date, isNew }
|
|
80
|
+
*/
|
|
81
|
+
function blameLines(filePath, lineNumbers, repoPath) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
if (!lineNumbers || lineNumbers.length === 0) {
|
|
84
|
+
resolve({});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cwd = repoPath || process.cwd();
|
|
89
|
+
const absPath = path.resolve(cwd, filePath);
|
|
90
|
+
|
|
91
|
+
if (!fs.existsSync(absPath)) {
|
|
92
|
+
resolve({});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Run git blame for specific lines, porcelain format
|
|
98
|
+
const lineArgs = lineNumbers.map(n => `-L ${n},${n}`).join(' ');
|
|
99
|
+
const cmd = `git blame --porcelain ${lineArgs} -- "${filePath}"`;
|
|
100
|
+
|
|
101
|
+
const stdout = execSync(cmd, {
|
|
102
|
+
cwd,
|
|
103
|
+
encoding: 'utf-8',
|
|
104
|
+
timeout: 10000,
|
|
105
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = {};
|
|
110
|
+
|
|
111
|
+
// Parse porcelain format
|
|
112
|
+
const porcelainLines = stdout.split('\n');
|
|
113
|
+
let i = 0;
|
|
114
|
+
|
|
115
|
+
while (i < porcelainLines.length) {
|
|
116
|
+
const line = porcelainLines[i];
|
|
117
|
+
if (!line.trim()) { i++; continue; }
|
|
118
|
+
|
|
119
|
+
// Header line: commit-hash author-line file-line (or not)
|
|
120
|
+
const headerMatch = line.match(/^([a-f0-9]+)\s+(\d+)\s+(\d+)\s+(\d+)$/);
|
|
121
|
+
if (!headerMatch) { i++; continue; }
|
|
122
|
+
|
|
123
|
+
const commitHash = headerMatch[1];
|
|
124
|
+
const origLine = parseInt(headerMatch[2], 10);
|
|
125
|
+
const finalLine = parseInt(headerMatch[3], 10);
|
|
126
|
+
const numLines = parseInt(headerMatch[4], 10);
|
|
127
|
+
|
|
128
|
+
// Skip boundary (not committed yet)
|
|
129
|
+
if (commitHash === '0000000000000000000000000000000000000000') {
|
|
130
|
+
result[finalLine] = { commit: commitHash, author: '(uncommitted)', date: new Date(), isNew: true };
|
|
131
|
+
// Skip ahead the content lines
|
|
132
|
+
i += numLines + 1;
|
|
133
|
+
while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read header fields until content
|
|
138
|
+
let author = '(unknown)';
|
|
139
|
+
let date = new Date(0);
|
|
140
|
+
let isNew = false;
|
|
141
|
+
|
|
142
|
+
i++;
|
|
143
|
+
while (i < porcelainLines.length && !porcelainLines[i].startsWith('\t')) {
|
|
144
|
+
const hdr = porcelainLines[i];
|
|
145
|
+
if (hdr.startsWith('author ')) {
|
|
146
|
+
author = hdr.slice(7);
|
|
147
|
+
} else if (hdr.startsWith('author-time ')) {
|
|
148
|
+
date = new Date(parseInt(hdr.slice(12), 10) * 1000);
|
|
149
|
+
} else if (hdr.startsWith('boundary')) {
|
|
150
|
+
// Boundary commit = root of history
|
|
151
|
+
}
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Line content (starts with \t)
|
|
156
|
+
if (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) {
|
|
157
|
+
// Content line - skip
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
result[finalLine] = { commit: commitHash, author, date, isNew };
|
|
161
|
+
|
|
162
|
+
// Jump to next header (skip content lines)
|
|
163
|
+
while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
resolve(result);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// git blame failed (file not tracked, etc.)
|
|
169
|
+
resolve({});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Analyze a diff with git blame to classify issues.
|
|
176
|
+
* @param {string} diff - The git diff
|
|
177
|
+
* @param {object} [options] - Options
|
|
178
|
+
* @param {string} [options.repoPath] - Git repo path
|
|
179
|
+
* @returns {Promise<{fileContexts: Array}>}
|
|
180
|
+
*/
|
|
181
|
+
async function analyzeDiffContext(diff, options = {}) {
|
|
182
|
+
const files = parseDiffAddedLines(diff);
|
|
183
|
+
const fileContexts = [];
|
|
184
|
+
|
|
185
|
+
for (const fileEntry of files) {
|
|
186
|
+
const { file, addedLines } = fileEntry;
|
|
187
|
+
if (addedLines.length === 0) continue;
|
|
188
|
+
|
|
189
|
+
const blameMap = await blameLines(file, addedLines, options.repoPath);
|
|
190
|
+
const newLines = [];
|
|
191
|
+
const existingLines = [];
|
|
192
|
+
|
|
193
|
+
for (const lineNum of addedLines) {
|
|
194
|
+
if (blameMap[lineNum] && blameMap[lineNum].isNew) {
|
|
195
|
+
newLines.push(lineNum);
|
|
196
|
+
} else {
|
|
197
|
+
existingLines.push(lineNum);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fileContexts.push({
|
|
202
|
+
file,
|
|
203
|
+
totalNewLines: addedLines.length,
|
|
204
|
+
newLines,
|
|
205
|
+
existingLines,
|
|
206
|
+
existingCount: existingLines.length,
|
|
207
|
+
newCount: newLines.length,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return fileContexts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Tag issues with blame context (new vs pre-existing).
|
|
216
|
+
* @param {Array} issues - List of review issues
|
|
217
|
+
* @param {Array} fileContexts - Output from analyzeDiffContext
|
|
218
|
+
* @returns {Array} Tagged issues with `isNew` field
|
|
219
|
+
*/
|
|
220
|
+
function tagIssuesWithBlame(issues, fileContexts) {
|
|
221
|
+
if (!issues || !fileContexts) return issues || [];
|
|
222
|
+
|
|
223
|
+
const lineMap = {};
|
|
224
|
+
for (const ctx of fileContexts) {
|
|
225
|
+
for (const ln of ctx.newLines) {
|
|
226
|
+
lineMap[`${ctx.file}:${ln}`] = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return issues.map(issue => {
|
|
231
|
+
if (!issue.file || !issue.line) {
|
|
232
|
+
return { ...issue, isNew: null }; // Can't determine
|
|
233
|
+
}
|
|
234
|
+
const key = `${issue.file}:${issue.line}`;
|
|
235
|
+
return {
|
|
236
|
+
...issue,
|
|
237
|
+
isNew: lineMap[key] || false,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
parseDiffAddedLines,
|
|
244
|
+
blameLines,
|
|
245
|
+
analyzeDiffContext,
|
|
246
|
+
tagIssuesWithBlame,
|
|
247
|
+
};
|