@spaceflow/review 0.77.0 → 0.78.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.
@@ -23,6 +23,7 @@ import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includ
23
23
  import { ReviewLlmProcessor } from "./review-llm";
24
24
  import { PullRequestModel } from "./pull-request-model";
25
25
  import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
26
+ import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
26
27
 
27
28
  export type { ReviewContext } from "./review-context";
28
29
  export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
@@ -100,6 +101,14 @@ export class ReviewService {
100
101
  // 2. 规则匹配
101
102
  const specs = await this.issueFilter.loadSpecs(specSources, verbose);
102
103
  const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
104
+ if (shouldLog(verbose, 2)) {
105
+ console.log(
106
+ `[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`,
107
+ );
108
+ console.log(
109
+ `[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
110
+ );
111
+ }
103
112
  if (shouldLog(verbose, 1)) {
104
113
  console.log(` 适用的规则文件: ${applicableSpecs.length}`);
105
114
  }
@@ -138,6 +147,9 @@ export class ReviewService {
138
147
  fileContents,
139
148
  commits,
140
149
  existingResultModel?.result ?? null,
150
+ context.whenModifiedCode,
151
+ verbose,
152
+ context.systemRules,
141
153
  );
142
154
  const result = await this.runLLMReview(llmMode, reviewPrompt, {
143
155
  verbose,
@@ -166,6 +178,14 @@ export class ReviewService {
166
178
  isDirectFileMode,
167
179
  context,
168
180
  });
181
+
182
+ // 静态规则产生的系统问题直接合并,不经过过滤管道
183
+ if (reviewPrompt.staticIssues?.length) {
184
+ result.issues = [...reviewPrompt.staticIssues, ...result.issues];
185
+ if (shouldLog(verbose, 1)) {
186
+ console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
187
+ }
188
+ }
169
189
  if (shouldLog(verbose, 1)) {
170
190
  console.log(`📝 最终发现 ${result.issues.length} 个问题`);
171
191
  }
@@ -211,7 +231,7 @@ export class ReviewService {
211
231
  files,
212
232
  commits: filterCommits,
213
233
  localMode,
214
- skipDuplicateWorkflow,
234
+ duplicateWorkflowResolved,
215
235
  } = context;
216
236
 
217
237
  const isDirectFileMode = !!(files && files.length > 0 && baseRef === headRef);
@@ -290,9 +310,14 @@ export class ReviewService {
290
310
  }
291
311
 
292
312
  // 检查是否有其他同名 review workflow 正在运行中
293
- if (skipDuplicateWorkflow && ci && prInfo?.head?.sha) {
294
- const skipResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, verbose);
295
- if (skipResult) {
313
+ if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
314
+ const duplicateResult = await this.checkDuplicateWorkflow(
315
+ prModel,
316
+ prInfo.head.sha,
317
+ duplicateWorkflowResolved,
318
+ verbose,
319
+ );
320
+ if (duplicateResult) {
296
321
  return {
297
322
  prModel,
298
323
  commits,
@@ -300,7 +325,7 @@ export class ReviewService {
300
325
  headSha: prInfo.head.sha,
301
326
  isLocalMode,
302
327
  isDirectFileMode,
303
- earlyReturn: skipResult,
328
+ earlyReturn: duplicateResult,
304
329
  };
305
330
  }
306
331
  }
@@ -382,10 +407,20 @@ export class ReviewService {
382
407
  // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
383
408
  if (includes && includes.length > 0) {
384
409
  const beforeFiles = changedFiles.length;
410
+ if (shouldLog(verbose, 2)) {
411
+ console.log(
412
+ `[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
413
+ );
414
+ }
385
415
  changedFiles = filterFilesByIncludes(changedFiles, includes);
386
416
  if (shouldLog(verbose, 1)) {
387
417
  console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
388
418
  }
419
+ if (shouldLog(verbose, 2)) {
420
+ console.log(
421
+ `[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
422
+ );
423
+ }
389
424
 
390
425
  const globs = extractGlobsFromIncludes(includes);
391
426
  const beforeCommits = commits.length;
@@ -843,10 +878,12 @@ export class ReviewService {
843
878
 
844
879
  /**
845
880
  * 检查是否有其他同名 review workflow 正在运行中
881
+ * 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
846
882
  */
847
883
  private async checkDuplicateWorkflow(
848
884
  prModel: PullRequestModel,
849
885
  headSha: string,
886
+ mode: "skip" | "delete",
850
887
  verbose?: VerboseLevel,
851
888
  ): Promise<ReviewResult | null> {
852
889
  const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
@@ -866,6 +903,19 @@ export class ReviewService {
866
903
  (!currentRunId || String(w.id) !== currentRunId),
867
904
  );
868
905
  if (duplicateReviewRuns.length > 0) {
906
+ if (mode === "delete") {
907
+ // 删除模式:清理旧的 AI Review 评论和 PR Review
908
+ if (shouldLog(verbose, 1)) {
909
+ console.log(
910
+ `🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
911
+ );
912
+ }
913
+ await this.cleanupDuplicateAiReviews(prModel, verbose);
914
+ // 清理后继续执行当前审查
915
+ return null;
916
+ }
917
+
918
+ // 跳过模式(默认)
869
919
  if (shouldLog(verbose, 1)) {
870
920
  console.log(
871
921
  `⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
@@ -890,6 +940,56 @@ export class ReviewService {
890
940
  return null;
891
941
  }
892
942
 
943
+ /**
944
+ * 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
945
+ */
946
+ private async cleanupDuplicateAiReviews(
947
+ prModel: PullRequestModel,
948
+ verbose?: VerboseLevel,
949
+ ): Promise<void> {
950
+ try {
951
+ // 删除 Issue Comments(主评论)
952
+ const comments = await prModel.getComments();
953
+ const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
954
+ let deletedComments = 0;
955
+ for (const comment of aiComments) {
956
+ if (comment.id) {
957
+ try {
958
+ await prModel.deleteComment(comment.id);
959
+ deletedComments++;
960
+ } catch {
961
+ // 忽略删除失败
962
+ }
963
+ }
964
+ }
965
+ if (deletedComments > 0 && shouldLog(verbose, 1)) {
966
+ console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
967
+ }
968
+
969
+ // 删除 PR Reviews(行级评论)
970
+ const reviews = await prModel.getReviews();
971
+ const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
972
+ let deletedReviews = 0;
973
+ for (const review of aiReviews) {
974
+ if (review.id) {
975
+ try {
976
+ await prModel.deleteReview(review.id);
977
+ deletedReviews++;
978
+ } catch {
979
+ // 已提交的 review 无法删除,忽略
980
+ }
981
+ }
982
+ }
983
+ if (deletedReviews > 0 && shouldLog(verbose, 1)) {
984
+ console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
985
+ }
986
+ } catch (error) {
987
+ if (shouldLog(verbose, 1)) {
988
+ console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
989
+ }
990
+ }
991
+ }
992
+
893
993
  // --- Delegation methods for backward compatibility with tests ---
894
994
 
895
995
  protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
@@ -0,0 +1,48 @@
1
+ import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
2
+ import type { ReviewIssue, FileContentsMap } from "../review-spec";
3
+ import type { SystemRules } from "../review.config";
4
+ import { checkMaxLinesPerFile } from "./max-lines-per-file";
5
+
6
+ export {
7
+ RULE_ID as SYSTEM_RULE_MAX_LINES,
8
+ SPEC_FILE as SYSTEM_SPEC_FILE,
9
+ } from "./max-lines-per-file";
10
+
11
+ export interface ApplyStaticRulesResult {
12
+ staticIssues: ReviewIssue[];
13
+ /** 被静态规则排除的文件名集合,不应再进入 LLM 审查 */
14
+ skippedFiles: Set<string>;
15
+ }
16
+
17
+ /**
18
+ * 对变更文件执行所有已启用的静态规则检查。
19
+ * 返回系统问题列表和需要跳过 LLM 审查的文件集合。
20
+ */
21
+ export function applyStaticRules(
22
+ changedFiles: ChangedFile[],
23
+ fileContents: FileContentsMap,
24
+ staticRules: SystemRules | undefined,
25
+ round: number,
26
+ verbose?: VerboseLevel,
27
+ ): ApplyStaticRulesResult {
28
+ const staticIssues: ReviewIssue[] = [];
29
+ const skippedFiles = new Set<string>();
30
+
31
+ if (!staticRules) {
32
+ return { staticIssues, skippedFiles };
33
+ }
34
+
35
+ if (staticRules.maxLinesPerFile) {
36
+ const result = checkMaxLinesPerFile(
37
+ changedFiles,
38
+ fileContents,
39
+ staticRules.maxLinesPerFile,
40
+ round,
41
+ verbose,
42
+ );
43
+ staticIssues.push(...result.staticIssues);
44
+ result.skippedFiles.forEach((f) => skippedFiles.add(f));
45
+ }
46
+
47
+ return { staticIssues, skippedFiles };
48
+ }
@@ -0,0 +1,57 @@
1
+ import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
2
+ import { shouldLog } from "@spaceflow/core";
3
+ import type { ReviewIssue, FileContentsMap } from "../review-spec";
4
+ import type { Severity } from "../review.config";
5
+
6
+ export const RULE_ID = "system:max-lines-per-file";
7
+ export const SPEC_FILE = "__system__";
8
+
9
+ export interface MaxLinesPerFileResult {
10
+ staticIssues: ReviewIssue[];
11
+ /** 超限文件名集合,需从 LLM 审查中排除 */
12
+ skippedFiles: Set<string>;
13
+ }
14
+
15
+ export function checkMaxLinesPerFile(
16
+ changedFiles: ChangedFile[],
17
+ fileContents: FileContentsMap,
18
+ rule: [number, Severity],
19
+ round: number,
20
+ verbose?: VerboseLevel,
21
+ ): MaxLinesPerFileResult {
22
+ const [maxLine, severity] = rule;
23
+ const staticIssues: ReviewIssue[] = [];
24
+ const skippedFiles = new Set<string>();
25
+
26
+ if (maxLine <= 0) {
27
+ return { staticIssues, skippedFiles };
28
+ }
29
+
30
+ for (const file of changedFiles) {
31
+ if (file.status === "deleted" || !file.filename) continue;
32
+ const filename = file.filename;
33
+ const contentLines = fileContents.get(filename);
34
+ if (!contentLines || contentLines.length <= maxLine) continue;
35
+
36
+ if (shouldLog(verbose, 1)) {
37
+ console.log(
38
+ `⚠️ [system-rules/maxLinesPerFile] ${filename}: ${contentLines.length} 行超过限制 ${maxLine} 行,跳过 LLM 审查`,
39
+ );
40
+ }
41
+
42
+ skippedFiles.add(filename);
43
+ staticIssues.push({
44
+ file: filename,
45
+ line: "1",
46
+ code: "",
47
+ ruleId: RULE_ID,
48
+ specFile: SPEC_FILE,
49
+ reason: `文件共 ${contentLines.length} 行,超过静态规则限制 ${maxLine} 行,已跳过 LLM 审查。请考虑拆分文件或调大 staticRules.maxLinesPerFile 配置。`,
50
+ severity,
51
+ round,
52
+ date: new Date().toISOString(),
53
+ });
54
+ }
55
+
56
+ return { staticIssues, skippedFiles };
57
+ }
@@ -8,6 +8,8 @@ export interface FileReviewPrompt {
8
8
 
9
9
  export interface ReviewPrompt {
10
10
  filePrompts: FileReviewPrompt[];
11
+ /** 静态规则检查产生的系统问题,不经过 LLM 过滤管道,直接写入结果 */
12
+ staticIssues?: import("../review-spec").ReviewIssue[];
11
13
  }
12
14
 
13
15
  export interface LLMReviewOptions {
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildLinesWithNumbers, buildCommitsSection, extractCodeBlocks } from "./review-llm";
3
+
4
+ describe("utils/review-llm", () => {
5
+ describe("buildLinesWithNumbers", () => {
6
+ const lines: [string, string][] = [
7
+ ["abc1234", "line one"],
8
+ ["-------", "line two"],
9
+ ["abc1234", "line three"],
10
+ ];
11
+
12
+ it("无 visibleRanges 时输出全部行,带行号和 hash", () => {
13
+ expect(buildLinesWithNumbers(lines)).toBe(
14
+ "abc1234 1| line one\n------- 2| line two\nabc1234 3| line three",
15
+ );
16
+ });
17
+
18
+ it("行号宽度按总行数自动 pad(10 行时个位行号前有空格)", () => {
19
+ const tenLines: [string, string][] = Array.from({ length: 10 }, (_, i) => [
20
+ "abc1234",
21
+ `line ${i + 1}`,
22
+ ]);
23
+ const result = buildLinesWithNumbers(tenLines);
24
+ expect(result.split("\n")[0]).toContain(" 1|");
25
+ expect(result.split("\n")[9]).toContain("10|");
26
+ });
27
+
28
+ it("visibleRanges 为空数组时等价于无 visibleRanges,输出全部行", () => {
29
+ expect(buildLinesWithNumbers(lines, [])).toBe(buildLinesWithNumbers(lines));
30
+ });
31
+
32
+ it("只指定首行时,末尾被忽略区间产生 ignore 占位", () => {
33
+ const result = buildLinesWithNumbers(lines, [[1, 1]]);
34
+ expect(result).toBe("abc1234 1| line one\n....... ignore 2-3 line .......");
35
+ });
36
+
37
+ it("只指定末行时,开头被忽略区间产生 ignore 占位", () => {
38
+ const result = buildLinesWithNumbers(lines, [[3, 3]]);
39
+ expect(result).toBe("....... ignore 1-2 line .......\nabc1234 3| line three");
40
+ });
41
+
42
+ it("只指定中间行时,前后均产生 ignore 占位", () => {
43
+ const result = buildLinesWithNumbers(lines, [[2, 2]]);
44
+ expect(result).toBe(
45
+ "....... ignore 1-1 line .......\n------- 2| line two\n....... ignore 3-3 line .......",
46
+ );
47
+ });
48
+
49
+ it("多个不相邻范围各自产生 ignore 占位", () => {
50
+ const fiveLines: [string, string][] = [
51
+ ["abc1234", "a"],
52
+ ["-------", "b"],
53
+ ["abc1234", "c"],
54
+ ["-------", "d"],
55
+ ["abc1234", "e"],
56
+ ];
57
+ const result = buildLinesWithNumbers(fiveLines, [
58
+ [1, 1],
59
+ [5, 5],
60
+ ]);
61
+ expect(result).toBe("abc1234 1| a\n....... ignore 2-4 line .......\nabc1234 5| e");
62
+ });
63
+
64
+ it("重叠的 visibleRanges 合并后不产生 ignore 占位", () => {
65
+ const result = buildLinesWithNumbers(lines, [
66
+ [1, 2],
67
+ [2, 3],
68
+ ]);
69
+ expect(result).not.toContain("ignore");
70
+ expect(result.split("\n")).toHaveLength(3);
71
+ });
72
+
73
+ it("visibleRanges 超出文件范围时自动裁剪,不产生 ignore 占位", () => {
74
+ const result = buildLinesWithNumbers(lines, [[0, 100]]);
75
+ expect(result).not.toContain("ignore");
76
+ expect(result.split("\n")).toHaveLength(3);
77
+ });
78
+
79
+ it("visibleRanges 无序时按起始行号排序后正常输出", () => {
80
+ const result = buildLinesWithNumbers(lines, [
81
+ [3, 3],
82
+ [1, 1],
83
+ ]);
84
+ expect(result).toBe(
85
+ "abc1234 1| line one\n....... ignore 2-2 line .......\nabc1234 3| line three",
86
+ );
87
+ });
88
+ });
89
+
90
+ describe("extractCodeBlocks", () => {
91
+ it("types 为空时返回空数组", () => {
92
+ expect(extractCodeBlocks([["abc1234", "function foo() {}"]], [])).toEqual([]);
93
+ });
94
+
95
+ it("空 contentLines 返回空数组", () => {
96
+ expect(extractCodeBlocks([], ["function"])).toEqual([]);
97
+ });
98
+
99
+ it("全为上下文行时返回空数组", () => {
100
+ const lines: [string, string][] = [
101
+ ["-------", "function foo() { }"],
102
+ ["-------", "class Bar { }"],
103
+ ];
104
+ expect(extractCodeBlocks(lines, ["function", "class"])).toEqual([]);
105
+ });
106
+
107
+ it("提取新增的单行 function", () => {
108
+ expect(
109
+ extractCodeBlocks([["abc1234", "function foo() { return 1; }"]], ["function"]),
110
+ ).toEqual([[1, 1]]);
111
+ });
112
+
113
+ it("提取新增的多行 function", () => {
114
+ const lines: [string, string][] = [
115
+ ["abc1234", "function foo() {"],
116
+ ["abc1234", " return 1;"],
117
+ ["abc1234", "}"],
118
+ ];
119
+ expect(extractCodeBlocks(lines, ["function"])).toEqual([[1, 3]]);
120
+ });
121
+
122
+ it("提取 export function", () => {
123
+ expect(extractCodeBlocks([["abc1234", "export function hello() { }"]], ["function"])).toEqual(
124
+ [[1, 1]],
125
+ );
126
+ });
127
+
128
+ it("提取 async function", () => {
129
+ expect(extractCodeBlocks([["abc1234", "async function load() { }"]], ["function"])).toEqual([
130
+ [1, 1],
131
+ ]);
132
+ });
133
+
134
+ it("上下文行中的 function 不被提取,只提取新增行中的", () => {
135
+ const lines: [string, string][] = [
136
+ ["-------", "function foo() {"],
137
+ ["-------", " return 1;"],
138
+ ["-------", "}"],
139
+ ["abc1234", "function bar() { return 2; }"],
140
+ ];
141
+ expect(extractCodeBlocks(lines, ["function"])).toEqual([[4, 4]]);
142
+ });
143
+
144
+ it("提取多行 class 代码块", () => {
145
+ const lines: [string, string][] = [
146
+ ["abc1234", "class Foo {"],
147
+ ["abc1234", " bar() {}"],
148
+ ["abc1234", "}"],
149
+ ];
150
+ expect(extractCodeBlocks(lines, ["class"])).toEqual([[1, 3]]);
151
+ });
152
+
153
+ it("提取 export class", () => {
154
+ expect(extractCodeBlocks([["abc1234", "export class Bar { }"]], ["class"])).toEqual([[1, 1]]);
155
+ });
156
+
157
+ it("提取多行 interface", () => {
158
+ const lines: [string, string][] = [
159
+ ["abc1234", "export interface IFoo {"],
160
+ ["abc1234", " name: string;"],
161
+ ["abc1234", "}"],
162
+ ];
163
+ expect(extractCodeBlocks(lines, ["interface"])).toEqual([[1, 3]]);
164
+ });
165
+
166
+ it("提取 type 别名(含 =)", () => {
167
+ expect(
168
+ extractCodeBlocks([["abc1234", "export type MyType = string | number;"]], ["type"]),
169
+ ).toEqual([[1, 1]]);
170
+ });
171
+
172
+ it("提取泛型 type(含 <)", () => {
173
+ expect(extractCodeBlocks([["abc1234", "type Result<T> = { data: T };"]], ["type"])).toEqual([
174
+ [1, 1],
175
+ ]);
176
+ });
177
+
178
+ it("同时提取 function 和 class,中间上下文行不影响结果", () => {
179
+ const lines: [string, string][] = [
180
+ ["abc1234", "function foo() { }"],
181
+ ["-------", "const x = 1;"],
182
+ ["abc1234", "class Bar { }"],
183
+ ];
184
+ expect(extractCodeBlocks(lines, ["function", "class"])).toEqual([
185
+ [1, 1],
186
+ [3, 3],
187
+ ]);
188
+ });
189
+
190
+ it("相邻代码块合并为一个范围", () => {
191
+ const lines: [string, string][] = [
192
+ ["abc1234", "function a() {"],
193
+ ["abc1234", "}"],
194
+ ["abc1234", "function b() {"],
195
+ ["abc1234", "}"],
196
+ ];
197
+ expect(extractCodeBlocks(lines, ["function"])).toEqual([[1, 4]]);
198
+ });
199
+
200
+ it("嵌套括号时找到最外层封闭括号作为结尾", () => {
201
+ const lines: [string, string][] = [
202
+ ["abc1234", "function outer() {"],
203
+ ["abc1234", " if (true) {"],
204
+ ["abc1234", " inner();"],
205
+ ["abc1234", " }"],
206
+ ["abc1234", "}"],
207
+ ];
208
+ expect(extractCodeBlocks(lines, ["function"])).toEqual([[1, 5]]);
209
+ });
210
+
211
+ it("method:public 修饰的方法识别为完整代码块", () => {
212
+ const lines: [string, string][] = [
213
+ ["abc1234", " public getName() {"],
214
+ ["abc1234", " return this.name;"],
215
+ ["abc1234", " }"],
216
+ ];
217
+ expect(extractCodeBlocks(lines, ["method"])).toEqual([[1, 3]]);
218
+ });
219
+
220
+ it("method:async 方法识别为完整代码块", () => {
221
+ const lines: [string, string][] = [
222
+ ["abc1234", " async fetchData() {"],
223
+ ["abc1234", " return await api();"],
224
+ ["abc1234", " }"],
225
+ ];
226
+ expect(extractCodeBlocks(lines, ["method"])).toHaveLength(1);
227
+ });
228
+ });
229
+
230
+ describe("buildCommitsSection", () => {
231
+ const lines: [string, string][] = [
232
+ ["abc1234", "const x = 1;"],
233
+ ["-------", "const y = 2;"],
234
+ ["def5678", "const z = 3;"],
235
+ ];
236
+
237
+ it("有匹配 commit 时返回 markdown 列表(- `hash` 首行消息)", () => {
238
+ const commits = [
239
+ { sha: "abc1234abcdef", commit: { message: "feat: add x\n详细说明" } },
240
+ { sha: "def5678abcdef", commit: { message: "fix: fix z" } },
241
+ ] as any;
242
+ const result = buildCommitsSection(lines, commits);
243
+ expect(result).toContain("- `abc1234` feat: add x");
244
+ expect(result).toContain("- `def5678` fix: fix z");
245
+ });
246
+
247
+ it("commit 消息只取第一行,忽略后续内容", () => {
248
+ const commits = [
249
+ { sha: "abc1234abc", commit: { message: "first line\nsecond line\nthird line" } },
250
+ ] as any;
251
+ expect(buildCommitsSection(lines, commits)).toBe("- `abc1234` first line");
252
+ });
253
+
254
+ it("没有匹配 commit 时返回默认文案", () => {
255
+ const commits = [{ sha: "xxxxxxxxxxxxxxx", commit: { message: "unrelated" } }] as any;
256
+ expect(buildCommitsSection(lines, commits)).toBe("- 无相关 commits");
257
+ });
258
+
259
+ it("commits 为空数组时返回默认文案", () => {
260
+ expect(buildCommitsSection(lines, [])).toBe("- 无相关 commits");
261
+ });
262
+
263
+ it("contentLines 全为上下文行时返回默认文案", () => {
264
+ const ctxLines: [string, string][] = [
265
+ ["-------", "line 1"],
266
+ ["-------", "line 2"],
267
+ ];
268
+ const commits = [{ sha: "abc1234abc", commit: { message: "msg" } }] as any;
269
+ expect(buildCommitsSection(ctxLines, commits)).toBe("- 无相关 commits");
270
+ });
271
+
272
+ it("commit sha 为 undefined 时不崩溃,返回默认文案", () => {
273
+ const commits = [{ sha: undefined, commit: { message: "msg" } }] as any;
274
+ expect(buildCommitsSection(lines, commits)).toBe("- 无相关 commits");
275
+ });
276
+ });
277
+ });