@spaceflow/review 0.81.0 → 0.82.0
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/CHANGELOG.md +17 -0
- package/dist/index.js +798 -717
- package/package.json +2 -2
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +20 -279
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +5 -4
- package/src/review-spec/review-spec.service.ts +5 -15
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
|
@@ -5,20 +5,9 @@ import {
|
|
|
5
5
|
type VerboseLevel,
|
|
6
6
|
shouldLog,
|
|
7
7
|
GitSdkService,
|
|
8
|
-
parseChangedLinesFromPatch,
|
|
9
|
-
parseDiffText,
|
|
10
|
-
parseHunksFromPatch,
|
|
11
|
-
calculateNewLineNumber,
|
|
12
8
|
} from "@spaceflow/core";
|
|
13
9
|
import type { IConfigReader } from "@spaceflow/core";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
ReviewSpecService,
|
|
17
|
-
ReviewSpec,
|
|
18
|
-
ReviewIssue,
|
|
19
|
-
FileContentsMap,
|
|
20
|
-
FileContentLine,
|
|
21
|
-
} from "./review-spec";
|
|
10
|
+
import { ReviewSpecService, ReviewSpec, ReviewIssue, FileContentsMap } from "./review-spec";
|
|
22
11
|
import { IssueVerifyService } from "./issue-verify.service";
|
|
23
12
|
import { generateIssueKey } from "./utils/review-pr-comment";
|
|
24
13
|
import type { ReviewContext } from "./review-context";
|
|
@@ -65,16 +54,14 @@ export class ReviewIssueFilter {
|
|
|
65
54
|
|
|
66
55
|
/**
|
|
67
56
|
* LLM 验证历史问题是否已修复
|
|
68
|
-
* 如果传入 preloaded(specs/fileContents),直接使用;否则从 PR 获取
|
|
69
57
|
*/
|
|
70
58
|
async verifyAndUpdateIssues(
|
|
71
59
|
context: ReviewContext,
|
|
72
60
|
issues: ReviewIssue[],
|
|
73
61
|
commits: PullRequestCommit[],
|
|
74
|
-
preloaded
|
|
75
|
-
pr?: PullRequestModel,
|
|
62
|
+
preloaded: { specs: ReviewSpec[]; fileContents: FileContentsMap },
|
|
76
63
|
): Promise<ReviewIssue[]> {
|
|
77
|
-
const { llmMode,
|
|
64
|
+
const { llmMode, verbose } = context;
|
|
78
65
|
const unfixedIssues = issues.filter((i) => i.valid !== "false" && !i.fixed);
|
|
79
66
|
|
|
80
67
|
if (unfixedIssues.length === 0) {
|
|
@@ -88,37 +75,11 @@ export class ReviewIssueFilter {
|
|
|
88
75
|
return issues;
|
|
89
76
|
}
|
|
90
77
|
|
|
91
|
-
if (!preloaded && (!specSources?.length || !pr)) {
|
|
92
|
-
if (shouldLog(verbose, 1)) {
|
|
93
|
-
console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 pr)`);
|
|
94
|
-
}
|
|
95
|
-
return issues;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
78
|
if (shouldLog(verbose, 1)) {
|
|
99
79
|
console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
|
|
100
80
|
}
|
|
101
81
|
|
|
102
|
-
|
|
103
|
-
let fileContents: FileContentsMap;
|
|
104
|
-
|
|
105
|
-
if (preloaded) {
|
|
106
|
-
specs = preloaded.specs;
|
|
107
|
-
fileContents = preloaded.fileContents;
|
|
108
|
-
} else {
|
|
109
|
-
const changedFiles = await pr!.getFiles();
|
|
110
|
-
const headSha = await pr!.getHeadSha();
|
|
111
|
-
specs = await this.loadSpecs(specSources, verbose);
|
|
112
|
-
fileContents = await this.getFileContents(
|
|
113
|
-
pr!.owner,
|
|
114
|
-
pr!.repo,
|
|
115
|
-
changedFiles,
|
|
116
|
-
commits,
|
|
117
|
-
headSha,
|
|
118
|
-
pr!.number,
|
|
119
|
-
verbose,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
82
|
+
const { specs, fileContents } = preloaded;
|
|
122
83
|
|
|
123
84
|
return await this.issueVerifyService.verifyIssueFixes(
|
|
124
85
|
issues,
|
|
@@ -175,105 +136,6 @@ export class ReviewIssueFilter {
|
|
|
175
136
|
}
|
|
176
137
|
}
|
|
177
138
|
|
|
178
|
-
/**
|
|
179
|
-
* 获取文件内容并构建行号到 commit hash 的映射
|
|
180
|
-
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
181
|
-
*/
|
|
182
|
-
async getFileContents(
|
|
183
|
-
owner: string,
|
|
184
|
-
repo: string,
|
|
185
|
-
changedFiles: ChangedFile[],
|
|
186
|
-
commits: PullRequestCommit[],
|
|
187
|
-
ref: string,
|
|
188
|
-
prNumber?: number,
|
|
189
|
-
verbose?: VerboseLevel,
|
|
190
|
-
isLocalMode?: boolean,
|
|
191
|
-
): Promise<FileContentsMap> {
|
|
192
|
-
const contents: FileContentsMap = new Map();
|
|
193
|
-
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
194
|
-
|
|
195
|
-
// 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
|
|
196
|
-
// 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
|
|
197
|
-
// buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
|
|
198
|
-
if (shouldLog(verbose, 1)) {
|
|
199
|
-
console.log(`📊 正在构建行号到变更的映射...`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
for (const file of changedFiles) {
|
|
203
|
-
if (file.filename && file.status !== "deleted") {
|
|
204
|
-
try {
|
|
205
|
-
let rawContent: string;
|
|
206
|
-
if (isLocalMode) {
|
|
207
|
-
// 本地模式:读取工作区文件的当前内容
|
|
208
|
-
rawContent = this.gitSdk.getWorkingFileContent(file.filename);
|
|
209
|
-
} else if (prNumber) {
|
|
210
|
-
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
211
|
-
} else {
|
|
212
|
-
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
213
|
-
}
|
|
214
|
-
const lines = rawContent.split("\n");
|
|
215
|
-
|
|
216
|
-
// 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
|
|
217
|
-
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
218
|
-
|
|
219
|
-
// 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
|
|
220
|
-
// 情况1: 文件是新增的(status 为 added/A)
|
|
221
|
-
// 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
|
|
222
|
-
const isNewFile =
|
|
223
|
-
file.status === "added" ||
|
|
224
|
-
file.status === "A" ||
|
|
225
|
-
(file.additions && file.additions > 0 && file.deletions === 0 && !file.patch);
|
|
226
|
-
if (changedLines.size === 0 && isNewFile) {
|
|
227
|
-
changedLines = new Set(lines.map((_, i) => i + 1));
|
|
228
|
-
if (shouldLog(verbose, 2)) {
|
|
229
|
-
console.log(
|
|
230
|
-
` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (shouldLog(verbose, 3)) {
|
|
236
|
-
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
237
|
-
console.log(` latestCommitHash: ${latestCommitHash}`);
|
|
238
|
-
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
239
|
-
console.log(
|
|
240
|
-
` 变更行号: ${Array.from(changedLines)
|
|
241
|
-
.sort((a, b) => a - b)
|
|
242
|
-
.join(", ")}`,
|
|
243
|
-
);
|
|
244
|
-
} else if (changedLines.size > 20) {
|
|
245
|
-
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
246
|
-
}
|
|
247
|
-
if (!file.patch) {
|
|
248
|
-
console.log(
|
|
249
|
-
` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`,
|
|
250
|
-
);
|
|
251
|
-
} else {
|
|
252
|
-
console.log(
|
|
253
|
-
` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const contentLines: FileContentLine[] = lines.map((line, index) => {
|
|
259
|
-
const lineNum = index + 1;
|
|
260
|
-
// 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
|
|
261
|
-
const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
|
|
262
|
-
return [hash, line];
|
|
263
|
-
});
|
|
264
|
-
contents.set(file.filename, contentLines);
|
|
265
|
-
} catch (error) {
|
|
266
|
-
console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (shouldLog(verbose, 1)) {
|
|
272
|
-
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
273
|
-
}
|
|
274
|
-
return contents;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
139
|
async fillIssueCode(
|
|
278
140
|
issues: ReviewIssue[],
|
|
279
141
|
fileContents: FileContentsMap,
|
|
@@ -300,86 +162,6 @@ export class ReviewIssueFilter {
|
|
|
300
162
|
});
|
|
301
163
|
}
|
|
302
164
|
|
|
303
|
-
/**
|
|
304
|
-
* 根据代码变更更新历史 issue 的行号
|
|
305
|
-
* 当代码发生变化时,之前发现的 issue 行号可能已经不准确
|
|
306
|
-
* 此方法通过分析 diff 来计算新的行号
|
|
307
|
-
*/
|
|
308
|
-
updateIssueLineNumbers(
|
|
309
|
-
issues: ReviewIssue[],
|
|
310
|
-
filePatchMap: Map<string, string>,
|
|
311
|
-
verbose?: VerboseLevel,
|
|
312
|
-
): ReviewIssue[] {
|
|
313
|
-
let updatedCount = 0;
|
|
314
|
-
let invalidatedCount = 0;
|
|
315
|
-
const updatedIssues = issues.map((issue) => {
|
|
316
|
-
// 如果 issue 已修复、已解决或无效,不需要更新行号
|
|
317
|
-
if (issue.fixed || issue.resolved || issue.valid === "false") {
|
|
318
|
-
return issue;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const patch = filePatchMap.get(issue.file);
|
|
322
|
-
if (!patch) {
|
|
323
|
-
// 文件没有变更,行号不变
|
|
324
|
-
return issue;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const lines = this.reviewSpecService.parseLineRange(issue.line);
|
|
328
|
-
if (lines.length === 0) {
|
|
329
|
-
return issue;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const startLine = lines[0];
|
|
333
|
-
const endLine = lines[lines.length - 1];
|
|
334
|
-
const hunks = parseHunksFromPatch(patch);
|
|
335
|
-
|
|
336
|
-
// 计算新的起始行号
|
|
337
|
-
const newStartLine = calculateNewLineNumber(startLine, hunks);
|
|
338
|
-
if (newStartLine === null) {
|
|
339
|
-
// 起始行被删除,直接标记为无效问题
|
|
340
|
-
invalidatedCount++;
|
|
341
|
-
if (shouldLog(verbose, 1)) {
|
|
342
|
-
console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
|
|
343
|
-
}
|
|
344
|
-
return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// 如果是范围行号,计算新的结束行号
|
|
348
|
-
let newLine: string;
|
|
349
|
-
if (startLine === endLine) {
|
|
350
|
-
newLine = String(newStartLine);
|
|
351
|
-
} else {
|
|
352
|
-
const newEndLine = calculateNewLineNumber(endLine, hunks);
|
|
353
|
-
if (newEndLine === null || newEndLine === newStartLine) {
|
|
354
|
-
// 结束行被删除或范围缩小为单行,使用起始行
|
|
355
|
-
newLine = String(newStartLine);
|
|
356
|
-
} else {
|
|
357
|
-
newLine = `${newStartLine}-${newEndLine}`;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// 如果行号发生变化,更新 issue
|
|
362
|
-
if (newLine !== issue.line) {
|
|
363
|
-
updatedCount++;
|
|
364
|
-
if (shouldLog(verbose, 1)) {
|
|
365
|
-
console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
|
|
366
|
-
}
|
|
367
|
-
return { ...issue, line: newLine, originalLine: issue.originalLine ?? issue.line };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return issue;
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
|
|
374
|
-
const parts: string[] = [];
|
|
375
|
-
if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
|
|
376
|
-
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
377
|
-
console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return updatedIssues;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
165
|
/**
|
|
384
166
|
* 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
|
|
385
167
|
* 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
|
|
@@ -391,9 +173,15 @@ export class ReviewIssueFilter {
|
|
|
391
173
|
verbose?: VerboseLevel,
|
|
392
174
|
): ReviewIssue[] {
|
|
393
175
|
const validCommitHashes = new Set(commits.map((c) => c.sha?.slice(0, 7)).filter(Boolean));
|
|
176
|
+
// commits 为空时(如分支比较模式本地无 commit 信息),退化为"行是否在 diff 变更范围内"模式
|
|
177
|
+
const useChangedLinesMode = validCommitHashes.size === 0;
|
|
394
178
|
|
|
395
179
|
if (shouldLog(verbose, 3)) {
|
|
396
|
-
|
|
180
|
+
if (useChangedLinesMode) {
|
|
181
|
+
console.log(` 🔍 commits 为空,使用变更行模式过滤`);
|
|
182
|
+
} else {
|
|
183
|
+
console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
|
|
184
|
+
}
|
|
397
185
|
}
|
|
398
186
|
|
|
399
187
|
const beforeCount = issues.length;
|
|
@@ -415,12 +203,16 @@ export class ReviewIssueFilter {
|
|
|
415
203
|
return true;
|
|
416
204
|
}
|
|
417
205
|
|
|
418
|
-
//
|
|
206
|
+
// 检查问题行范围内是否有任意一行属于本次变更(diff 范围)
|
|
419
207
|
for (const lineNum of lineNums) {
|
|
420
208
|
const lineData = contentLines[lineNum - 1];
|
|
421
209
|
if (lineData) {
|
|
422
210
|
const [actualHash] = lineData;
|
|
423
|
-
|
|
211
|
+
const isChangedLine = actualHash !== "-------";
|
|
212
|
+
const isValid = useChangedLinesMode
|
|
213
|
+
? isChangedLine
|
|
214
|
+
: isChangedLine && validCommitHashes.has(actualHash);
|
|
215
|
+
if (isValid) {
|
|
424
216
|
if (shouldLog(verbose, 3)) {
|
|
425
217
|
console.log(
|
|
426
218
|
` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`,
|
|
@@ -431,9 +223,9 @@ export class ReviewIssueFilter {
|
|
|
431
223
|
}
|
|
432
224
|
}
|
|
433
225
|
|
|
434
|
-
//
|
|
226
|
+
// 问题行都不属于本次变更范围
|
|
435
227
|
if (shouldLog(verbose, 2)) {
|
|
436
|
-
console.log(` Issue ${issue.file}:${issue.line}
|
|
228
|
+
console.log(` Issue ${issue.file}:${issue.line} 不在本次变更行范围内,跳过`);
|
|
437
229
|
}
|
|
438
230
|
if (shouldLog(verbose, 3)) {
|
|
439
231
|
const hashes = lineNums.map((ln) => {
|
|
@@ -445,7 +237,7 @@ export class ReviewIssueFilter {
|
|
|
445
237
|
return false;
|
|
446
238
|
});
|
|
447
239
|
if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
|
|
448
|
-
console.log(`
|
|
240
|
+
console.log(` 变更行过滤后: ${beforeCount} -> ${filtered.length} 个问题`);
|
|
449
241
|
}
|
|
450
242
|
return filtered;
|
|
451
243
|
}
|
|
@@ -469,55 +261,4 @@ export class ReviewIssueFilter {
|
|
|
469
261
|
generateIssueKey(issue: ReviewIssue): string {
|
|
470
262
|
return generateIssueKey(issue);
|
|
471
263
|
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* 构建文件行号到 commit hash 的映射
|
|
475
|
-
* 遍历每个 commit,获取其修改的文件和行号
|
|
476
|
-
* 优先使用 API,失败时回退到 git 命令
|
|
477
|
-
*/
|
|
478
|
-
async buildLineCommitMap(
|
|
479
|
-
owner: string,
|
|
480
|
-
repo: string,
|
|
481
|
-
commits: PullRequestCommit[],
|
|
482
|
-
verbose?: VerboseLevel,
|
|
483
|
-
): Promise<Map<string, Map<number, string>>> {
|
|
484
|
-
// Map<filename, Map<lineNumber, commitHash>>
|
|
485
|
-
const fileLineMap = new Map<string, Map<number, string>>();
|
|
486
|
-
|
|
487
|
-
// 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
|
|
488
|
-
for (const commit of commits) {
|
|
489
|
-
if (!commit.sha) continue;
|
|
490
|
-
|
|
491
|
-
const shortHash = commit.sha.slice(0, 7);
|
|
492
|
-
let files: Array<{ filename: string; patch: string }> = [];
|
|
493
|
-
|
|
494
|
-
// 优先使用 getCommitDiff API 获取 diff 文本
|
|
495
|
-
try {
|
|
496
|
-
const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
|
|
497
|
-
files = parseDiffText(diffText);
|
|
498
|
-
} catch {
|
|
499
|
-
// API 失败,回退到 git 命令
|
|
500
|
-
files = this.gitSdk.getCommitDiff(commit.sha);
|
|
501
|
-
}
|
|
502
|
-
if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
|
|
503
|
-
|
|
504
|
-
for (const file of files) {
|
|
505
|
-
// 解析这个 commit 修改的行号
|
|
506
|
-
const changedLines = parseChangedLinesFromPatch(file.patch);
|
|
507
|
-
|
|
508
|
-
// 获取或创建文件的行号映射
|
|
509
|
-
if (!fileLineMap.has(file.filename)) {
|
|
510
|
-
fileLineMap.set(file.filename, new Map());
|
|
511
|
-
}
|
|
512
|
-
const lineMap = fileLineMap.get(file.filename)!;
|
|
513
|
-
|
|
514
|
-
// 记录每行对应的 commit hash
|
|
515
|
-
for (const lineNum of changedLines) {
|
|
516
|
-
lineMap.set(lineNum, shortHash);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
return fileLineMap;
|
|
522
|
-
}
|
|
523
264
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { ReviewLlmProcessor } from "./review-llm";
|
|
3
|
+
import type { ReviewPrompt } from "./review.service";
|
|
4
|
+
import { ChangedFileCollection } from "./changed-file-collection";
|
|
5
|
+
|
|
6
|
+
vi.mock("c12");
|
|
7
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
8
|
+
query: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("@opencode-ai/sdk", () => ({
|
|
11
|
+
createOpencodeClient: vi.fn().mockReturnValue({
|
|
12
|
+
session: {
|
|
13
|
+
create: vi.fn(),
|
|
14
|
+
prompt: vi.fn(),
|
|
15
|
+
delete: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("openai", () => {
|
|
20
|
+
const mCreate = vi.fn();
|
|
21
|
+
class MockOpenAI {
|
|
22
|
+
chat = {
|
|
23
|
+
completions: {
|
|
24
|
+
create: mCreate,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
static APIError = class extends Error {
|
|
28
|
+
status: number;
|
|
29
|
+
constructor(status: number, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
__esModule: true,
|
|
37
|
+
default: MockOpenAI,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("ReviewLlmProcessor", () => {
|
|
42
|
+
let processor: ReviewLlmProcessor;
|
|
43
|
+
let mockLlmProxyService: any;
|
|
44
|
+
let mockReviewSpecService: any;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
|
|
49
|
+
mockReviewSpecService = {
|
|
50
|
+
resolveSpecSources: vi.fn().mockResolvedValue(["/mock/spec/dir"]),
|
|
51
|
+
loadReviewSpecs: vi.fn().mockResolvedValue([
|
|
52
|
+
{
|
|
53
|
+
filename: "ts.base.md",
|
|
54
|
+
extensions: ["ts"],
|
|
55
|
+
rules: [{ id: "R1", title: "Rule 1", description: "D1", examples: [], overrides: [] }],
|
|
56
|
+
overrides: [],
|
|
57
|
+
includes: [],
|
|
58
|
+
},
|
|
59
|
+
]),
|
|
60
|
+
applyOverrides: vi.fn().mockImplementation((specs) => specs),
|
|
61
|
+
filterApplicableSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
62
|
+
filterIssuesByIncludes: vi.fn().mockImplementation((issues) => issues),
|
|
63
|
+
filterIssuesByOverrides: vi.fn().mockImplementation((issues) => issues),
|
|
64
|
+
filterIssuesByCommits: vi.fn().mockImplementation((issues) => issues),
|
|
65
|
+
formatIssues: vi.fn().mockImplementation((issues) => issues),
|
|
66
|
+
buildSpecsSection: vi.fn().mockReturnValue("mock specs section"),
|
|
67
|
+
filterIssuesByRuleExistence: vi.fn().mockImplementation((issues) => issues),
|
|
68
|
+
deduplicateSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
mockLlmProxyService = {
|
|
72
|
+
chat: vi.fn(),
|
|
73
|
+
chatStream: vi.fn(),
|
|
74
|
+
createSession: vi.fn(),
|
|
75
|
+
getAvailableAdapters: vi.fn().mockReturnValue(["claude-code", "openai"]),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
processor = new ReviewLlmProcessor(mockLlmProxyService as any, mockReviewSpecService as any);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("runLLMReview", () => {
|
|
86
|
+
it("should call callLLM when llmMode is claude", async () => {
|
|
87
|
+
const callLLMSpy = vi
|
|
88
|
+
.spyOn(processor, "callLLM")
|
|
89
|
+
.mockResolvedValue({ issues: [], summary: [] } as any);
|
|
90
|
+
|
|
91
|
+
const mockPrompt: ReviewPrompt = {
|
|
92
|
+
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await processor.runLLMReview("claude-code", mockPrompt);
|
|
96
|
+
|
|
97
|
+
expect(callLLMSpy).toHaveBeenCalledWith("claude-code", mockPrompt, {});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should call callLLM when llmMode is openai", async () => {
|
|
101
|
+
const callLLMSpy = vi
|
|
102
|
+
.spyOn(processor, "callLLM")
|
|
103
|
+
.mockResolvedValue({ issues: [], summary: [] } as any);
|
|
104
|
+
|
|
105
|
+
const mockPrompt: ReviewPrompt = {
|
|
106
|
+
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
await processor.runLLMReview("openai", mockPrompt);
|
|
110
|
+
|
|
111
|
+
expect(callLLMSpy).toHaveBeenCalledWith("openai", mockPrompt, {});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("normalizeIssues", () => {
|
|
116
|
+
it("should split comma separated lines", () => {
|
|
117
|
+
const issues = [
|
|
118
|
+
{
|
|
119
|
+
file: "test.ts",
|
|
120
|
+
line: "10, 12",
|
|
121
|
+
ruleId: "R1",
|
|
122
|
+
specFile: "s1.md",
|
|
123
|
+
reason: "r1",
|
|
124
|
+
suggestion: "fix",
|
|
125
|
+
} as any,
|
|
126
|
+
];
|
|
127
|
+
const normalized = processor.normalizeIssues(issues);
|
|
128
|
+
expect(normalized).toHaveLength(2);
|
|
129
|
+
expect(normalized[0].line).toBe("10");
|
|
130
|
+
expect(normalized[1].line).toBe("12");
|
|
131
|
+
expect(normalized[1].suggestion).toContain("参考 test.ts:10");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("buildReviewPrompt", () => {
|
|
136
|
+
it("should build prompts for changed files", async () => {
|
|
137
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
|
|
138
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
139
|
+
const fileContents = new Map([
|
|
140
|
+
[
|
|
141
|
+
"test.ts",
|
|
142
|
+
[
|
|
143
|
+
["abc1234", "const x = 1;"],
|
|
144
|
+
["-------", "const y = 2;"],
|
|
145
|
+
],
|
|
146
|
+
],
|
|
147
|
+
]);
|
|
148
|
+
const commits = [{ sha: "abc1234567890", commit: { message: "fix" } }];
|
|
149
|
+
const result = await processor.buildReviewPrompt(
|
|
150
|
+
specs as any,
|
|
151
|
+
ChangedFileCollection.from(changedFiles),
|
|
152
|
+
fileContents as any,
|
|
153
|
+
commits,
|
|
154
|
+
);
|
|
155
|
+
expect(result.filePrompts).toHaveLength(1);
|
|
156
|
+
expect(result.filePrompts[0].filename).toBe("test.ts");
|
|
157
|
+
expect(result.filePrompts[0].userPrompt).toContain("test.ts");
|
|
158
|
+
expect(result.filePrompts[0].systemPrompt).toContain("代码审查专家");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should skip deleted files", async () => {
|
|
162
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
163
|
+
const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
|
|
164
|
+
const result = await processor.buildReviewPrompt(
|
|
165
|
+
specs as any,
|
|
166
|
+
ChangedFileCollection.from(changedFiles),
|
|
167
|
+
new Map(),
|
|
168
|
+
[],
|
|
169
|
+
);
|
|
170
|
+
expect(result.filePrompts).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should handle missing file contents", async () => {
|
|
174
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
175
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
176
|
+
const result = await processor.buildReviewPrompt(
|
|
177
|
+
specs as any,
|
|
178
|
+
ChangedFileCollection.from(changedFiles),
|
|
179
|
+
new Map(),
|
|
180
|
+
[],
|
|
181
|
+
);
|
|
182
|
+
expect(result.filePrompts).toHaveLength(1);
|
|
183
|
+
expect(result.filePrompts[0].userPrompt).toContain("无法获取内容");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should include existing result in prompt", async () => {
|
|
187
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
188
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
189
|
+
const fileContents = new Map([["test.ts", [["-------", "code"]]]]);
|
|
190
|
+
const existingResult = {
|
|
191
|
+
issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad code" }],
|
|
192
|
+
summary: [{ file: "test.ts", summary: "has issues" }],
|
|
193
|
+
};
|
|
194
|
+
const result = await processor.buildReviewPrompt(
|
|
195
|
+
specs as any,
|
|
196
|
+
ChangedFileCollection.from(changedFiles),
|
|
197
|
+
fileContents as any,
|
|
198
|
+
[],
|
|
199
|
+
existingResult as any,
|
|
200
|
+
);
|
|
201
|
+
expect(result.filePrompts[0].userPrompt).toContain("bad code");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("generatePrDescription", () => {
|
|
206
|
+
it("should generate description from LLM", async () => {
|
|
207
|
+
const mockStream = (async function* () {
|
|
208
|
+
yield { type: "text", content: "# Feat: 新功能\n\n详细描述" };
|
|
209
|
+
})();
|
|
210
|
+
mockLlmProxyService.chatStream.mockReturnValue(mockStream);
|
|
211
|
+
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
212
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
213
|
+
const result = await processor.generatePrDescription(
|
|
214
|
+
commits,
|
|
215
|
+
ChangedFileCollection.from(changedFiles),
|
|
216
|
+
"openai",
|
|
217
|
+
);
|
|
218
|
+
expect(result.title).toBe("Feat: 新功能");
|
|
219
|
+
expect(result.description).toContain("详细描述");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should fallback on LLM error", async () => {
|
|
223
|
+
const mockStream = (async function* () {
|
|
224
|
+
yield { type: "error", message: "fail" };
|
|
225
|
+
})();
|
|
226
|
+
mockLlmProxyService.chatStream.mockReturnValue(mockStream);
|
|
227
|
+
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
228
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
229
|
+
const result = await processor.generatePrDescription(
|
|
230
|
+
commits,
|
|
231
|
+
ChangedFileCollection.from(changedFiles),
|
|
232
|
+
"openai",
|
|
233
|
+
);
|
|
234
|
+
expect(result.title).toBeDefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should include code changes section when fileContents provided", async () => {
|
|
238
|
+
const mockStream = (async function* () {
|
|
239
|
+
yield { type: "text", content: "Feat: test\n\ndesc" };
|
|
240
|
+
})();
|
|
241
|
+
mockLlmProxyService.chatStream.mockReturnValue(mockStream);
|
|
242
|
+
const commits = [{ sha: "abc123", commit: { message: "feat" } }];
|
|
243
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
244
|
+
const fileContents = new Map([["a.ts", [["abc1234", "new code"]]]]) as any;
|
|
245
|
+
const result = await processor.generatePrDescription(
|
|
246
|
+
commits,
|
|
247
|
+
ChangedFileCollection.from(changedFiles),
|
|
248
|
+
"openai",
|
|
249
|
+
fileContents,
|
|
250
|
+
);
|
|
251
|
+
expect(result.title).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("buildBasicDescription", () => {
|
|
256
|
+
it("should build description from commits and files", async () => {
|
|
257
|
+
const mockStream = (async function* () {
|
|
258
|
+
yield { type: "text", content: "Feat: test" };
|
|
259
|
+
})();
|
|
260
|
+
mockLlmProxyService.chatStream.mockReturnValue(mockStream);
|
|
261
|
+
const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
|
|
262
|
+
const changedFiles = [
|
|
263
|
+
{ filename: "a.ts", status: "added" },
|
|
264
|
+
{ filename: "b.ts", status: "modified" },
|
|
265
|
+
{ filename: "c.ts", status: "deleted" },
|
|
266
|
+
];
|
|
267
|
+
const result = await processor.buildBasicDescription(
|
|
268
|
+
commits,
|
|
269
|
+
ChangedFileCollection.from(changedFiles),
|
|
270
|
+
);
|
|
271
|
+
expect(result.description).toContain("提交记录");
|
|
272
|
+
expect(result.description).toContain("文件变更");
|
|
273
|
+
expect(result.description).toContain("新增 1");
|
|
274
|
+
expect(result.description).toContain("修改 1");
|
|
275
|
+
expect(result.description).toContain("删除 1");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should handle empty commits", async () => {
|
|
279
|
+
const mockStream = (async function* () {
|
|
280
|
+
yield { type: "text", content: "Feat: empty" };
|
|
281
|
+
})();
|
|
282
|
+
mockLlmProxyService.chatStream.mockReturnValue(mockStream);
|
|
283
|
+
const result = await processor.buildBasicDescription([], ChangedFileCollection.empty());
|
|
284
|
+
expect(result.title).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|