@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.
- package/CHANGELOG.md +35 -0
- package/dist/index.js +1060 -481
- package/package.json +1 -1
- package/src/deletion-impact.service.ts +13 -128
- package/src/issue-verify.service.ts +18 -82
- package/src/mcp/index.ts +4 -1
- package/src/prompt/code-review.ts +95 -0
- package/src/prompt/deletion-impact.ts +105 -0
- package/src/prompt/index.ts +37 -0
- package/src/prompt/issue-verify.ts +86 -0
- package/src/prompt/pr-description.ts +149 -0
- package/src/prompt/schemas.ts +106 -0
- package/src/prompt/types.ts +53 -0
- package/src/review-context.ts +29 -5
- package/src/review-includes-filter.spec.ts +36 -0
- package/src/review-includes-filter.ts +59 -7
- package/src/review-issue-filter.ts +1 -1
- package/src/review-llm.ts +116 -207
- package/src/review-result-model.ts +28 -6
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review.config.ts +31 -5
- package/src/review.service.spec.ts +11 -1
- package/src/review.service.ts +105 -5
- package/src/system-rules/index.ts +48 -0
- package/src/system-rules/max-lines-per-file.ts +57 -0
- package/src/types/review-llm.ts +2 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +152 -7
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/{review-pr-comment-utils.ts → utils/review-pr-comment.ts} +2 -2
package/src/review.service.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
294
|
-
const
|
|
295
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/types/review-llm.ts
CHANGED
|
@@ -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
|
+
});
|