@spaceflow/review 0.81.0 → 0.83.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.
@@ -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 { PullRequestModel } from "./pull-request-model";
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?: { specs: ReviewSpec[]; fileContents: FileContentsMap },
75
- pr?: PullRequestModel,
62
+ preloaded: { specs: ReviewSpec[]; fileContents: FileContentsMap },
76
63
  ): Promise<ReviewIssue[]> {
77
- const { llmMode, specSources, verbose } = context;
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
- let specs: ReviewSpec[];
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
- console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
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
- // 检查问题行范围内是否有任意一行属于本次 PR 的有效 commits
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
- if (actualHash !== "-------" && validCommitHashes.has(actualHash)) {
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
- // 问题行都不属于本次 PR 的有效 commits
226
+ // 问题行都不属于本次变更范围
435
227
  if (shouldLog(verbose, 2)) {
436
- console.log(` Issue ${issue.file}:${issue.line} 不在本次 PR 变更行范围内,跳过`);
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(` 过滤非本次 PR commits 问题后: ${beforeCount} -> ${filtered.length} 个问题`);
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
+ });